magic-agent 1.0.2 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// title.go - 会话标题的**统一存储与归纳**(2026-10-15)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么标题归 core(用户:「应该 magic-agent 统一归纳作为会话标题」):
|
|
4
|
+
// 此前归纳逻辑只在业务项目(掌天瓶)里,而标题存储在插件里 —— 于是 PC 与移动端
|
|
5
|
+
// 各算各的:PC 显示本地 AI 归纳的要点,移动端只能回落 prompt_head(首问原文)。
|
|
6
|
+
// 同一会话在两端显示不同标题,且**从移动端发起的会话没人替它归纳**。
|
|
7
|
+
// 现在口径收成一份:归纳在这里、存储也在这里,客户端只读不自己算。
|
|
8
|
+
//
|
|
9
|
+
// 两层结构,边界要清楚:
|
|
10
|
+
//
|
|
11
|
+
// · 存储(sid→title 覆盖表)是**纯数据**,无依赖、幂等,任何客户端都能读写;
|
|
12
|
+
// · 归纳(Summarize)是**调引擎**,只在这里做,客户端不重复实现。
|
|
13
|
+
//
|
|
14
|
+
// 覆盖表沿用插件时期落的同一个文件(~/.magic-agent/session-titles.json)——
|
|
15
|
+
// 换实现不换路径,用户已有的重命名结果不丢。
|
|
16
|
+
package session
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"context"
|
|
20
|
+
"encoding/json"
|
|
21
|
+
"os"
|
|
22
|
+
"path/filepath"
|
|
23
|
+
"regexp"
|
|
24
|
+
"strconv"
|
|
25
|
+
"strings"
|
|
26
|
+
"sync"
|
|
27
|
+
"time"
|
|
28
|
+
|
|
29
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// TitleMaxChars 归纳标题的硬上限(汉字个数)。与业务项目时期的口径一致(≤12 字)。
|
|
33
|
+
const TitleMaxChars = 12
|
|
34
|
+
|
|
35
|
+
// titlePromptCap 送进 prompt 的原文上限:首问可能很长(含需求正文 / 粘贴的整段文本),
|
|
36
|
+
// 而「标题」只需要知道这件事在做什么。截断只影响模型看到的原文,不影响任何落盘数据。
|
|
37
|
+
const titlePromptCap = 2000
|
|
38
|
+
|
|
39
|
+
var (
|
|
40
|
+
titleMu sync.Mutex
|
|
41
|
+
titleCache map[string]string
|
|
42
|
+
titleLoaded bool
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// TitleStorePath 覆盖表路径(~/.magic-agent/session-titles.json)。
|
|
46
|
+
// 与会话登记表同在 .magic-agent 下 —— 换实现不换路径,见文件头。
|
|
47
|
+
func TitleStorePath() (string, error) {
|
|
48
|
+
if p := strings.TrimSpace(os.Getenv(EnvDir)); p != "" {
|
|
49
|
+
return filepath.Join(filepath.Dir(p), "session-titles.json"), nil
|
|
50
|
+
}
|
|
51
|
+
home, err := os.UserHomeDir()
|
|
52
|
+
if err != nil || home == "" {
|
|
53
|
+
return "", os.ErrNotExist
|
|
54
|
+
}
|
|
55
|
+
return filepath.Join(home, ".magic-agent", "session-titles.json"), nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// loadTitles 读覆盖表(文件不存在 = 空表,不是错误)。
|
|
59
|
+
// 进程内缓存一份:列表页会逐行问标题,逐行读盘没必要;
|
|
60
|
+
// 写操作(SetTitle)后缓存失效,保证同一进程内也读到新值。
|
|
61
|
+
func loadTitles() (map[string]string, error) {
|
|
62
|
+
titleMu.Lock()
|
|
63
|
+
defer titleMu.Unlock()
|
|
64
|
+
if titleLoaded {
|
|
65
|
+
out := make(map[string]string, len(titleCache))
|
|
66
|
+
for k, v := range titleCache {
|
|
67
|
+
out[k] = v
|
|
68
|
+
}
|
|
69
|
+
return out, nil
|
|
70
|
+
}
|
|
71
|
+
out := map[string]string{}
|
|
72
|
+
p, err := TitleStorePath()
|
|
73
|
+
if err != nil {
|
|
74
|
+
titleCache, titleLoaded = out, true
|
|
75
|
+
return out, nil
|
|
76
|
+
}
|
|
77
|
+
if b, err := os.ReadFile(p); err == nil {
|
|
78
|
+
_ = json.Unmarshal(b, &out)
|
|
79
|
+
}
|
|
80
|
+
titleCache, titleLoaded = out, true
|
|
81
|
+
return out, nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func saveTitlesLocked(m map[string]string) error {
|
|
85
|
+
p, err := TitleStorePath()
|
|
86
|
+
if err != nil {
|
|
87
|
+
return err
|
|
88
|
+
}
|
|
89
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
90
|
+
return err
|
|
91
|
+
}
|
|
92
|
+
b, err := json.MarshalIndent(m, "", " ")
|
|
93
|
+
if err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
titleCache, titleLoaded = m, true
|
|
100
|
+
return nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Titles 返回整份覆盖表(GET /desk/titles 给客户端合并用)。
|
|
104
|
+
func Titles() map[string]string {
|
|
105
|
+
m, _ := loadTitles()
|
|
106
|
+
return m
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// TitleFor 取一条会话的覆盖标题(没有则空串)。
|
|
110
|
+
func TitleFor(id string) string {
|
|
111
|
+
id = strings.TrimSpace(id)
|
|
112
|
+
if id == "" {
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
m, _ := loadTitles()
|
|
116
|
+
return m[id]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// TitleForAny 取一条会话的覆盖标题:**session_id 优先,其次 run_id**。
|
|
120
|
+
//
|
|
121
|
+
// 为什么需要「认两个键」(2026-10-15 用户:「会话标题每次追问也在更新,只在第一次
|
|
122
|
+
// 创建时生成就好」):标题的键是**归纳那一刻**能拿到的 id —— 首轮归纳时引擎可能还没吐
|
|
123
|
+
// session_id,于是键落在 run_id 上;而 run_id **每轮追问都换一个**,下一轮按 session_id
|
|
124
|
+
// 一查就查不到 → 客户端以为「还没归纳」→ 再归纳一次(且输入回落成最新那条追问的原文)
|
|
125
|
+
// → 标题每轮都在变,PC 与移动端各自刷新到的时刻不同,还显示得不一样。
|
|
126
|
+
// 两个键都认之后,老的 run_id 键也命中;调用方顺手用 AdoptTitle 把它迁到 session_id 上,
|
|
127
|
+
// 从此键就固定了。
|
|
128
|
+
func TitleForAny(sessionID, runID string) string {
|
|
129
|
+
m, _ := loadTitles()
|
|
130
|
+
if t := m[strings.TrimSpace(sessionID)]; t != "" {
|
|
131
|
+
return t
|
|
132
|
+
}
|
|
133
|
+
return m[strings.TrimSpace(runID)]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// AdoptTitle 把标题从 run_id 迁到 session_id(幂等),并**清掉那个 run_id 键**。
|
|
137
|
+
//
|
|
138
|
+
// 只在新键**为空**时搬:用户可能已经手动重命名过(那就是权威值),不能被旧键覆盖回去。
|
|
139
|
+
// 两个 id 相同、任一为空、或旧键没有标题时都是空操作。
|
|
140
|
+
//
|
|
141
|
+
// ⚠️ 搬完要删旧键:留着它就等于同一会话有两个键、两个值 ——
|
|
142
|
+
// 谁按 session_id 读、谁按 run_id 读,就会看到**两个不同的标题**(PC 与移动端不一致的
|
|
143
|
+
// 一个直接来源,实测踩到过)。
|
|
144
|
+
func AdoptTitle(sessionID, runID string) {
|
|
145
|
+
sid, rid := strings.TrimSpace(sessionID), strings.TrimSpace(runID)
|
|
146
|
+
if sid == "" || rid == "" || sid == rid {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
m, _ := loadTitles()
|
|
150
|
+
if m[sid] == "" && m[rid] != "" {
|
|
151
|
+
_ = SetTitle(sid, m[rid])
|
|
152
|
+
}
|
|
153
|
+
dropRunTitle(rid, sid)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ConsolidateTitle 写标题,并把这条会话**归一到一个键**上:值写给 session_id,
|
|
157
|
+
// 该会话各轮 run_id 上的旧键一并清掉。
|
|
158
|
+
//
|
|
159
|
+
// 为什么要归一:首轮归纳时引擎可能还没吐 session_id,键于是落在那一轮的 run_id 上;而
|
|
160
|
+
// run_id 每轮追问都换 → 同一会话会攒出一串键,且**值还可能不同**(每次归纳是独立的 LLM 调用)。
|
|
161
|
+
// 结果就是「同一会话按不同 id 读,标题不一样」。
|
|
162
|
+
//
|
|
163
|
+
// sessionID 为空(引擎始终没给 id)时只能挂在 runID 上 —— 那种会话本来就每轮一行。
|
|
164
|
+
func ConsolidateTitle(sessionID, runID, title string) error {
|
|
165
|
+
sid := strings.TrimSpace(sessionID)
|
|
166
|
+
if sid == "" {
|
|
167
|
+
return SetTitle(runID, title)
|
|
168
|
+
}
|
|
169
|
+
if err := SetTitle(sid, title); err != nil {
|
|
170
|
+
return err
|
|
171
|
+
}
|
|
172
|
+
dropSessionRunTitles(sid)
|
|
173
|
+
return nil
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// dropRunTitle 删掉一个 run_id 键(同 sid 时不动)。
|
|
177
|
+
func dropRunTitle(runID, sessionID string) {
|
|
178
|
+
rid := strings.TrimSpace(runID)
|
|
179
|
+
if rid == "" || rid == strings.TrimSpace(sessionID) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if TitleFor(rid) != "" {
|
|
183
|
+
_ = SetTitle(rid, "")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// dropSessionRunTitles 清掉这条会话**各轮 run_id** 上的标题键(保留 session_id 那个)。
|
|
188
|
+
//
|
|
189
|
+
// 名单取自登记表(每轮一条记录)。登记表清掉的陈年 run 键就留着了 —— 那时它已经不可达
|
|
190
|
+
// (没有记录指向它,客户端拿不到那个 id),无副作用。
|
|
191
|
+
func dropSessionRunTitles(sessionID string) {
|
|
192
|
+
recs, err := List()
|
|
193
|
+
if err != nil {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
for _, r := range recs {
|
|
197
|
+
if r.SessionID != sessionID {
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
dropRunTitle(r.RunID, sessionID)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// TitleForSessionRuns 在这条会话**各轮 run_id** 上找已有标题(没找到回空串)。
|
|
205
|
+
//
|
|
206
|
+
// 为什么需要:首轮归纳时引擎可能还没吐 session_id,标题就挂在那一轮的 run_id 上;而
|
|
207
|
+
// 追问轮的当前 run_id 是**另一个**。只认「session_id + 当前 run_id」的话,会判成
|
|
208
|
+
// 「还没归纳」→ 把这条会话的标题**重写一遍**(用户 2026-10-15 报的「每次追问也在更新标题」
|
|
209
|
+
// 就是这个现象;AI 归纳是独立调用,重写出来的字还会不一样)。
|
|
210
|
+
//
|
|
211
|
+
// 取**最早**的那一轮(登记表按 StartedAt 倒序,所以倒着扫):那是「这条会话第一次
|
|
212
|
+
// 生成标题」的结果,符合「只在第一次创建时生成」的期望。
|
|
213
|
+
func TitleForSessionRuns(sessionID string) string {
|
|
214
|
+
sid := strings.TrimSpace(sessionID)
|
|
215
|
+
if sid == "" {
|
|
216
|
+
return ""
|
|
217
|
+
}
|
|
218
|
+
recs, err := List()
|
|
219
|
+
if err != nil {
|
|
220
|
+
return ""
|
|
221
|
+
}
|
|
222
|
+
for i := len(recs) - 1; i >= 0; i-- {
|
|
223
|
+
r := recs[i]
|
|
224
|
+
if r.SessionID != sid || r.RunID == sid {
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if t := TitleFor(r.RunID); t != "" {
|
|
228
|
+
return t
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return ""
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// SetTitle 设置/清除一条会话的标题(空标题 = 清除覆盖,回到 prompt_head)。
|
|
235
|
+
//
|
|
236
|
+
// 并发写:两个客户端同时重命名,后写的整表覆盖会丢掉对方那条。
|
|
237
|
+
// 所以读-改-写全程持锁,并且**每次都重新从磁盘读**再合并 ——
|
|
238
|
+
// 拿进程内缓存去写会把另一个进程刚写的条目抹掉(插件时代就有这个洞)。
|
|
239
|
+
func SetTitle(id, title string) error {
|
|
240
|
+
id = strings.TrimSpace(id)
|
|
241
|
+
if id == "" {
|
|
242
|
+
return os.ErrInvalid
|
|
243
|
+
}
|
|
244
|
+
titleMu.Lock()
|
|
245
|
+
defer titleMu.Unlock()
|
|
246
|
+
p, err := TitleStorePath()
|
|
247
|
+
if err != nil {
|
|
248
|
+
return err
|
|
249
|
+
}
|
|
250
|
+
onDisk := map[string]string{}
|
|
251
|
+
if b, err := os.ReadFile(p); err == nil {
|
|
252
|
+
_ = json.Unmarshal(b, &onDisk)
|
|
253
|
+
}
|
|
254
|
+
t := strings.TrimSpace(title)
|
|
255
|
+
if t == "" {
|
|
256
|
+
delete(onDisk, id)
|
|
257
|
+
} else {
|
|
258
|
+
onDisk[id] = t
|
|
259
|
+
}
|
|
260
|
+
return saveTitlesLocked(onDisk)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── 归纳 ──────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
// buildTitlePrompt 归纳提示词。
|
|
266
|
+
//
|
|
267
|
+
// 规则与业务项目时期的 buildTitlePrompt 逐字一致(≤12 字、产品经理视角要点、
|
|
268
|
+
// 只输出标题本身)—— 换实现不换口径,否则同一句话在两端会归纳出不同标题。
|
|
269
|
+
func buildTitlePrompt(text string, max int) string {
|
|
270
|
+
return strings.Join([]string{
|
|
271
|
+
"你是资深产品经理。把下面这段「用户发给助手的第一句话」归纳成一句话要点,作为这条对话的标题。",
|
|
272
|
+
"要求:",
|
|
273
|
+
"1. 用中文,不超过 " + strconv.Itoa(max) + " 个汉字;",
|
|
274
|
+
"2. 是产品经理视角的要点(说清「做什么 / 要达成什么」),不要照抄原句、不要写整段话;",
|
|
275
|
+
"3. 不要标点符号、引号、书名号、序号,不要换行;",
|
|
276
|
+
"4. 不要任何解释,也不要「标题:」这类前缀 —— 只输出标题本身。",
|
|
277
|
+
"",
|
|
278
|
+
"用户的话:" + truncateRunes(strings.TrimSpace(text), titlePromptCap),
|
|
279
|
+
}, "\n")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// normalizeTitle 归一化模型输出:取首个非空行 → 剥前缀/引号/序号 → 收尾标点 → 硬截断。
|
|
283
|
+
// 模型偶发不听话(「标题:xxx」「1. xxx」「“xxx”」「xxx。」)都在这里收口。
|
|
284
|
+
//
|
|
285
|
+
// 收口用正则而不是逐层 TrimPrefix:前缀形态太多(`标题:` `标题 - ` `**标题:**` …),
|
|
286
|
+
// 逐个试会把「标题设计评审」这种**本身就以「标题」开头**的正常标题误剥。
|
|
287
|
+
func normalizeTitle(raw string, max int) string {
|
|
288
|
+
first := ""
|
|
289
|
+
for _, line := range strings.Split(raw, "\n") {
|
|
290
|
+
if s := strings.TrimSpace(stripFence(line)); s != "" {
|
|
291
|
+
first = s
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return trimTitleTail(truncateRunes(stripTitleDecor(first), max))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
var (
|
|
299
|
+
reFence = regexp.MustCompile("^```[a-zA-Z]*$")
|
|
300
|
+
// 前缀有两种形态,分开认(一条正则写不干净):
|
|
301
|
+
// ① **带分隔符**:标题:/ 标题: / 标题 - / **标题:**
|
|
302
|
+
// ② **括号包裹**:【标题】/ [标题] / (标题) —— 括号后面**没有**分隔符,
|
|
303
|
+
// 若强求分隔符,这条就不匹配,剥完前缀会剩一个「标题】」。
|
|
304
|
+
// 分开写才不会为了统一而让其中一种剥不掉。
|
|
305
|
+
reTitlePreColon = regexp.MustCompile(`^(?:\*\*)?\s*(?:需求)?(?:标题|题目|title)\s*(?:\*\*)?\s*[::\-—]\s*`)
|
|
306
|
+
reTitlePreBrack = regexp.MustCompile(`^(?:\*\*)?\s*[【\[((]\s*(?:需求)?(?:标题|题目|title)\s*[】\]))]\s*(?:\*\*)?\s*`)
|
|
307
|
+
reMarkdown = regexp.MustCompile(`^[#>\-*·]+\s*`)
|
|
308
|
+
reLeadingNum = regexp.MustCompile(`^\d+\s*[.、))::]\s*`)
|
|
309
|
+
reOpenQuote = regexp.MustCompile(`^[「『“”‘’"'《【\[((]+\s*`)
|
|
310
|
+
reCloseQuote = regexp.MustCompile(`\s*[」』“”‘’"'》】\]))]+$`)
|
|
311
|
+
reTailPunct = regexp.MustCompile(`[。,、;:!?,.;:!?~~\s]+$`)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// stripFence 剥模型爱加的 ``` 代码围栏(只处理整行就是围栏的情形)。
|
|
315
|
+
func stripFence(line string) string {
|
|
316
|
+
s := strings.TrimSpace(line)
|
|
317
|
+
if reFence.MatchString(s) {
|
|
318
|
+
return ""
|
|
319
|
+
}
|
|
320
|
+
return s
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// stripTitleDecor 剥「标题:」这类前缀、markdown 记号、序号、开引号。
|
|
324
|
+
func stripTitleDecor(s string) string {
|
|
325
|
+
s = strings.TrimSpace(s)
|
|
326
|
+
// 两种前缀各试一次:它们互斥(有没有分隔符),所以顺序无关。
|
|
327
|
+
s = reTitlePreColon.ReplaceAllString(s, "")
|
|
328
|
+
s = reTitlePreBrack.ReplaceAllString(s, "")
|
|
329
|
+
s = reMarkdown.ReplaceAllString(s, "")
|
|
330
|
+
s = reLeadingNum.ReplaceAllString(s, "")
|
|
331
|
+
return strings.TrimSpace(reOpenQuote.ReplaceAllString(s, ""))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func trimTitleTail(s string) string {
|
|
335
|
+
s = reCloseQuote.ReplaceAllString(s, "")
|
|
336
|
+
return strings.TrimSpace(reTailPunct.ReplaceAllString(s, ""))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
func truncateRunes(s string, n int) string {
|
|
340
|
+
r := []rune(s)
|
|
341
|
+
if len(r) <= n {
|
|
342
|
+
return s
|
|
343
|
+
}
|
|
344
|
+
return string(r[:n])
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// FirstUserText 取一条会话**首条 user 消息**的正文(标题归纳的输入)。
|
|
348
|
+
//
|
|
349
|
+
// 优先读事件日志(--save-history 落的那份,含真实首问);读不到再回落登记表的
|
|
350
|
+
// prompt_head(它就是首问的前 80 字,够归纳用)。日志优先是因为 prompt_head 被
|
|
351
|
+
// 截断过,长首问会归纳不出要点。
|
|
352
|
+
//
|
|
353
|
+
// ⚠️ dir 传空 = 用默认会话目录(Dir()),**不要**让空串退化成相对路径 ——
|
|
354
|
+
// 那会在 cwd 下找 `<id>.jsonl`,于是「日志明明在也读不到」,白跑一次 LLM。
|
|
355
|
+
func FirstUserText(dir, sessionID, promptHead string) string {
|
|
356
|
+
if strings.TrimSpace(dir) == "" {
|
|
357
|
+
dir = Dir()
|
|
358
|
+
}
|
|
359
|
+
if s := strings.TrimSpace(sessionID); s != "" && dir != "" {
|
|
360
|
+
if b, err := os.ReadFile(filepath.Join(dir, s+".jsonl")); err == nil {
|
|
361
|
+
for _, line := range strings.Split(string(b), "\n") {
|
|
362
|
+
line = strings.TrimSpace(line)
|
|
363
|
+
if line == "" {
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
var ev struct {
|
|
367
|
+
Kind string `json:"kind"`
|
|
368
|
+
Text string `json:"text"`
|
|
369
|
+
}
|
|
370
|
+
if json.Unmarshal([]byte(line), &ev) != nil {
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if ev.Kind == "user" && strings.TrimSpace(ev.Text) != "" {
|
|
374
|
+
return ev.Text
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return promptHead
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Summarize 为一条会话归纳标题并写进覆盖表。
|
|
383
|
+
//
|
|
384
|
+
// 为什么在 core 里跑 LLM(而不是让各客户端各自归纳):这是「统一口径」的唯一实现点。
|
|
385
|
+
// 用 `llm` 引擎(纯文本、无工具)—— 与业务项目归纳需求标题时用的是同一个引擎,
|
|
386
|
+
// 所以同一句话在两端会得到同一个标题。
|
|
387
|
+
//
|
|
388
|
+
// 超长再压一次(实测模型偶发给 13 字,硬截断会把「保持一致」切成「保持一」)。
|
|
389
|
+
// 失败**不抛**:调用方多在用户点击的同步路径上,抛出去就是没有反馈的死点击;
|
|
390
|
+
// 返回 ok=false 让界面保留已有的临时标题。
|
|
391
|
+
//
|
|
392
|
+
// 已经归纳过(有覆盖)且 force=false → 直接返回现有标题,不再调 LLM。
|
|
393
|
+
func Summarize(ctx context.Context, opts SummarizeOptions) (SummaryResult, error) {
|
|
394
|
+
sessionID := strings.TrimSpace(opts.SessionID)
|
|
395
|
+
runID := strings.TrimSpace(opts.RunID)
|
|
396
|
+
if sessionID == "" && runID == "" {
|
|
397
|
+
return SummaryResult{}, os.ErrInvalid
|
|
398
|
+
}
|
|
399
|
+
key := sessionID
|
|
400
|
+
if key == "" {
|
|
401
|
+
key = runID
|
|
402
|
+
}
|
|
403
|
+
if !opts.Force {
|
|
404
|
+
// ⚠️ 两个键都认(session_id / run_id):老的键可能落在上一轮的 run_id 上,
|
|
405
|
+
// 只认 session_id 会判成「还没归纳」→ 每轮重算(标题就跟着追问变)。
|
|
406
|
+
// 命中时顺手把标题迁到 session_id 上,此后键固定。
|
|
407
|
+
// 命中时顺手把键归一(值不动):老的 run_id 键清掉、只留 session_id ——
|
|
408
|
+
// 留着两把键,按不同 id 读就会拿到不同标题。
|
|
409
|
+
if t := TitleForAny(sessionID, runID); t != "" {
|
|
410
|
+
_ = ConsolidateTitle(sessionID, runID, t)
|
|
411
|
+
return SummaryResult{ID: key, Title: t, Cached: true}, nil
|
|
412
|
+
}
|
|
413
|
+
// 本会话**其他轮**的 run 键(首轮归纳时还没 session_id)也算命中 ——
|
|
414
|
+
// 否则会把已经有的标题重写一遍。
|
|
415
|
+
if t := TitleForSessionRuns(sessionID); t != "" {
|
|
416
|
+
_ = ConsolidateTitle(sessionID, runID, t)
|
|
417
|
+
return SummaryResult{ID: key, Title: t, Cached: true}, nil
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
src := strings.TrimSpace(opts.Text)
|
|
421
|
+
if src == "" {
|
|
422
|
+
return SummaryResult{}, os.ErrNotExist
|
|
423
|
+
}
|
|
424
|
+
engineName := strings.TrimSpace(opts.Engine)
|
|
425
|
+
if engineName == "" {
|
|
426
|
+
engineName = "llm"
|
|
427
|
+
}
|
|
428
|
+
engine := agent.Lookup(engineName)
|
|
429
|
+
if engine == nil {
|
|
430
|
+
return SummaryResult{}, os.ErrNotExist
|
|
431
|
+
}
|
|
432
|
+
timeout := opts.Timeout
|
|
433
|
+
if timeout <= 0 {
|
|
434
|
+
timeout = 60 * time.Second
|
|
435
|
+
}
|
|
436
|
+
run := func(max int, extra string) (string, error) {
|
|
437
|
+
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
438
|
+
defer cancel()
|
|
439
|
+
runner := &agent.Runner{Engine: engine, Timeout: timeout}
|
|
440
|
+
resp, err := runner.Run(cctx, agent.Request{
|
|
441
|
+
Messages: []agent.Message{{Role: "user", Content: buildTitlePrompt(src+extra, max)}},
|
|
442
|
+
Tools: agent.ToolsOff,
|
|
443
|
+
})
|
|
444
|
+
if err != nil {
|
|
445
|
+
return "", err
|
|
446
|
+
}
|
|
447
|
+
return normalizeTitle(resp.Text, TitleMaxChars), nil
|
|
448
|
+
}
|
|
449
|
+
title, err := run(TitleMaxChars, "")
|
|
450
|
+
if err != nil || title == "" {
|
|
451
|
+
if err == nil {
|
|
452
|
+
err = os.ErrInvalid
|
|
453
|
+
}
|
|
454
|
+
return SummaryResult{ID: key}, err
|
|
455
|
+
}
|
|
456
|
+
if len([]rune(normalizeTitle(title, 999))) > TitleMaxChars {
|
|
457
|
+
// 第一次给长了:加压再要一次更短的,仍按 12 字收口。
|
|
458
|
+
if shorter, serr := run(8, "\n(上一次给的标题太长了,请务必压缩到 8 个字以内)"); serr == nil && shorter != "" {
|
|
459
|
+
title = shorter
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if err := ConsolidateTitle(sessionID, runID, title); err != nil {
|
|
463
|
+
return SummaryResult{ID: key, Title: title}, err
|
|
464
|
+
}
|
|
465
|
+
return SummaryResult{ID: key, Title: title}, nil
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// SummarizeOptions 归纳一条会话标题的输入。
|
|
469
|
+
type SummarizeOptions struct {
|
|
470
|
+
SessionID string
|
|
471
|
+
RunID string
|
|
472
|
+
// Text 首问正文(空 = 自己从日志 / 登记表取)。
|
|
473
|
+
Text string
|
|
474
|
+
// Engine 归纳用哪个引擎(空 = llm,纯文本无工具)。
|
|
475
|
+
Engine string
|
|
476
|
+
// Force 已有覆盖时是否重新归纳(默认否:省一次 LLM 调用)。
|
|
477
|
+
Force bool
|
|
478
|
+
Timeout time.Duration
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// SummaryResult 归纳结果。
|
|
482
|
+
type SummaryResult struct {
|
|
483
|
+
ID string `json:"id"`
|
|
484
|
+
Title string `json:"title,omitempty"`
|
|
485
|
+
Cached bool `json:"cached,omitempty"`
|
|
486
|
+
}
|