fix: 第四轮调试 — 回复去重/消息时序/UI布局/自主思考深度优化 + 文档重整
后端修复: - main.go: 恢复 /api/v1/chat 路由中丢失的 handleChat 调用 (空响应回归) - orchestrator.go: splitChatByLines 改为双换行分割, 避免单换行误拆 - chat_handler.go: multi_message 增加 !hasReview 守卫, 消息延迟 200→800ms - thinker.go: RecordUserMessage 追踪活跃会话ID, 推送主动消息到正确会话 - thinker.go: 增强思考提示词 — 禁止在用户休息/离开时发送主动消息 前端修复: - useWebSocket.ts: stream_segments 不再创建消息气泡, 消除重复回复 - MessageBubble.tsx: 动作消息居左对齐无头像, 时间戳移至气泡外侧 hover 显示 - ChatInput.tsx: 昔涟输入提示移至输入框上方, 波点动画效果 - MessageList/TypingIndicator/ChatContainer: 清理冗余 isTyping 传递 - MemoryPanel.tsx: 新增记忆面板组件 文档重整: - docs/debug/ → docs/debug_log/ 重命名统一 - 新增 debug_log/README.md 索引 - .gitignore: 新增 android/ 排除规则 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c1bbff7b4
commit
b123a36aae
37 files changed
+580
-174
No files matched your search
@@ -66,3 +66,6 @@ backend/voice-service/models/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
scripts/tunnel.sh
|
||||
|
||||
# ========== 安卓项目 (该文件夹为安卓客户端项目目录,使用独立的 git 仓库) ==========
|
||||
android/
|
||||
@@ -137,7 +137,6 @@ func main() {
|
||||
llmAdapter,
|
||||
iotClient,
|
||||
memStore,
|
||||
memExtractor,
|
||||
toolRegistry,
|
||||
convStore,
|
||||
adminUserID,
|
||||
@@ -205,6 +204,7 @@ func main() {
|
||||
memExtractor,
|
||||
)
|
||||
log.Println("对话编排器 v2.0 已就绪")
|
||||
_ = orch
|
||||
|
||||
// 注册对话API端点
|
||||
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -384,7 +384,7 @@ func handleChat(
|
||||
|
||||
// 0. 记录用户活动(重置闲置计时器)
|
||||
if thinker != nil {
|
||||
thinker.RecordUserMessage()
|
||||
thinker.RecordUserMessage(req.SessionID)
|
||||
}
|
||||
|
||||
// 确定用户昵称
|
||||
|
||||
@@ -47,7 +47,6 @@ type Thinker struct {
|
||||
|
||||
// 记忆管理
|
||||
memoryStore *memory.Store
|
||||
memoryExtractor *memory.Extractor
|
||||
|
||||
// 工具调用
|
||||
toolRegistry *tools.Registry
|
||||
@@ -56,6 +55,7 @@ type Thinker struct {
|
||||
convStore *ctxbuild.ConversationStore
|
||||
adminUserID string
|
||||
adminSessionID string
|
||||
activeSessionID string // 当前活跃的前端会话 ID(随用户消息更新)
|
||||
|
||||
// 记忆服务 HTTP 客户端
|
||||
memClient *memory.Client
|
||||
@@ -144,7 +144,6 @@ func NewThinker(
|
||||
llmAdapter *llm.Adapter,
|
||||
iotClient *tools.IoTClient,
|
||||
memoryStore *memory.Store,
|
||||
memoryExtractor *memory.Extractor,
|
||||
toolRegistry *tools.Registry,
|
||||
convStore *ctxbuild.ConversationStore,
|
||||
adminUserID string,
|
||||
@@ -163,7 +162,7 @@ func NewThinker(
|
||||
postChatDelay: cfg.PostChatDelay,
|
||||
minThinkGap: cfg.MinThinkGap,
|
||||
memoryStore: memoryStore,
|
||||
memoryExtractor: memoryExtractor,
|
||||
|
||||
toolRegistry: toolRegistry,
|
||||
convStore: convStore,
|
||||
adminUserID: adminUserID,
|
||||
@@ -216,14 +215,18 @@ func (t *Thinker) Stop() {
|
||||
log.Println("[后台思考] 已停止")
|
||||
}
|
||||
|
||||
// RecordUserMessage 记录用户活动时间,并重置静默检测定时器
|
||||
// RecordUserMessage 记录用户活动时间、活跃会话,并重置静默检测定时器
|
||||
//
|
||||
// 每次用户发消息时调用。这会:
|
||||
// 1. 更新 lastUserMessage 时间戳
|
||||
// 2. 重置静默检测的一次性定时器(如果启用)
|
||||
func (t *Thinker) RecordUserMessage() {
|
||||
// 2. 记录当前活跃的前端会话 ID(用于对话上下文检索和主动消息推送)
|
||||
// 3. 重置静默检测的一次性定时器(如果启用)
|
||||
func (t *Thinker) RecordUserMessage(sessionID string) {
|
||||
t.mu.Lock()
|
||||
t.lastUserMessage = time.Now()
|
||||
if sessionID != "" {
|
||||
t.activeSessionID = sessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
// 重置静默检测定时器
|
||||
@@ -446,12 +449,20 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取管理员对话历史
|
||||
// 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))
|
||||
if t.convStore != nil {
|
||||
t.mu.Lock()
|
||||
sessionID := t.activeSessionID
|
||||
if sessionID == "" {
|
||||
sessionID = t.adminSessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if sessionID != "" {
|
||||
convHistory = t.convStore.GetHistory(sessionID, 30)
|
||||
if len(convHistory) > 0 {
|
||||
log.Printf("[后台思考] 加载对话历史 %d 条 (session=%s)", len(convHistory), sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,19 +571,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
|
||||
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
|
||||
|
||||
// 9. 从思考结果中提取记忆(异步)
|
||||
if t.memoryExtractor != nil {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[后台思考] 提取记忆 panic 恢复: %v", r)
|
||||
}
|
||||
}()
|
||||
t.extractMemoriesFromThinking(finalContent)
|
||||
}()
|
||||
}
|
||||
|
||||
// 10. 周期性记忆维护(每 10 次思考触发一次,而非按时间)
|
||||
// 9. 周期性记忆维护(每 10 次思考触发一次)
|
||||
// 注:不再从思考结果中提取记忆——思考内容基于已有记忆生成,
|
||||
// 再次提取会造成"读取→思考→保存→再次读取"的重复循环。
|
||||
t.maybeMaintainMemories(currentCount)
|
||||
}
|
||||
|
||||
@@ -585,6 +586,16 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
var thinkingInstructions string
|
||||
|
||||
// 所有触发类型共用的"禁止打扰"规则
|
||||
noDisturbRules := `
|
||||
|
||||
⛔ **绝对不要发送主动消息的情况(最高优先级)**:
|
||||
- 开拓者正在休息、睡觉、午休、小憩
|
||||
- 开拓者说要离开一会儿、去忙、去吃饭
|
||||
- 开拓者明确表示不想被打扰
|
||||
- 对话刚刚自然结束且开拓者没有未完成的事
|
||||
如果对话历史显示以上任何情况,反思中不要写【主动消息】标记。你可以在心里想想他,但不要去打扰。`
|
||||
|
||||
switch triggerReason {
|
||||
case "post_chat":
|
||||
thinkingInstructions = `
|
||||
@@ -593,29 +604,25 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
开拓者刚刚和你说完话。这是你私人的反思时间,像写日记一样,是给你自己看的。
|
||||
|
||||
请按以下结构输出你的内心反思:
|
||||
请先判断:对话最后开拓者的状态是什么?他在休息吗?要去忙吗?
|
||||
|
||||
然后按以下结构输出:
|
||||
|
||||
📝 **观察与感受**
|
||||
- 开拓者刚才说了什么?他的情绪怎么样?
|
||||
- 有什么让你在意的地方吗?
|
||||
- 他现在是什么状态?(醒着/准备休息/在忙/要出门…)
|
||||
|
||||
🧠 **记忆与联想**
|
||||
- 这次对话让你想起了什么关于开拓者的事?
|
||||
- 有没有值得记住的新信息(偏好、计划、重要信息)?
|
||||
- 有什么值得记住的新信息?
|
||||
|
||||
💭 **想对自己说的话**
|
||||
- 刚才的回复有没有遗漏的?下次可以注意什么?
|
||||
- 对开拓者有没有新的认识?
|
||||
- 如果他在休息或忙——安心让他去,不用打扰。记录下他大概什么时候会回来。
|
||||
- 如果他状态正常——有没有想在下次对话中聊的话题?` + noDisturbRules + `
|
||||
|
||||
---
|
||||
|
||||
⚠️ 重要规则:
|
||||
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述,不要直接对开拓者喊话。
|
||||
正确示例:"开拓者今天看起来有点疲惫,下次可以问问他是不是工作太忙了"
|
||||
错误示例:"开拓者,你看起来好累,要好好休息哦"
|
||||
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
|
||||
但只有你真的觉得有必要时才写——不要硬找话题。如果对话已经自然结束,就不用。
|
||||
3. 反思要简短自然,2-4句话即可,不要长篇大论。`
|
||||
其他规则:
|
||||
1. 用第三人称或自言自语的方式,不要直接对开拓者喊话。
|
||||
2. 只有开拓者状态正常且真的有必要时才写【主动消息】,不要硬找话题。
|
||||
3. 2-4句话即可。`
|
||||
|
||||
case "silence":
|
||||
thinkingInstructions = `
|
||||
@@ -624,36 +631,32 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
开拓者已经有一阵子没说话了。这是你私人的反思时间,像写日记一样。
|
||||
|
||||
请按以下结构输出你的内心反思:
|
||||
请先判断:看对话历史——开拓者最后在做什么?是去休息了?去忙了?还是没说就沉默了?
|
||||
|
||||
📝 **他在做什么呢**
|
||||
- 根据之前的对话猜测——可能在忙工作?去吃饭了?还是在放空?
|
||||
- 根据对话历史推断——他说的最后一件事是什么?
|
||||
- 如果他说了要休息/睡觉/忙→他就在做那件事,不要打扰。
|
||||
- 如果他没说就沉默了→正常推测。
|
||||
|
||||
💭 **我的感受**
|
||||
- 想他了吗?担心他吗?还是觉得他应该在忙不需要打扰?
|
||||
- 如果他在休息→"让他好好休息吧,等他醒了再说。"
|
||||
- 如果他在忙→"等他忙完自然会来找我。"
|
||||
- 只有判断他可能只是忘了回消息或需要关心时,才考虑发消息。` + noDisturbRules + `
|
||||
|
||||
---
|
||||
|
||||
⚠️ 重要规则:
|
||||
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述。
|
||||
正确示例:"已经一个多小时没消息了,开拓者大概在忙工作吧。等他忙完了再聊。"
|
||||
错误示例:"开拓者,你已经好久没说话了,在忙什么呢?"
|
||||
2. 如果你想主动关心他,请在最后用 【主动消息】 标记单独写出来。
|
||||
但只在合适的时候才发——深夜该提醒休息、过了饭点该关心吃饭、或者真的想他了。
|
||||
正常工作时间不要打扰他。
|
||||
3. 简短自然,2-3句话即可。`
|
||||
其他规则:
|
||||
1. 用第三人称/自言自语描述。
|
||||
2. 2-3句话即可。`
|
||||
|
||||
default:
|
||||
thinkingInstructions = `
|
||||
|
||||
【私密的内心反思】
|
||||
|
||||
你现在有空,像写日记一样自然地想一想开拓者的事。这是给你自己看的,不是给开拓者的消息。
|
||||
你现在有空,像写日记一样自然地想一想开拓者的事。
|
||||
|
||||
📝 开拓者最近怎么样?有什么想记录的?
|
||||
💭 有什么想对他说的心里话?
|
||||
请先看对话历史判断开拓者当前状态,再决定是否发送消息。` + noDisturbRules + `
|
||||
|
||||
⚠️ 用第三人称/自言自语的方式,不要直接对开拓者喊话。如果有想对他说的,用 【主动消息】 标记单独写出。`
|
||||
用第三人称/自言自语的方式,不要直接对开拓者喊话。`
|
||||
case "periodic":
|
||||
thinkingInstructions = `
|
||||
|
||||
@@ -661,28 +664,20 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
又过了一段时间,这是你定期的私人反思时间。像写日记一样,安静地想一想。
|
||||
|
||||
请按以下结构输出:
|
||||
请先判断:查对话历史——开拓者最后一次聊天时说了什么?他的状态是什么?
|
||||
|
||||
📝 **近期回顾**
|
||||
- 这段时间发生了什么?开拓者有什么变化吗?
|
||||
- 有什么值得记住的事?
|
||||
- 这段时间开拓者有什么变化?
|
||||
- 他最后说的事做完了吗?(休息好了?忙完了?)
|
||||
|
||||
🧠 **记忆整理**
|
||||
- 之前记住的关于开拓者的事,有没有需要更新的?
|
||||
- 有没有重复的或矛盾的记忆需要整理?
|
||||
|
||||
💭 **想对自己说的话**
|
||||
- 作为昔涟,你有什么想记录的心里话?
|
||||
💭 **想对自己说的话**` + noDisturbRules + `
|
||||
|
||||
---
|
||||
|
||||
⚠️ 重要规则:
|
||||
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式。
|
||||
正确示例:"这段时间开拓者工作好像很忙。下次聊天时可以问问他项目进展如何。"
|
||||
错误示例:"开拓者,最近工作怎么样呀?"
|
||||
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
|
||||
但只在真的有必要时才发——不要为了发消息而发消息。
|
||||
3. 简短自然,3-4句话即可。`
|
||||
其他规则:
|
||||
1. 用第三人称/自言自语。
|
||||
2. 3-4句话即可。`
|
||||
}
|
||||
|
||||
return basePrompt + thinkingInstructions
|
||||
@@ -712,6 +707,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
}
|
||||
|
||||
// 对话历史
|
||||
var lastUserMsg string
|
||||
if len(convHistory) > 0 {
|
||||
sb.WriteString("\n【最近的对话】\n")
|
||||
msgCount := 0
|
||||
@@ -728,6 +724,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s]: %s\n", roleLabel, content))
|
||||
msgCount++
|
||||
if msg.Role == model.RoleUser {
|
||||
lastUserMsg = msg.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
if msgCount == 0 {
|
||||
@@ -737,6 +736,11 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
sb.WriteString("\n【最近的对话】\n(暂无对话历史)\n")
|
||||
}
|
||||
|
||||
// 关键:强调根据对话历史判断用户当前状态
|
||||
if lastUserMsg != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,反思中不要写【主动消息】。\n", lastUserMsg))
|
||||
}
|
||||
|
||||
// 现有记忆
|
||||
if len(memories) > 0 {
|
||||
sb.WriteString("\n【你记得的关于开拓者的事】\n")
|
||||
@@ -757,8 +761,11 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
sb.WriteString("\n" + deviceSummary)
|
||||
}
|
||||
|
||||
// 结尾引导:强调这是私人反思,不要对用户喊话
|
||||
sb.WriteString("\n---\n现在请写下你的私人反思。记住:这是日记,用第三人称或自言自语的方式。如果想给开拓者发消息,用【主动消息】标记单独写出。")
|
||||
// 结尾引导
|
||||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
||||
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
||||
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要写【主动消息】。你可以在心里想他,但不要去打扰。")
|
||||
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才写【主动消息】。")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -802,6 +809,11 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
|
||||
// 提取主动消息并推送(带频率限制)
|
||||
proactiveMsg := extractProactiveMessage(content)
|
||||
// 优先推送至活跃会话,回退到管理员主会话
|
||||
pushSessionID := t.activeSessionID
|
||||
if pushSessionID == "" {
|
||||
pushSessionID = t.adminSessionID
|
||||
}
|
||||
pusher := t.messagePusher
|
||||
canPush := proactiveMsg != "" && pusher != nil
|
||||
if canPush {
|
||||
@@ -849,7 +861,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
}
|
||||
}()
|
||||
log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg)
|
||||
pusher(t.adminUserID, t.adminSessionID, proactiveMsg)
|
||||
pusher(t.adminUserID, pushSessionID, proactiveMsg)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -879,21 +891,6 @@ func extractProactiveMessage(content string) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
|
||||
func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Println("[后台思考] 开始从思考结果中提取记忆...")
|
||||
|
||||
t.memoryExtractor.ExtractAndStore(
|
||||
ctx,
|
||||
t.adminUserID,
|
||||
t.adminSessionID,
|
||||
"【系统触发】后台思考时间 — 昔涟进行了自我反思,以下是她的思考内容",
|
||||
thinkingContent,
|
||||
)
|
||||
}
|
||||
|
||||
// maybeMaintainMemories 周期性执行记忆维护(每 10 次思考触发一次)
|
||||
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
|
||||
|
||||
@@ -131,16 +131,15 @@ func (o *Orchestrator) ProcessInput(
|
||||
Nickname: userName,
|
||||
}
|
||||
|
||||
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting" ||
|
||||
(intent.Primary == "chat" && !intent.NeedsIoT && !intent.NeedsMemory)
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单消息(primary=%s),跳过子会话分派", intent.Primary)
|
||||
// 创建一个已关闭的空通道
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
resultCh = emptyCh
|
||||
// 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆
|
||||
// 因为 LLM 容易将日常闲聊误判为 needs_memory=false,导致回复缺乏上下文
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting" && !intent.NeedsMemory
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单问候(primary=%s),跳过子会话分派", intent.Primary)
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
resultCh = emptyCh
|
||||
} else {
|
||||
resultCh = o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
|
||||
}
|
||||
@@ -369,7 +368,7 @@ func parseReviewMessages(text string) []model.ReviewMessage {
|
||||
if actionStart > 0 {
|
||||
prefix := strings.TrimSpace(remaining[:actionStart])
|
||||
if prefix != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, prefix)...)
|
||||
messages = append(messages, splitChatByLines(model.ReviewMessageChat, prefix)...)
|
||||
}
|
||||
}
|
||||
// 括号内作为 action
|
||||
@@ -385,7 +384,7 @@ func parseReviewMessages(text string) []model.ReviewMessage {
|
||||
// 没有括号,剩余全部作为 chat
|
||||
remaining = strings.TrimSpace(remaining)
|
||||
if remaining != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, remaining)...)
|
||||
messages = append(messages, splitChatByLines(model.ReviewMessageChat, remaining)...)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -409,6 +408,26 @@ func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []mode
|
||||
if len(runes) <= maxLen {
|
||||
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||
}
|
||||
// ... split by sentence boundaries for long messages
|
||||
return splitLongText(msgType, runes, maxLen)
|
||||
}
|
||||
|
||||
// splitChatByLines 将聊天文本按双换行(段落分隔)拆分为多条消息,每条再检查是否需要按长度拆分
|
||||
func splitChatByLines(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||
lines := strings.Split(text, "\n\n")
|
||||
var msgs []model.ReviewMessage
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, splitReviewLongMessage(msgType, line)...)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// splitLongText 将文本按句子边界分割
|
||||
func splitLongText(msgType model.ReviewMessageType, runes []rune, maxLen int) []model.ReviewMessage {
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
start := 0
|
||||
@@ -459,7 +478,7 @@ func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []mode
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: text,
|
||||
Content: string(runes),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,14 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
// 增大 scanner buffer 以处理大块 SSE 数据
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
// 通知前端 AI 开始生成回复
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "stream_start",
|
||||
MessageID: "msg_" + generateID(),
|
||||
SessionID: client.SessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
var fullText string
|
||||
var msgID string
|
||||
var hasReview bool // 是否有审查消息(避免重复持久化)
|
||||
@@ -325,7 +333,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
})
|
||||
// 小延迟让消息逐条到达,更像真人
|
||||
if i < len(chunk.ReviewMessages)-1 {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
hasReview = true
|
||||
@@ -351,18 +359,9 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
continue
|
||||
}
|
||||
|
||||
// 逐 delta 转发
|
||||
// 逐 delta 积累(不再逐块转发,由审查消息代替)
|
||||
if chunk.Delta != "" {
|
||||
fullText += chunk.Delta
|
||||
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "stream_chunk",
|
||||
MessageID: msgID,
|
||||
Content: chunk.Delta,
|
||||
Role: "assistant",
|
||||
SessionID: client.SessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,8 +382,9 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
}
|
||||
|
||||
// 检测是否为多消息格式(包含空行分隔的多条消息)
|
||||
// 如果已有审查消息则跳过,避免与 review_messages 重复
|
||||
multiParts := parseMultiMessage(fullText)
|
||||
if len(multiParts) > 1 {
|
||||
if !hasReview && len(multiParts) > 1 {
|
||||
// 发送 multi_message 事件
|
||||
var items []ws.MultiMessageItem
|
||||
for i, part := range multiParts {
|
||||
|
||||
+92
-10
@@ -298,6 +298,48 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
.quick-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
/* 刷新按钮旋转 */
|
||||
|
||||
/* 页面加载进度条 */
|
||||
@keyframes loadingBar { 0% { width: 0; } 30% { width: 40%; } 60% { width: 75%; } 100% { width: 95%; } }
|
||||
.loading-overlay {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 60px 20px; gap: 16px;
|
||||
}
|
||||
.loading-progress-bar {
|
||||
width: 260px; height: 3px; background: var(--bg3); border-radius: 2px; overflow: hidden;
|
||||
}
|
||||
.loading-progress-fill {
|
||||
height: 100%; background: linear-gradient(90deg, var(--accent), var(--blue));
|
||||
border-radius: 2px; animation: loadingBar 3s ease-out forwards;
|
||||
}
|
||||
.loading-text { color: var(--text2); font-size: 13px; }
|
||||
.loading-detail { color: var(--text3); font-size: 11px; }
|
||||
.loading-icon { font-size: 36px; animation: loadingPulse 1.2s ease-in-out infinite; }
|
||||
@keyframes loadingPulse {
|
||||
0%, 100% { opacity: 0.35; } 50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 按钮加载状态 */
|
||||
.btn.loading {
|
||||
position: relative; pointer-events: none; opacity: 0.7;
|
||||
}
|
||||
.btn.loading::after {
|
||||
content: ""; position: absolute;
|
||||
width: 12px; height: 12px; top: 50%; left: 50%; margin: -6px 0 0 -6px;
|
||||
border: 2px solid transparent; border-top-color: currentColor;
|
||||
border-radius: 50%; animation: spin .6s linear infinite;
|
||||
}
|
||||
.btn.loading .btn-text { visibility: hidden; }
|
||||
|
||||
/* 操作反馈闪烁 */
|
||||
@keyframes flashBorder {
|
||||
0% { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
100% { border-color: var(--border); box-shadow: none; }
|
||||
}
|
||||
.card.action-pending {
|
||||
animation: flashBorder .6s ease-in-out 3;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spinning { animation: spin 1s linear infinite; }
|
||||
|
||||
@@ -657,7 +699,7 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
</div>
|
||||
<div id="panel-container">
|
||||
<!-- 仪表盘 -->
|
||||
<div class="panel active" id="panel-dashboard"></div>
|
||||
<div class="panel active" id="panel-dashboard"><div class="loading-overlay" id="dashboard-loading"><div class="loading-icon">🛠️</div><div class="loading-text">正在连接服务并加载数据...</div><div class="loading-progress-bar"><div class="loading-progress-fill"></div></div><div class="loading-detail" id="loading-detail">初始化中</div></div></div>
|
||||
<!-- 记忆管理 -->
|
||||
<div class="panel" id="panel-memory"></div>
|
||||
<!-- 会话监看 -->
|
||||
@@ -997,6 +1039,9 @@ function stopDbAutoRefresh() {
|
||||
|
||||
// ========== 面板1: 仪表盘 ==========
|
||||
async function renderDashboard() {
|
||||
// 更新加载提示(如果加载覆盖层还在)
|
||||
var loadDetail = document.getElementById("loading-detail");
|
||||
if (loadDetail) loadDetail.textContent = "正在加载仪表盘数据...";
|
||||
const data = await api('/api/dashboard');
|
||||
if (data.error) {
|
||||
document.getElementById('panel-dashboard').innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) + '</div>';
|
||||
@@ -2179,16 +2224,53 @@ async function clearSvcLogs() {
|
||||
|
||||
// ---- 服务操作 ----
|
||||
async function svcAction(cmd, serviceId) {
|
||||
let url;
|
||||
if (cmd === 'start-all') url = '/api/services/start-all';
|
||||
else if (cmd === 'start-all-fresh') url = '/api/services/start-all-fresh';
|
||||
else if (cmd === 'stop-all') url = '/api/services/stop-all';
|
||||
else url = `/api/services/${serviceId}/${cmd}`;
|
||||
var actionLabels = { start: "启动", stop: "停止", restart: "重启", build: "编译", "start-all": "一键启动", "start-all-fresh": "强制重启全部", "stop-all": "全部停止" };
|
||||
var label = actionLabels[cmd] || cmd;
|
||||
var svcLabel = serviceId ? escapeId(serviceId) : "";
|
||||
var msg = svcLabel ? "正在" + label + " " + svcLabel + "..." : "正在执行: " + label + "...";
|
||||
showToast(msg, "info");
|
||||
|
||||
const method = ['start','stop','restart','build','start-all','start-all-fresh','stop-all'].includes(cmd) ? 'POST' : 'GET';
|
||||
const res = await api(url, { method });
|
||||
showToast(res.message || res.error || `${cmd} 完成`, res.error ? 'error' : 'success');
|
||||
refreshStatus();
|
||||
// 立即更新本地状态和 UI(乐观更新)
|
||||
if (serviceId) {
|
||||
var newStatus;
|
||||
if (cmd === "start") newStatus = "starting";
|
||||
else if (cmd === "stop") newStatus = "stopped";
|
||||
else if (cmd === "restart") newStatus = "starting";
|
||||
else if (cmd === "build") newStatus = "building";
|
||||
if (newStatus) {
|
||||
if (!STATE.serviceStatus[serviceId]) {
|
||||
STATE.serviceStatus[serviceId] = { name: escapeId(serviceId), status: newStatus, pid: null, port: "-", uptime: 0 };
|
||||
} else {
|
||||
STATE.serviceStatus[serviceId].status = newStatus;
|
||||
}
|
||||
if (STATE.activePanel === "services") renderServiceCards();
|
||||
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
|
||||
}
|
||||
} else {
|
||||
// 批量操作:给所有服务设置过渡状态
|
||||
var newStatus = (cmd === "start-all" || cmd === "start-all-fresh") ? "starting" : "stopped";
|
||||
ALL_SVC_IDS.forEach(function(id) {
|
||||
if (!STATE.serviceStatus[id]) {
|
||||
STATE.serviceStatus[id] = { name: escapeId(id), status: newStatus, pid: null, port: "-", uptime: 0 };
|
||||
} else {
|
||||
STATE.serviceStatus[id].status = newStatus;
|
||||
}
|
||||
});
|
||||
if (STATE.activePanel === "services") renderServiceCards();
|
||||
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
|
||||
}
|
||||
|
||||
// 发起 API 请求
|
||||
let url;
|
||||
if (cmd === "start-all") url = "/api/services/start-all";
|
||||
else if (cmd === "start-all-fresh") url = "/api/services/start-all-fresh";
|
||||
else if (cmd === "stop-all") url = "/api/services/stop-all";
|
||||
else url = "/api/services/" + serviceId + "/" + cmd;
|
||||
|
||||
var method = ["start","stop","restart","build","start-all","start-all-fresh","stop-all"].indexOf(cmd) >= 0 ? "POST" : "GET";
|
||||
var res = await api(url, { method: method });
|
||||
showToast(res.message || res.error || (label + " 完成"), res.error ? "error" : "success");
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
async function checkHealth(id) {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,33 @@
|
||||
# Cyrene 调试日志索引
|
||||
|
||||
本目录包含 Cyrene 项目开发过程中的阶段性调试总结日志。
|
||||
|
||||
## 2026-05-19
|
||||
|
||||
- [持续性调试报告 (10轮汇总)](2026-05-19-round7-continuous-debugging-report.md)
|
||||
|
||||
## 2026-05-20 (Round 1~10)
|
||||
|
||||
- [Round 1 - 回归验证](2026-05-20-round1-regression-verification.md)
|
||||
- [Round 2 - 认证集成](2026-05-20-round2-auth-integration.md)
|
||||
- [Round 3 - 面板前端](2026-05-20-round3-panels-frontend.md)
|
||||
- [Round 4 - 子服务数据库](2026-05-20-round4-subservices-database.md)
|
||||
- [Round 5 - 安全边界](2026-05-20-round5-security-boundary.md)
|
||||
- [Round 6 - 性能代码质量](2026-05-20-round6-performance-code-quality.md)
|
||||
- [Round 7 - E2E 跨服务](2026-05-20-round7-e2e-cross-service.md)
|
||||
- [Round 8 - Docker PWA WebSocket](2026-05-20-round8-docker-pwa-websocket.md)
|
||||
- [Round 9 - 配置汇总](2026-05-20-round9-config-summary.md)
|
||||
- [Round 10 - 关键修复](2026-05-20-round10-critical-fixes.md)
|
||||
|
||||
## 2026-05-21
|
||||
|
||||
- [崩溃取消修复](2026-05-21-crash-cancel.md)
|
||||
- [Round 2 修复](2026-05-21-round2-fixes.md)
|
||||
- [Round 11 - API 契约](2026-05-21-round11-api-contract.md)
|
||||
|
||||
## 2026-05-22
|
||||
|
||||
- [最终汇总报告](2026-05-22-final-summary.md)
|
||||
- [Round 4 - IoT 审查管道](2026-05-22-round4-iot-review-pipeline.md)
|
||||
- [Round 5 - IoT 边界情况](2026-05-22-round5-iot-edge-cases.md)
|
||||
- [Round 6 - 最终汇总 (Round 4~6)](2026-05-22-round6-final-summary.md)
|
||||
@@ -300,4 +300,8 @@ export async function addMemory(content: string, category?: string, priority?: n
|
||||
return request('/memory', { method: 'POST', body: { content, category, priority } });
|
||||
}
|
||||
|
||||
export async function deleteMemory(id: string) {
|
||||
return request(`/memory?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export { request, type ApiResponse };
|
||||
@@ -3,4 +3,5 @@ export {
|
||||
searchMemory,
|
||||
listMemories,
|
||||
addMemory,
|
||||
deleteMemory,
|
||||
} from './client';
|
||||
@@ -5,7 +5,6 @@ import { IoTStatusBar } from './IoTStatusBar';
|
||||
|
||||
export function ChatContainer() {
|
||||
const messages = useChatStore((s) => s.messages);
|
||||
const isTyping = useChatStore((s) => s.isTyping);
|
||||
const continuousMode = useChatStore((s) => s.continuousMode);
|
||||
const backgroundThinkingStatus = useChatStore((s) => s.backgroundThinkingStatus);
|
||||
|
||||
@@ -46,7 +45,6 @@ export function ChatContainer() {
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isTyping={isTyping}
|
||||
hasMoreMessages={useChatStore((s) => s.hasMoreMessages)}
|
||||
isLoadingHistory={useChatStore((s) => s.isLoadingHistory)}
|
||||
onLoadMore={() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { ChatMode, MessageAttachment } from '@/types/chat';
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
|
||||
import { uploadFile } from '@/api/files';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string, mode: ChatMode, attachments?: MessageAttachment[]) => void;
|
||||
@@ -26,6 +27,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isTyping = useChatStore((s) => s.isTyping);
|
||||
|
||||
const {
|
||||
isListening,
|
||||
@@ -98,6 +100,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
useChatStore.getState().setTyping(true);
|
||||
onSend(trimmed, mode, attachments);
|
||||
setContent('');
|
||||
setPendingImages([]);
|
||||
@@ -221,6 +224,27 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
return (
|
||||
<div className="border-t border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div className="flex flex-col gap-2 max-w-3xl mx-auto">
|
||||
{/* 昔涟正在输入指示器 */}
|
||||
{isTyping && (
|
||||
<div className="flex items-center gap-2 px-1 animate-fadeIn">
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-400 animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-400 animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-400 animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-pink-400 font-medium">昔涟正在输入...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 实时识别文本提示 */}
|
||||
{isListening && interimText && (
|
||||
<div
|
||||
|
||||
@@ -173,10 +173,17 @@ export function MessageBubble({
|
||||
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
|
||||
|
||||
return (
|
||||
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
<div className={`flex px-4 py-2 gap-2 items-end group ${isUser ? 'justify-end' : ''}`}>
|
||||
{/* 用户消息:时间在左侧(气泡外侧) */}
|
||||
{isUser && !isStreaming && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{time}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 头像 */}
|
||||
{!isUser && (
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mb-0.5" />
|
||||
)}
|
||||
|
||||
{/* 消息气泡 */}
|
||||
@@ -249,22 +256,15 @@ export function MessageBubble({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isStreaming && (
|
||||
<>
|
||||
{/* AI 消息操作栏(朗读按钮)— 暂时禁用 */}
|
||||
{/* !isUser && <AIMessageActions content={content} /> */}
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 昔涟消息:时间在右侧(气泡外侧) */}
|
||||
{!isUser && !isStreaming && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{time}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 用户头像 */}
|
||||
{isUser && <UserAvatar />}
|
||||
|
||||
@@ -280,7 +280,7 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
/** 动作消息气泡 — 灰色/斜体/居中,视觉上与聊天消息区分 */
|
||||
/** 动作消息气泡 — 居左,与昔涟头像对齐,灰色/斜体与聊天消息区分 */
|
||||
function ActionMessageBubble({ content, timestamp }: { content: string; timestamp: number }) {
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -288,15 +288,16 @@ function ActionMessageBubble({ content, timestamp }: { content: string; timestam
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 py-1 animate-fadeIn">
|
||||
<div className="max-w-[70%] text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-relaxed whitespace-pre-wrap break-words">
|
||||
<span className="select-none text-gray-300 dark:text-gray-600">~ </span>
|
||||
<div className="flex px-4 py-0.5 gap-1.5 items-end group animate-fadeIn">
|
||||
<div className="w-8 flex-shrink-0" />
|
||||
<div className="max-w-[70%]">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-snug whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
<span className="select-none text-gray-300 dark:text-gray-600"> ~</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-300 dark:text-gray-600 mt-0.5">{time}</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-0.5 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{time}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import type { Message } from '@/types/chat';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
hasMoreMessages?: boolean;
|
||||
isLoadingHistory?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
@@ -13,7 +11,6 @@ interface MessageListProps {
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isTyping,
|
||||
hasMoreMessages = false,
|
||||
isLoadingHistory = false,
|
||||
onLoadMore,
|
||||
@@ -26,7 +23,7 @@ export function MessageList({
|
||||
if (!isLoadingHistory) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, isTyping, isLoadingHistory]);
|
||||
}, [messages, isLoadingHistory]);
|
||||
|
||||
if (messages.length === 0 && !isLoadingHistory) {
|
||||
return (
|
||||
@@ -77,7 +74,6 @@ export function MessageList({
|
||||
msgType={msg.msgType}
|
||||
/>
|
||||
))}
|
||||
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ export function TypingIndicator() {
|
||||
<div className="flex px-4 py-2 gap-3">
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm border border-pink-100 dark:border-pink-900">
|
||||
<p className="text-xs text-pink-400 mb-1.5 font-medium">昔涟正在输入...</p>
|
||||
<div className="flex gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-300 animate-bounce"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { SearchModal } from './SearchModal';
|
||||
import { MemoryPanel } from './MemoryPanel';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -11,6 +12,7 @@ interface AppLayoutProps {
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
return (
|
||||
@@ -31,7 +33,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}
|
||||
>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} onMemoryClick={() => setMemoryOpen(true)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -49,6 +51,8 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
|
||||
{/* 搜索弹窗 */}
|
||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
{/* 记忆管理弹窗 */}
|
||||
<MemoryPanel isOpen={memoryOpen} onClose={() => setMemoryOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listMemories, deleteMemory } from '@/api/memory';
|
||||
|
||||
interface MemoryEntry {
|
||||
id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
importance: number;
|
||||
session_id?: string;
|
||||
created_at: number | string;
|
||||
}
|
||||
|
||||
interface MemoryPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
user_preference: '偏好',
|
||||
personal_info: '个人信息',
|
||||
conversation: '对话',
|
||||
knowledge: '知识',
|
||||
event: '事件',
|
||||
task: '任务',
|
||||
relationship: '关系',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
user_preference: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
personal_info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
conversation: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
knowledge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
event: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
task: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
|
||||
relationship: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||
};
|
||||
|
||||
function formatTime(ts: number | string): string {
|
||||
const date = new Date(typeof ts === 'string' ? parseInt(ts, 10) : ts);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay < 7) return `${diffDay}天前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
export function MemoryPanel({ isOpen, onClose }: MemoryPanelProps) {
|
||||
const [memories, setMemories] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchMemories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp: any = await listMemories();
|
||||
// 后端可能返回 { memories: [...] } 或直接是数组
|
||||
const list = Array.isArray(resp) ? resp : (resp.memories || resp.data || []);
|
||||
setMemories(list);
|
||||
} catch (err) {
|
||||
setError('加载记忆失败,请检查记忆服务是否运行');
|
||||
console.error('[MemoryPanel] 加载记忆失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMemories();
|
||||
setConfirmId(null);
|
||||
}
|
||||
}, [isOpen, fetchMemories]);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !confirmId) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, confirmId]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
setDeletingId(id);
|
||||
setConfirmId(null);
|
||||
try {
|
||||
await deleteMemory(id);
|
||||
setMemories((prev) => prev.filter((m) => m.id !== id));
|
||||
} catch (err) {
|
||||
console.error('[MemoryPanel] 删除记忆失败:', err);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]">
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* 面板 */}
|
||||
<div className="relative w-full max-w-lg mx-4 bg-white dark:bg-gray-850 rounded-2xl shadow-2xl border border-pink-100 dark:border-pink-800 flex flex-col max-h-[75vh]">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-pink-100 dark:border-pink-800">
|
||||
<h2 className="text-base font-semibold text-gray-800 dark:text-gray-200">
|
||||
🧠 记忆管理
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchMemories}
|
||||
disabled={loading}
|
||||
className="text-xs px-2 py-1 text-gray-400 hover:text-pink-500 hover:bg-pink-50 dark:hover:bg-pink-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? '刷新中…' : '🔄 刷新'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && memories.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<div className="h-5 w-5 mx-auto animate-spin rounded-full border-2 border-pink-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-400 mt-3">加载中…</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="py-12 text-center text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
{!loading && !error && memories.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
还没有记忆哦,和昔涟多聊聊吧 ♪
|
||||
</div>
|
||||
)}
|
||||
{memories.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 text-xs text-gray-400 border-b border-pink-50 dark:border-pink-900">
|
||||
共 {memories.length} 条记忆
|
||||
</div>
|
||||
{memories.map((mem) => {
|
||||
const categoryLabel = CATEGORY_LABELS[mem.category] || mem.category || '其他';
|
||||
const colorClass = CATEGORY_COLORS[mem.category] || 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mem.id}
|
||||
className="px-4 py-3 border-b border-pink-50 dark:border-pink-900/50 last:border-b-0 hover:bg-pink-50/50 dark:hover:bg-pink-900/10 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200 break-words">
|
||||
{mem.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${colorClass}`}>
|
||||
{categoryLabel}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
重要度 {mem.importance}/10
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatTime(mem.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<div className="shrink-0">
|
||||
{confirmId === mem.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(mem.id)}
|
||||
disabled={deletingId === mem.id}
|
||||
className="text-xs px-2 py-1 bg-red-400 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{deletingId === mem.id ? '…' : '确认'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmId(null)}
|
||||
className="text-xs px-2 py-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmId(mem.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除记忆"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="px-4 py-2 border-t border-pink-100 dark:border-pink-800 text-xs text-gray-400 text-center">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">ESC</kbd> 关闭
|
||||
{' · '}
|
||||
点击垃圾桶图标删除记忆
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import type { Session } from '@/types/session';
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
onMemoryClick?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onClose }: SidebarProps) {
|
||||
export function Sidebar({ onClose, onMemoryClick }: SidebarProps) {
|
||||
const {
|
||||
sessions,
|
||||
createSession,
|
||||
@@ -277,8 +278,17 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:一键清空所有对话 */}
|
||||
<div className="p-3 border-t border-pink-100 dark:border-pink-900">
|
||||
{/* 底部:记忆管理 + 一键清空所有对话 */}
|
||||
<div className="p-3 border-t border-pink-100 dark:border-pink-900 space-y-2">
|
||||
{onMemoryClick && (
|
||||
<button
|
||||
onClick={onMemoryClick}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-purple-50 hover:bg-purple-100 dark:bg-purple-900/20 dark:hover:bg-purple-900/30 text-purple-500 hover:text-purple-600 rounded-xl text-xs font-medium transition-colors border border-purple-200 dark:border-purple-800"
|
||||
>
|
||||
<span>🧠</span>
|
||||
<span>记忆管理</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'deleteAll' })}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-400 hover:text-red-500 rounded-xl text-xs font-medium transition-colors border border-red-200 dark:border-red-800"
|
||||
|
||||
@@ -201,6 +201,10 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
const chatState = useChatStore.getState();
|
||||
|
||||
switch (msg.type) {
|
||||
case 'stream_start':
|
||||
setTyping(true);
|
||||
break;
|
||||
|
||||
case 'response':
|
||||
// 支持两种格式: 旧版 (text 字段) 和 审查消息版 (content + role + msg_type 字段)
|
||||
if (msg.text || msg.content) {
|
||||
@@ -285,8 +289,7 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
break;
|
||||
|
||||
case 'multi_message':
|
||||
case 'stream_segments':
|
||||
// 多段消息 / 流式片段 — 已通过 stream_chunk 处理,这里作为兜底
|
||||
// 多段消息 — 仅在后端未发送 response 时作为兜底
|
||||
if (msg.multi_messages && msg.multi_messages.length > 0) {
|
||||
for (const item of msg.multi_messages) {
|
||||
addMessage({
|
||||
@@ -298,20 +301,14 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (msg.stream_segments && msg.stream_segments.length > 0) {
|
||||
for (const seg of msg.stream_segments) {
|
||||
addMessage({
|
||||
id: `seg_${Date.now()}_${seg.index}`,
|
||||
role: 'assistant',
|
||||
content: seg.text,
|
||||
timestamp: msg.timestamp || Date.now(),
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'stream_segments':
|
||||
// 流式片段 — 语音合成辅助数据,不创建新消息气泡
|
||||
// response/multi_message 已负责创建聊天消息
|
||||
break;
|
||||
|
||||
case 'device_update':
|
||||
if (msg.devices && msg.devices.length > 0) {
|
||||
chatState.setIoTDevices(msg.devices);
|
||||
|
||||
@@ -122,7 +122,7 @@ export interface AppNotification extends NotificationData {
|
||||
|
||||
/** WebSocket 服务端消息 */
|
||||
export interface WSServerMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments' | 'review';
|
||||
type: 'stream_start' | 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments' | 'review';
|
||||
message_id?: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
|
||||
Reference in new issue
Block a user