@@ -172,6 +172,11 @@ type model struct {
172172 workingMode agent.WorkingMode // 工作模式 kp/openspec/sp(默认 kp);每轮注入对应 skill 引导;按 session 保存/恢复
173173 history []agent.ChatMessage
174174
175+ // 会话主题:模型每轮结尾输出 <topic shift="yes|no">…</topic>(见 topic.go)。会话级状态,
176+ // 跟着 /new、/sessions 走。topicF 逐 chunk 把标签从显示里剥掉并捕获主题。
177+ topic string
178+ topicF topicFilter
179+
175180 streamCh <- chan tea.Msg
176181
177182 status string
@@ -773,6 +778,7 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub
773778 gobHistory = gobHistory [1 :]
774779 }
775780 m .history = gobHistory
781+ m .topic = lastTopicOf (gobHistory ) // 冷启动恢复右栏主题(不额外存盘,从历史里读回)
776782 rebuildChatFromHistory (m .chatContent , gobHistory )
777783 // 老对话(升级前就有 history、没 conv.json)首次进 /sessions 别显示"(未命名)":
778784 // 用第一条用户消息回填标题。session 包自己解码不了 history.gob,放这儿做。
@@ -1180,6 +1186,7 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) {
11801186 m .streaming = true
11811187 m .thinking = true
11821188 m .currentReply .Reset ()
1189+ m .topicF = topicFilter {} // 上一轮若被 ESC 打断,过滤器可能停在标签中间
11831190
11841191 // 仪表盘:开计时,快照本轮输入字符数,输出归零
11851192 m .turnStartedAt = time .Now ()
@@ -2599,10 +2606,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
25992606 m .activeTool = ""
26002607 m .retryNotice = "" // 流式开始 = 重试成功,清掉提示
26012608 text := string (msg )
2602- m .currentReply .WriteString (text )
2603- // 上一段若是 tools(刚执行完工具,模型继续说话),切回 assistant 段。
2604- m .chatContent .EnsureKind (kindAssistant , "" )
2605- m .chatContent .Append (text )
2609+ m .currentReply .WriteString (text ) // 落盘/入历史的是原文,只有显示层剥主题标签
2610+ // 主题标签是给 DeepX 看的元信息,不显示给用户。标签可能被切在 chunk 中间,
2611+ // 故过滤器是有状态的(见 topic.go),这里逐段喂。
2612+ visible := m .topicF .feed (text )
2613+ // 整段都被标签吃掉时什么都不做 —— 开了段就会给"只输出了一行标签"的轮次留个空气泡,
2614+ // 广播出去也只是条空事件。
2615+ if visible != "" {
2616+ // 上一段若是 tools(刚执行完工具,模型继续说话),切回 assistant 段。
2617+ m .chatContent .EnsureKind (kindAssistant , "" )
2618+ m .chatContent .Append (visible )
2619+ // token 事件不走 Update 顶部的通用广播(那里拿不到过滤后的文本),在这里显式发,
2620+ // 免得标签漏到浏览器面板上。
2621+ m .broadcast (web.Event {Kind : "token" , Text : visible })
2622+ }
26062623 m .tokens += len ([]rune (text ))
26072624 m .turnOutputChars += len ([]rune (text ))
26082625 m .refreshViewport ()
@@ -2614,6 +2631,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26142631 }
26152632 m .status = "tool"
26162633 m .retryNotice = "" // 工具开始 = 上游请求已成功,清掉重试提示
2634+ // 助手这段话说完了:把主题过滤器攒着的未决尾巴(可能是 "<topic" 的部分前缀)吐回当前段,
2635+ // 否则它会跟到下面的工具行后面去。
2636+ if tail := m .topicF .flush (); tail != "" {
2637+ m .chatContent .Append (tail )
2638+ }
26172639 // Todo 是可见清单的「全量快照更新」,不是一次干活动作:清单本身由下方 live overlay
26182640 // 实时勾选,每次更新再往 chat 打一行 "📌 Todo" 是纯噪音。所以 Todo 不留 chat 行、
26192641 // 也不计入"N 次工具调用"。其余工具照旧:紧凑单行 <icon> Name (主参数)。
@@ -2945,6 +2967,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29452967 _ = m .session .Append ("assistant" , final )
29462968 }
29472969 }
2970+ // 本轮收尾:先把主题过滤器的未决尾巴吐回对话区,再落地这一轮的主题 / 判是否提醒 /new。
2971+ // 尾巴只剩空白就丢掉 —— 话都说完了,补几个换行只会在气泡末尾留空行。
2972+ if tail := strings .TrimRight (m .topicF .flush (), " \t \r \n " ); tail != "" {
2973+ m .chatContent .Append (tail )
2974+ }
2975+ ctxWin := m .models .Pro .ContextWindow
2976+ if ctxWin <= 0 {
2977+ ctxWin = 65536
2978+ }
2979+ m .applyTurnTopic (ctxWin )
2980+
29482981 m .status = "idle"
29492982 m .streaming = false
29502983 m .thinking = false
@@ -2961,10 +2994,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29612994 // 这里无需额外动作 — 旧的 trimDisplayTurns 按"10 轮"裁的逻辑已被 chatLog 取代。
29622995
29632996 // 检查是否需要触发会话压缩:上下文用量达到 agent.CompactTriggerTokens(动态阈值)就压。
2964- ctxWin := m .models .Pro .ContextWindow
2965- if ctxWin <= 0 {
2966- ctxWin = 65536
2967- }
2997+ // ctxWin 在上面判主题提醒时已经算好。
29682998 if m .session != nil && m .models .Pro .Model != "" && ! m .compacting && m .lastPromptTokens () >= agent .CompactTriggerTokens (ctxWin ) {
29692999 m .compacting = true
29703000 m .compactingFG = true // 前台阻塞:同手动 /compact,footer 转 spinner + 期间挡输入。子 agent 走 runSubAgent 不经此处,天然例外。
@@ -3374,6 +3404,9 @@ func (m *model) appendChat(role, text string) {
33743404// chatDisplayText 取一条消息用于"对话区显示"的文本:优先 Content,否则从 ContentParts 拼出文本、图片用 [图片] 表示。
33753405func chatDisplayText (msg agent.ChatMessage ) string {
33763406 if msg .Content != "" {
3407+ if msg .Role == "assistant" {
3408+ return stripTopicTags (msg .Content ) // 历史存的是原文,重放到对话区时才剥主题标签
3409+ }
33773410 return msg .Content
33783411 }
33793412 var sb strings.Builder
@@ -4190,6 +4223,7 @@ func (m *model) startWorkflowTurn(rawInput, name string, args any) tea.Cmd {
41904223 m .streaming = true
41914224 m .thinking = true
41924225 m .currentReply .Reset ()
4226+ m .topicF = topicFilter {}
41934227 m .turnStartedAt = time .Now ()
41944228 m .turnInputChars = sumHistoryChars (m .history )
41954229 m .turnOutputChars = 0
0 commit comments