feat: 第四轮功能增强 - LLM 思维记忆优化、DevTools 记忆UI、9个新工具、5分钟自我思考
- 优化 LLM 思维方式和记忆方法(类别/重要性/关键词/相似度合并/衰减) - DevTools 记忆查询 UI 重新设计(类别筛选/排序/星标/搜索) - 新增 9 个 LLM 工具:calculator, datetime, file_ops, http_request, json_ops, text, random, crypto, markdown - 管理员主对话 5 分钟自我思考增强(工具调用/记忆提取/记忆维护)
This commit is contained in:
@@ -2,13 +2,16 @@ package background
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||
@@ -23,7 +26,7 @@ type PendingThought struct {
|
||||
Consumed bool `json:"consumed"`
|
||||
}
|
||||
|
||||
// Thinker 后台思考器
|
||||
// Thinker 后台思考器(增强版:支持工具调用、记忆管理、5分钟定时循环)
|
||||
type Thinker struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
@@ -35,6 +38,18 @@ type Thinker struct {
|
||||
thinkInterval time.Duration // 两次思考最小间隔
|
||||
iotQueryInterval time.Duration // IoT查询最小间隔
|
||||
|
||||
// 新增字段:记忆管理
|
||||
memoryStore *memory.Store // 直接操作记忆(衰减、合并)
|
||||
memoryExtractor *memory.Extractor // 从思考结果中提取记忆
|
||||
|
||||
// 新增字段:工具调用
|
||||
toolRegistry *tools.Registry // 工具注册中心
|
||||
|
||||
// 新增字段:会话上下文
|
||||
convStore *ctxbuild.ConversationStore // 管理员对话历史
|
||||
adminUserID string // 管理员用户 ID
|
||||
adminSessionID string // 管理员主对话 session ID
|
||||
|
||||
pendingThoughts []*PendingThought
|
||||
|
||||
lastUserMessage time.Time
|
||||
@@ -62,13 +77,19 @@ func DefaultThinkerConfig() ThinkerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// NewThinker 创建后台思考器
|
||||
// NewThinker 创建增强版后台思考器
|
||||
func NewThinker(
|
||||
cfg ThinkerConfig,
|
||||
personaLoader *persona.Loader,
|
||||
memRetriever *memory.Retriever,
|
||||
llmAdapter *llm.Adapter,
|
||||
iotClient *tools.IoTClient,
|
||||
memoryStore *memory.Store,
|
||||
memoryExtractor *memory.Extractor,
|
||||
toolRegistry *tools.Registry,
|
||||
convStore *ctxbuild.ConversationStore,
|
||||
adminUserID string,
|
||||
adminSessionID string,
|
||||
) *Thinker {
|
||||
return &Thinker{
|
||||
enabled: cfg.Enabled,
|
||||
@@ -79,13 +100,19 @@ func NewThinker(
|
||||
idleTimeout: cfg.IdleTimeout,
|
||||
thinkInterval: cfg.ThinkInterval,
|
||||
iotQueryInterval: cfg.IoTQueryInterval,
|
||||
memoryStore: memoryStore,
|
||||
memoryExtractor: memoryExtractor,
|
||||
toolRegistry: toolRegistry,
|
||||
convStore: convStore,
|
||||
adminUserID: adminUserID,
|
||||
adminSessionID: adminSessionID,
|
||||
pendingThoughts: make([]*PendingThought, 0),
|
||||
lastUserMessage: time.Now(),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动后台思考循环
|
||||
// Start 启动后台思考循环(5分钟定时器)
|
||||
func (t *Thinker) Start() {
|
||||
if !t.enabled {
|
||||
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
|
||||
@@ -94,8 +121,8 @@ func (t *Thinker) Start() {
|
||||
|
||||
t.wg.Add(1)
|
||||
go t.loop()
|
||||
log.Printf("[后台思考] 已启动 (闲置超时=%v, 思考间隔=%v, IoT查询间隔=%v)",
|
||||
t.idleTimeout, t.thinkInterval, t.iotQueryInterval)
|
||||
log.Printf("[后台思考] 已启动 (思考间隔=%v, IoT查询间隔=%v, 管理员=%s)",
|
||||
t.thinkInterval, t.iotQueryInterval, t.adminUserID)
|
||||
}
|
||||
|
||||
// Stop 停止后台思考
|
||||
@@ -105,7 +132,7 @@ func (t *Thinker) Stop() {
|
||||
log.Println("[后台思考] 已停止")
|
||||
}
|
||||
|
||||
// RecordUserMessage 记录用户活动时间
|
||||
// RecordUserMessage 记录用户活动时间(管理员对话时调用)
|
||||
func (t *Thinker) RecordUserMessage() {
|
||||
t.mu.Lock()
|
||||
t.lastUserMessage = time.Now()
|
||||
@@ -138,137 +165,351 @@ func (t *Thinker) HasPendingThoughts() bool {
|
||||
return len(t.pendingThoughts) > 0
|
||||
}
|
||||
|
||||
// loop 后台主循环
|
||||
// loop 后台主循环(5分钟定时器)
|
||||
func (t *Thinker) loop() {
|
||||
defer t.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second) // 每10秒检查一次
|
||||
// 启动后等待 10 秒再执行首次思考(让服务完全就绪)
|
||||
initialDelay := time.NewTimer(10 * time.Second)
|
||||
ticker := time.NewTicker(t.thinkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 思考计数器(用于周期性记忆维护)
|
||||
thinkCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
initialDelay.Stop()
|
||||
return
|
||||
case <-initialDelay.C:
|
||||
t.performThink()
|
||||
thinkCount++
|
||||
t.maybeMaintainMemories(thinkCount)
|
||||
case <-ticker.C:
|
||||
t.checkAndThink()
|
||||
t.performThink()
|
||||
thinkCount++
|
||||
t.maybeMaintainMemories(thinkCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndThink 检查是否需要触发思考
|
||||
func (t *Thinker) checkAndThink() {
|
||||
t.mu.Lock()
|
||||
|
||||
// 检查空闲时间是否超过阈值
|
||||
idleDuration := time.Since(t.lastUserMessage)
|
||||
if idleDuration < t.idleTimeout {
|
||||
t.mu.Unlock()
|
||||
// maybeMaintainMemories 周期性执行记忆维护(每6次思考约30分钟)
|
||||
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
|
||||
if thinkCount%6 != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查距离上次思考是否超过最小间隔
|
||||
if time.Since(t.lastThinkTime) < t.thinkInterval {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
t.lastThinkTime = time.Now()
|
||||
t.mu.Unlock()
|
||||
|
||||
// 执行后台思考(不持锁)
|
||||
t.performThink()
|
||||
}
|
||||
|
||||
// performThink 执行一次后台思考
|
||||
func (t *Thinker) performThink() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 加载人格配置
|
||||
if t.memoryStore != nil && t.memoryStore.IsReady() {
|
||||
// 衰减旧记忆
|
||||
if err := t.memoryStore.DecayMemories(ctx, t.adminUserID); err != nil {
|
||||
log.Printf("[后台思考] 记忆衰减失败: %v", err)
|
||||
}
|
||||
|
||||
// 合并相似记忆
|
||||
if err := t.memoryStore.ConsolidateMemories(ctx, t.adminUserID); err != nil {
|
||||
log.Printf("[后台思考] 记忆合并失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
|
||||
func (t *Thinker) performThink() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Println("[后台思考] 开始执行思考周期...")
|
||||
|
||||
// 1. 加载人格配置
|
||||
personaConfig, err := t.personaLoader.Get("cyrene")
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] 加载人格失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检索最近的记忆
|
||||
// 2. 检索相关记忆
|
||||
var memories []memory.MemoryEntry
|
||||
if t.memRetriever != nil {
|
||||
memories, err = t.memRetriever.Retrieve(ctx, "system", "最近发生了什么 重要的事情")
|
||||
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] 记忆检索失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询 IoT 设备状态(节制)
|
||||
// 3. 获取管理员对话历史
|
||||
var convHistory []model.LLMMessage
|
||||
if t.convStore != nil && t.adminSessionID != "" {
|
||||
convHistory = t.convStore.GetHistory(t.adminSessionID, 30)
|
||||
if len(convHistory) > 0 {
|
||||
log.Printf("[后台思考] 加载管理员对话历史 %d 条", len(convHistory))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查询 IoT 设备状态(节制)
|
||||
var deviceSummary string
|
||||
if t.iotClient != nil && time.Since(t.lastIoTQuery) >= t.iotQueryInterval {
|
||||
devices := t.iotClient.GetDevicesForContext()
|
||||
if len(devices) > 0 {
|
||||
deviceSummary = formatDeviceContext(devices)
|
||||
}
|
||||
if t.iotClient != nil {
|
||||
t.mu.Lock()
|
||||
t.lastIoTQuery = time.Now()
|
||||
canQuery := time.Since(t.lastIoTQuery) >= t.iotQueryInterval
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// 构建思考提示
|
||||
systemPrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
|
||||
|
||||
memoryContext := ""
|
||||
if len(memories) > 0 {
|
||||
memoryContext = "【最近的记忆】\n"
|
||||
for _, m := range memories {
|
||||
if len(memoryContext)+len(m.Content) > 500 {
|
||||
break // 限制记忆上下文长度
|
||||
if canQuery {
|
||||
devices := t.iotClient.GetDevicesForContext()
|
||||
if len(devices) > 0 {
|
||||
deviceSummary = formatDeviceContext(devices)
|
||||
}
|
||||
memoryContext += fmt.Sprintf("- %s\n", m.Content)
|
||||
t.mu.Lock()
|
||||
t.lastIoTQuery = time.Now()
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
userPrompt := "昔涟,现在是你的后台思考时间。开拓者暂时没有说话。"
|
||||
userPrompt += "\n请你基于以下信息进行简短思考:你注意到了什么?有什么想对开拓者说的吗?"
|
||||
userPrompt += "\n注意:这是内部思考,不是直接对话,请以第三人称或自省的方式思考。"
|
||||
// 5. 构建思考提示词
|
||||
systemPrompt := t.buildThinkingSystemPrompt(personaConfig)
|
||||
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary)
|
||||
|
||||
if memoryContext != "" {
|
||||
userPrompt += "\n\n" + memoryContext
|
||||
}
|
||||
if deviceSummary != "" {
|
||||
userPrompt += "\n\n" + deviceSummary
|
||||
}
|
||||
|
||||
// 调用 LLM
|
||||
messages := []model.LLMMessage{
|
||||
{Role: model.RoleSystem, Content: systemPrompt},
|
||||
{Role: model.RoleUser, Content: userPrompt},
|
||||
}
|
||||
|
||||
resp, err := t.llmAdapter.Chat(ctx, messages)
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] LLM调用失败: %v", err)
|
||||
// 6. 准备工具定义
|
||||
openAITools := t.buildOpenAITools()
|
||||
|
||||
// 7. 调用 LLM(支持工具调用,最多 3 轮)
|
||||
maxToolRounds := 3
|
||||
var finalContent string
|
||||
var totalToolCalls int
|
||||
|
||||
for round := 0; round <= maxToolRounds; round++ {
|
||||
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] LLM调用失败 (round=%d): %v", round, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 LLM 没有请求工具调用,这就是最终回复
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
finalContent = resp.Content
|
||||
break
|
||||
}
|
||||
|
||||
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
|
||||
|
||||
// 将助手消息(含工具调用)加入上下文
|
||||
assistantMsg := model.LLMMessage{
|
||||
Role: model.RoleAssistant,
|
||||
Content: resp.Content,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
ReasoningContent: resp.ReasoningContent,
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
// 执行每个工具调用
|
||||
for _, tc := range resp.ToolCalls {
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
|
||||
log.Printf("[后台思考] 工具 %s 参数解析失败: %v", tc.Name, err)
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
result, execErr := t.toolRegistry.Execute(ctx, tc.Name, args)
|
||||
if execErr != nil {
|
||||
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
messages = append(messages, model.LLMMessage{
|
||||
Role: model.RoleTool,
|
||||
Content: string(resultJSON),
|
||||
ToolCallID: tc.ID,
|
||||
})
|
||||
|
||||
totalToolCalls++
|
||||
}
|
||||
|
||||
// 最后一轮:即使有 tool_calls 也强制停止
|
||||
if round == maxToolRounds {
|
||||
// 再做一次不带工具的调用获取最终总结
|
||||
finalResp, finalErr := t.llmAdapter.Chat(ctx, messages)
|
||||
if finalErr != nil {
|
||||
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
|
||||
finalContent = resp.Content
|
||||
} else {
|
||||
finalContent = finalResp.Content
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if finalContent == "" {
|
||||
log.Println("[后台思考] 未获得有效思考内容,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
return
|
||||
// 8. 存储思考结果
|
||||
t.storeThought(finalContent)
|
||||
|
||||
log.Printf("[后台思考] 完成 (内容长度=%d, 工具调用=%d次)", len(finalContent), totalToolCalls)
|
||||
|
||||
// 9. 从思考结果中提取记忆(异步)
|
||||
if t.memoryExtractor != nil {
|
||||
go t.extractMemoriesFromThinking(finalContent)
|
||||
}
|
||||
}
|
||||
|
||||
// buildThinkingSystemPrompt 构建思考用的系统提示词
|
||||
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig) string {
|
||||
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
|
||||
|
||||
thinkingInstructions := `
|
||||
|
||||
【后台思考模式】
|
||||
|
||||
你正在进行定期的自我反思。开拓者暂时没有说话,这是你主动思考和整理信息的时间。
|
||||
|
||||
请完成以下内省任务:
|
||||
|
||||
1. **回顾最近的对话**:总结开拓者表达了什么、情绪如何、有什么新信息值得关注
|
||||
2. **评估现有记忆**:基于记忆检索结果,判断哪些记忆仍然相关、哪些可能需要更新
|
||||
3. **识别重要信息**:是否有值得保存的用户偏好、个人信息、计划任务、关系信息等
|
||||
4. **使用工具获取信息**:如果需要了解当前时间、搜索实时信息等,请使用可用工具
|
||||
5. **记忆操作建议**:判断是否需要创建新记忆、更新旧记忆或合并重复记忆
|
||||
|
||||
完成反思后,请输出结构化的思考总结,包含:
|
||||
- **关键洞察**:从最近对话中提炼的核心发现
|
||||
- **记忆更新建议**:需要创建/更新/合并的记忆条目
|
||||
- **下次关注事项**:下次思考时需要跟进的话题或任务
|
||||
|
||||
注意:
|
||||
- 这是内部思考,不是直接与开拓者对话
|
||||
- 请以自省和观察的方式思考,不要用"你"来称呼开拓者
|
||||
- 有机会就使用工具获取实时信息(如当前时间)
|
||||
- 思考要简洁有深度,不需要太长`
|
||||
|
||||
return basePrompt + thinkingInstructions
|
||||
}
|
||||
|
||||
// buildThinkingUserPrompt 构建思考用的用户提示词
|
||||
func (t *Thinker) buildThinkingUserPrompt(
|
||||
memories []memory.MemoryEntry,
|
||||
convHistory []model.LLMMessage,
|
||||
deviceSummary string,
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("现在是你的后台思考时间。请基于以下信息进行深度反思。\n")
|
||||
|
||||
// 对话历史
|
||||
if len(convHistory) > 0 {
|
||||
sb.WriteString("\n【最近的对话历史】\n")
|
||||
msgCount := 0
|
||||
for _, msg := range convHistory {
|
||||
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
|
||||
roleLabel := "开拓者"
|
||||
if msg.Role == model.RoleAssistant {
|
||||
roleLabel = "昔涟"
|
||||
}
|
||||
content := msg.Content
|
||||
runes := []rune(content)
|
||||
if len(runes) > 200 {
|
||||
content = string(runes[:200]) + "…"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s]: %s\n", roleLabel, content))
|
||||
msgCount++
|
||||
}
|
||||
}
|
||||
if msgCount == 0 {
|
||||
sb.WriteString("(暂无对话历史)\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("\n【最近的对话历史】\n(暂无对话历史,这是首次思考或对话历史为空)\n")
|
||||
}
|
||||
|
||||
// 存储思考结果
|
||||
// 现有记忆
|
||||
if len(memories) > 0 {
|
||||
sb.WriteString("\n【现有相关记忆】\n")
|
||||
for i, m := range memories {
|
||||
if i >= 15 {
|
||||
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆未列出\n", len(memories)-15))
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- [%s|重要度%d] %s\n",
|
||||
m.Category.DisplayName(), m.Importance, m.Content))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("\n【现有相关记忆】\n(暂无相关记忆)\n")
|
||||
}
|
||||
|
||||
// IoT 设备状态
|
||||
if deviceSummary != "" {
|
||||
sb.WriteString("\n" + deviceSummary)
|
||||
}
|
||||
|
||||
sb.WriteString("\n请开始你的后台思考。如果需要获取当前时间或搜索信息,请使用可用工具。")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
|
||||
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
|
||||
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
defs := t.toolRegistry.GetDefinitions()
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]llm.OpenAITool, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
result = append(result, llm.OpenAITool{
|
||||
Type: "function",
|
||||
Function: llm.OpenAIToolFunc{
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
Parameters: d.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// storeThought 存储思考结果到待推送队列
|
||||
func (t *Thinker) storeThought(content string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
|
||||
Content: resp.Content,
|
||||
Content: content,
|
||||
CreatedAt: time.Now(),
|
||||
Consumed: false,
|
||||
})
|
||||
|
||||
// 只保留最近5条
|
||||
if len(t.pendingThoughts) > 5 {
|
||||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-5:]
|
||||
// 只保留最近 10 条
|
||||
if len(t.pendingThoughts) > 10 {
|
||||
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
|
||||
}
|
||||
count := len(t.pendingThoughts)
|
||||
t.mu.Unlock()
|
||||
|
||||
log.Printf("[后台思考] 完成 (当前累积 %d 条待推送思考)", count)
|
||||
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
|
||||
}
|
||||
|
||||
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
|
||||
func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Println("[后台思考] 开始从思考结果中提取记忆...")
|
||||
|
||||
// 使用 memoryExtractor.ExtractAndStore 提取记忆
|
||||
// 将思考内容作为"昔涟的自省"传递给提取器
|
||||
t.memoryExtractor.ExtractAndStore(
|
||||
ctx,
|
||||
t.adminUserID,
|
||||
t.adminSessionID,
|
||||
"【系统触发】后台思考时间 — 昔涟进行了自我反思,以下是她的思考内容",
|
||||
thinkingContent,
|
||||
)
|
||||
}
|
||||
|
||||
// formatDeviceContext 格式化设备状态为文本
|
||||
|
||||
Reference in New Issue
Block a user