magic-agent 1.0.111 → 2.3.0

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.
Files changed (55) hide show
  1. package/CLIENT-CONTRACT.md +117 -6
  2. package/client-ui/pc/app.css +3 -0
  3. package/client-ui/pc/index.html +5 -0
  4. package/client-ui/pc/js/05-task-list-render.js +28 -4
  5. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  6. package/client-ui/pc/js/93-rail.js +21 -1
  7. package/client-ui/shared/conv/03-chat-message.js +43 -0
  8. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  9. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  10. package/client-ui/shared/conv/19-attachments.js +75 -11
  11. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  12. package/client-ui/shared/conv/README.md +6 -0
  13. package/client-ui/shared/conv/conv.css +16 -1
  14. package/client-ui/shared/conversation.css +42 -4
  15. package/client-ui/shared/conversation.js +655 -87
  16. package/client-ui/shared/transport-http.js +16 -0
  17. package/client-ui-mobile/index.html +1052 -595
  18. package/client-ui-mobile/reqs.css +39 -0
  19. package/client-ui-mobile/reqs.js +507 -0
  20. package/cmd/magic-client/main.go +2 -1
  21. package/internal/cli/ask.go +6 -0
  22. package/internal/cli/root.go +5 -0
  23. package/internal/cli/session.go +16 -14
  24. package/internal/cli/suggest.go +98 -0
  25. package/internal/cli/suggest_test.go +54 -0
  26. package/internal/cli/team.go +111 -24
  27. package/internal/client/apk.go +135 -0
  28. package/internal/client/apk_test.go +99 -0
  29. package/internal/client/prefs.go +99 -0
  30. package/internal/client/service.go +202 -1
  31. package/internal/client/suggest.go +131 -0
  32. package/internal/client/suggest_test.go +116 -0
  33. package/internal/session/delete.go +58 -11
  34. package/internal/session/delete_test.go +140 -0
  35. package/internal/session/session.go +25 -0
  36. package/internal/session/session_mark_test.go +62 -0
  37. package/internal/suggest/suggest.go +421 -0
  38. package/internal/suggest/suggest_test.go +218 -0
  39. package/internal/team/board.go +107 -15
  40. package/internal/team/execute.go +1 -1
  41. package/internal/team/team.go +28 -19
  42. package/internal/team/team_test.go +68 -15
  43. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  44. package/npm/apk/version.json +5 -0
  45. package/npm/dist/darwin-arm64/magic-agent +0 -0
  46. package/npm/dist/darwin-arm64/magic-client +0 -0
  47. package/npm/dist/darwin-x64/magic-agent +0 -0
  48. package/npm/dist/darwin-x64/magic-client +0 -0
  49. package/npm/dist/linux-arm64/magic-agent +0 -0
  50. package/npm/dist/linux-arm64/magic-client +0 -0
  51. package/npm/dist/linux-x64/magic-agent +0 -0
  52. package/npm/dist/linux-x64/magic-client +0 -0
  53. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  54. package/npm/dist/win32-x64/magic-client.exe +0 -0
  55. package/package.json +6 -2
@@ -69,6 +69,146 @@ func TestDeleteRemovesEverything(t *testing.T) {
69
69
  }
70
70
  }
71
71
 
72
+ /*
73
+ 删除主会话要把**子任务会话**一起删(2026-10-23 用户:「列表中删除主会话时要把
74
+ 子任务的会话也删除」)。
75
+
76
+ 团队模式拆出的子任务是普通会话:各自有登记记录、日志、标题与状态,靠
77
+ ParentRunID / ParentSessionID 挂在主会话下。只删主会话会留下一批「没有父行可挂、
78
+ 却仍在列表 / 磁盘上」的孤儿 —— 和 2026-10-19「一端删了另一端还在」同一类错位。
79
+ 还要正向确认:删子会话**不该**反向带走主会话。
80
+ */
81
+ func TestDeleteCascadesToSubSessions(t *testing.T) {
82
+ dir := t.TempDir()
83
+ t.Setenv(EnvDir, dir)
84
+
85
+ parent, err := Begin(BeginOptions{Engine: "claude", PromptHead: "团队主会话"})
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ parent.Finish("sess-parent", StateDone)
90
+
91
+ // 子会话 1:父会话已有 session_id,两个父指针都有。
92
+ kid1, err := Begin(BeginOptions{
93
+ Engine: "claude", PromptHead: "子任务 1",
94
+ ParentRunID: parent.RunID(), ParentSessionID: "sess-parent",
95
+ })
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ kid1.Finish("sess-kid-1", StateDone)
100
+
101
+ // 子会话 2:创建时父会话还没拿到 session_id,只能按 parent_run_id 认亲。
102
+ kid2, err := Begin(BeginOptions{
103
+ Engine: "codex", PromptHead: "子任务 2", ParentRunID: parent.RunID(),
104
+ })
105
+ if err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ kid2.Finish("sess-kid-2", StateDone)
109
+
110
+ // 子会话 1 的第二轮(同 session_id、没有父指针):按「同会话多轮」也归它。
111
+ kid1b, err := Begin(BeginOptions{Engine: "claude", PromptHead: "子任务 1 追问", SessionID: "sess-kid-1"})
112
+ if err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ kid1b.Finish("sess-kid-1", StateDone)
116
+
117
+ // 无关会话:同引擎、同目录,但不是这条主会话的子会话 —— 必须留下。
118
+ other, err := Begin(BeginOptions{Engine: "claude", PromptHead: "无关会话"})
119
+ if err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ other.Finish("sess-other", StateDone)
123
+
124
+ // 每个名字都落一份日志(run_id 与 session_id 两种命名都覆盖)。
125
+ names := []string{
126
+ parent.RunID(), "sess-parent",
127
+ kid1.RunID(), "sess-kid-1", kid1b.RunID(),
128
+ kid2.RunID(), "sess-kid-2",
129
+ other.RunID(), "sess-other",
130
+ }
131
+ for _, name := range names {
132
+ if err := os.WriteFile(filepath.Join(dir, name+".jsonl"), []byte("{\"seq\":1}\n"), 0o600); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ }
136
+ for _, id := range []string{"sess-parent", "sess-kid-1", "sess-kid-2", "sess-other"} {
137
+ if err := SetTitle(id, "标题 "+id); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ }
141
+
142
+ res, err := Delete("sess-parent")
143
+ if err != nil {
144
+ t.Fatalf("Delete: %v", err)
145
+ }
146
+ // parent + kid1 / kid1b / kid2 共 4 条记录;无关会话不算。
147
+ if res.RecordsRemoved != 4 {
148
+ t.Errorf("登记记录删了 %d 条,want 4(主会话 + 3 条子会话记录)", res.RecordsRemoved)
149
+ }
150
+ if res.ChildSessionsRemoved != 2 {
151
+ t.Errorf("子会话删了 %d 条,want 2(sess-kid-1 / sess-kid-2)", res.ChildSessionsRemoved)
152
+ }
153
+ if res.LogsRemoved != 7 {
154
+ t.Errorf("日志删了 %d 份,want 7(主/子会话的 run_id 与 session_id 两个名字)", res.LogsRemoved)
155
+ }
156
+ if res.TitlesRemoved != 3 {
157
+ t.Errorf("标题清了 %d 条,want 3(主会话 + 两个子会话)", res.TitlesRemoved)
158
+ }
159
+
160
+ recs, _ := List()
161
+ if len(recs) != 1 || recs[0].RunID != other.RunID() {
162
+ t.Fatalf("登记表应只剩无关会话 %s,实得 %+v", other.RunID(), recs)
163
+ }
164
+ for _, name := range []string{
165
+ parent.RunID(), "sess-parent",
166
+ kid1.RunID(), "sess-kid-1", kid1b.RunID(),
167
+ kid2.RunID(), "sess-kid-2",
168
+ } {
169
+ if fileExists(filepath.Join(dir, name+".jsonl")) {
170
+ t.Errorf("日志 %s.jsonl 还在(子会话必须跟着主会话一起删)", name)
171
+ }
172
+ }
173
+ if !fileExists(filepath.Join(dir, other.RunID()+".jsonl")) {
174
+ t.Error("无关会话的日志被误删了")
175
+ }
176
+ for _, id := range []string{"sess-parent", "sess-kid-1", "sess-kid-2"} {
177
+ if got := TitleFor(id); got != "" {
178
+ t.Errorf("标题 %s 还有 %q,want 空", id, got)
179
+ }
180
+ if got := StatusFor(id); got != "" {
181
+ t.Errorf("状态 %s 还有 %q,want 空", id, got)
182
+ }
183
+ }
184
+ if TitleFor("sess-other") == "" || StatusFor("sess-other") == "" {
185
+ t.Error("无关会话的标题 / 状态被误删了")
186
+ }
187
+ }
188
+
189
+ // 删子会话只删它自己 —— 父指针是单向认亲,不能反向把主会话带走。
190
+ func TestDeleteSubSessionKeepsParent(t *testing.T) {
191
+ t.Setenv(EnvDir, t.TempDir())
192
+ parent, err := Begin(BeginOptions{Engine: "claude"})
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ parent.Finish("sess-parent", StateDone)
197
+ kid, err := Begin(BeginOptions{Engine: "claude", ParentRunID: parent.RunID(), ParentSessionID: "sess-parent"})
198
+ if err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ kid.Finish("sess-kid", StateDone)
202
+
203
+ if _, err := Delete("sess-kid"); err != nil {
204
+ t.Fatalf("Delete: %v", err)
205
+ }
206
+ recs, _ := List()
207
+ if len(recs) != 1 || recs[0].SessionID != "sess-parent" {
208
+ t.Fatalf("删子会话后应只剩主会话,实得 %+v", recs)
209
+ }
210
+ }
211
+
72
212
  /*
73
213
  幂等:id 不认识 / 文件本来就不在 / 再删一次 —— 都算成功。
74
214
 
@@ -323,6 +323,8 @@ type Handle struct {
323
323
  // 照常接受 pid / 状态 / 收尾回写,只是**不写文件** —— 调用方(CLI 默认)
324
324
  // 因此不留痕,而 run_id 仍能当追加入口的地址(见 AppendSocketPath)。
325
325
  noPersist bool
326
+ // lastTouch 上次 Touch 续期的时间(节流用,见 Touch)。
327
+ lastTouch time.Time
326
328
  }
327
329
 
328
330
  // RunID 本次运行的 id。
@@ -379,6 +381,29 @@ func (h *Handle) Mark(state string) {
379
381
  h.write()
380
382
  }
381
383
 
384
+ // Touch 续期一条活着的会话:只刷新 updated_at,状态不变。
385
+ //
386
+ // 为什么 Mark 不够(2026-10-22 真机复现):Mark 在「新旧状态相同」时直接 return,
387
+ // 一次盘都不写 —— 调用方以为自己在「每来一条事件就续一次期」,实际上 updated_at
388
+ // 从第一条事件起就冻住了。别端的尾随连接(followStateEvery=5s)靠 updated_at 判活,
389
+ // 团队模式计划 + 子任务几分钟不动,就被归一成 gone,画面提前落「这一轮已结束」。
390
+ //
391
+ // 节流:调用方可能每秒来几十条事件,没必要每条都落盘;minGap 内直接跳过。
392
+ // 状态已进终态(或进程已不在)时不动 —— 终态是结论,不许被心跳续活。
393
+ func (h *Handle) Touch(minGap time.Duration) {
394
+ if h == nil || h.noPersist {
395
+ return
396
+ }
397
+ if !isLiveState(normalizeState(h.rec.State)) {
398
+ return
399
+ }
400
+ if !h.lastTouch.IsZero() && time.Since(h.lastTouch) < minGap {
401
+ return
402
+ }
403
+ h.lastTouch = time.Now()
404
+ h.write()
405
+ }
406
+
382
407
  // Finish 收尾:写最终状态(done / failed / cancelled / interrupted)+ 回填会话 id。
383
408
  //
384
409
  // 一个刻意的小保护:被别人 `--stop` 停掉、或用户中途打断时,磁盘上已经是
@@ -3,6 +3,7 @@ package session
3
3
  import (
4
4
  "os"
5
5
  "testing"
6
+ "time"
6
7
  )
7
8
 
8
9
  /* Handle.Mark:唯一的中途写状态入口(2026-09-29 统一会话状态词表时新增)。
@@ -132,3 +133,64 @@ func TestNormalizeLegacyState(t *testing.T) {
132
133
  t.Errorf("不认识的词原样返回,得到 %q", got)
133
134
  }
134
135
  }
136
+
137
+ /* Handle.Touch:只续期,不改状态(2026-10-22 报障「团队模式子任务还在跑 主会话
138
+ * 就说结束了」的真因之一)。
139
+ *
140
+ * 为什么 Mark 顶不上:Mark 在新旧状态相同时**直接 return、一次盘都不写**。流式路径
141
+ * 里每条事件都喊一次 Mark(running),看起来像在续期,实际 updated_at 从第一条事件
142
+ * 起就冻住了 —— 团队模式计划 + 子任务几分钟不动,网关尾随连接据此判终态,
143
+ * 画面提前落「这一轮已结束」。 */
144
+ func TestHandleTouchRefreshesUpdatedAt(t *testing.T) {
145
+ t.Setenv(EnvDir, t.TempDir())
146
+ h, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
147
+ if err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ runID := h.RunID()
151
+
152
+ // Mark 同值不写盘:这是 Touch 存在的理由,钉住它免得日后被「顺手优化」掉
153
+ before, _ := readRecord(h.path)
154
+ h.Mark(StateRunning)
155
+ after, _ := readRecord(h.path)
156
+ if !after.UpdatedAt.Equal(before.UpdatedAt) {
157
+ t.Errorf("Mark 同值不该写盘:%v → %v", before.UpdatedAt, after.UpdatedAt)
158
+ }
159
+
160
+ // Touch 必须真的把 updated_at 往前推
161
+ h.lastTouch = time.Time{} // 跳过节流
162
+ h.Touch(0)
163
+ rec, found, _ := Find(runID)
164
+ if !found {
165
+ t.Fatal("记录不见了")
166
+ }
167
+ if !rec.UpdatedAt.After(before.UpdatedAt) {
168
+ t.Errorf("Touch 没续期:%v → %v", before.UpdatedAt, rec.UpdatedAt)
169
+ }
170
+ if rec.State != StateRunning {
171
+ t.Errorf("Touch 不该改状态,得到 %q", rec.State)
172
+ }
173
+ }
174
+
175
+ func TestHandleTouchThrottlesAndRespectsTerminal(t *testing.T) {
176
+ t.Setenv(EnvDir, t.TempDir())
177
+ h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
178
+ h.lastTouch = time.Time{}
179
+ h.Touch(time.Hour) // 第一次写
180
+ first, _ := readRecord(h.path)
181
+
182
+ h.Touch(time.Hour) // 节流窗口内:不写
183
+ second, _ := readRecord(h.path)
184
+ if !second.UpdatedAt.Equal(first.UpdatedAt) {
185
+ t.Errorf("节流窗口内不该写盘:%v → %v", first.UpdatedAt, second.UpdatedAt)
186
+ }
187
+
188
+ // 终态是结论,心跳不许把它续活
189
+ h.Finish("", StateDone)
190
+ h.lastTouch = time.Time{}
191
+ h.Touch(0)
192
+ final, _ := readRecord(h.path)
193
+ if final.State != StateDone {
194
+ t.Errorf("Touch 把终态改掉了:%q", final.State)
195
+ }
196
+ }
@@ -0,0 +1,421 @@
1
+ // Package suggest - 生成「下一轮快捷回复」的建议(一轮对话结束后给用户可点的下一句)。
2
+ //
3
+ // 为什么要有它(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」):
4
+ // 每轮回答结束后,用户往往还要再打一句「继续 / 看下 diff / 先别动」。这些句子**就在回答里**
5
+ // (AI 问「要我继续吗?」、AI 列了「下一步」),把它提炼成按钮能省一次打字 —— 但提炼必须
6
+ // 来自模型对**这一轮原文**的理解,不能套模板硬凑(模板会给出与内容无关的噪音)。
7
+ //
8
+ // 三条纪律:
9
+ // 1. **一次性旁路调用**:`agent.Runner` + `ToolsOff` + 不带 SessionID —— 独立进程、独立会话,
10
+ // 不续接也不污染用户正在聊的那条上下文;不经过 CLI 的 ask 通道,不留会话登记。
11
+ // 2. **宁缺毋滥**:模型没给出可用句子、或这轮本来就没有下一步时返回空数组,调用方据此不画按钮。
12
+ // 3. **不编造**:提示词明确禁止引入对话里没出现过的文件 / 数字 / 结论;解析侧再做一层长度、
13
+ // 去重、形态校验,挡掉「AI 的回答」冒充「用户的话」。
14
+ //
15
+ // 与 `--summarize-title`(internal/session/title.go::Summarize)的分工:那条把会话首问压成
16
+ // 标题、结果落进标题表;这条只产出一组**临时**建议,不落盘、不缓存 —— 下一轮结束就重算。
17
+ package suggest
18
+
19
+ import (
20
+ "context"
21
+ "encoding/json"
22
+ "fmt"
23
+ "regexp"
24
+ "strings"
25
+ "time"
26
+ "unicode/utf8"
27
+
28
+ "github.com/darren/magic-agent/internal/agent"
29
+ )
30
+
31
+ // 默认参数。取「短、快」:建议是锦上添花,不能让它拖住下一轮。
32
+ const (
33
+ // DefaultEngine 默认用 llm:纯文本、不带工具、没有 CLI 会话副作用(与 --title-engine 同口径)。
34
+ DefaultEngine = "llm"
35
+ // DefaultCount 最多给几条建议(UI 一行放得下 3 枚)。
36
+ DefaultCount = 3
37
+ // MaxLabelRunes 单条建议的字数上限。提示词要求 ≤16;解析时放宽到 28 仍然接受,
38
+ // 再长就不是「随手发的一句话」了,直接丢掉(不截断 —— 截断后的半句话发出去更糟)。
39
+ MaxLabelRunes = 28
40
+ // DefaultTimeout 单次旁路调用的超时。到点没有结果就当没有建议(调用方不等待)。
41
+ DefaultTimeout = 22 * time.Second
42
+ )
43
+
44
+ // Request 生成建议的输入。
45
+ type Request struct {
46
+ // User 用户最新那一句(可为空:比如本轮是系统派发的任务)。
47
+ User string
48
+ // Reply 引擎这一轮的**权威正文**(必填;空 = 没有可提炼的内容)。
49
+ Reply string
50
+ // Engine 用哪个引擎生成(空 = DefaultEngine)。
51
+ Engine string
52
+ // Model 模型标识(空 = 引擎默认)。
53
+ Model string
54
+ // Count 最多几条(<=0 = DefaultCount)。
55
+ Count int
56
+ // Timeout 单次调用超时(<=0 = DefaultTimeout)。
57
+ Timeout time.Duration
58
+ }
59
+
60
+ // Result 生成结果。
61
+ type Result struct {
62
+ Suggestions []string `json:"suggestions"`
63
+ Engine string `json:"engine,omitempty"`
64
+ Model string `json:"model,omitempty"`
65
+ ElapsedMS int64 `json:"elapsedMs,omitempty"`
66
+ }
67
+
68
+ // RequestBody 是 CLI / 插件之间传的 JSON 形状(只含对话内容,引擎走 -e/-m)。
69
+ type RequestBody struct {
70
+ User string `json:"user"`
71
+ Reply string `json:"reply"`
72
+ }
73
+
74
+ // Prompt 拼提示词。截断策略:用户那句取**开头**(它通常很短),回复取**结尾** ——
75
+ // 下一步 / 待确认的问题几乎都在最后一段,截掉尾部等于把要提炼的东西扔了。
76
+ func Prompt(user, reply string, count int) string {
77
+ if count <= 0 || count > DefaultCount {
78
+ count = DefaultCount
79
+ }
80
+ u := strings.TrimSpace(user)
81
+ if n := len([]rune(u)); n > 1500 {
82
+ u = string([]rune(u)[:1500]) + "…"
83
+ }
84
+ r := strings.TrimSpace(reply)
85
+ if n := len([]rune(r)); n > 6000 {
86
+ r = "…" + string([]rune(r)[n-6000:])
87
+ }
88
+ var b strings.Builder
89
+ b.WriteString("你是「下一句」建议器,服务于一个正在用 AI agent 干活的用户。\n")
90
+ b.WriteString("读完下面这一轮对话,给出用户此刻最可能**直接发出去**的简短指令。\n\n")
91
+ b.WriteString("硬约束:\n")
92
+ fmt.Fprintf(&b, "1. 最多 %d 条;每条 ≤ 16 个汉字,口语,像用户随手打的;不要编号、引号、句末标点。\n", count)
93
+ b.WriteString("2. 必须是**用户对 AI 说的话**,不是 AI 的回答,也不是给用户的说明。\n")
94
+ b.WriteString("3. 只依据这一轮里**已经出现**的内容(AI 问的问题、列出的下一步、给出的选项)。\n")
95
+ b.WriteString(" 不许编造对话里没有的文件、数字、结论;拿不准就不要写。\n")
96
+ b.WriteString("4. AI 在等拍板 → 给直接的肯定/否定答复;AI 列了下一步 → 挑最关键的 1-2 条;\n")
97
+ b.WriteString(" 给多条时按「最该先做」排序,**第一条就是最佳建议**(托管模式会直接发它);\n")
98
+ b.WriteString(" 这一轮已经彻底结束、没有下一步 → 给空数组。宁缺毋滥。\n")
99
+ b.WriteString("5. 只输出 JSON:{\"suggestions\":[\"…\",\"…\"]},不要解释、不要 markdown 围栏。\n")
100
+ b.WriteString("6. 这一轮如果是**交付型任务**(小说 / 故事 / 文章 / 诗 / 剧本 / 文案 / 报告 / 代码 / 图纸),\n")
101
+ b.WriteString(" 而 AI 已经把成品完整交出来、没有提问、也没有列出下一步 → **必须给空数组**。\n")
102
+ b.WriteString(" 不要建议「再来一篇」「换个题材」「再写一个」「继续写」这类**另起一篇**的话;\n")
103
+ b.WriteString(" 除非 AI 在这一轮明确问「要不要再来一篇 / 下一篇写什么」。即使用户上一句就是\n")
104
+ b.WriteString(" 「再来一篇」,只要这一篇已经交付完、没有新问题,也算结束。\n\n")
105
+ b.WriteString("例 1(交付完成 → 空):\n")
106
+ b.WriteString("用户:写一个短篇恋爱小说\n")
107
+ b.WriteString("AI:# 标题 …(完整故事,结尾收束,没有提问)\n")
108
+ b.WriteString("输出:{\"suggestions\":[]}\n\n")
109
+ b.WriteString("例 2(AI 在等拍板 → 1-2 条):\n")
110
+ b.WriteString("用户:修一下登录 bug\n")
111
+ b.WriteString("AI:修好了,测试通过。要不要我顺手把过期 token 也处理掉?\n")
112
+ b.WriteString("输出:{\"suggestions\":[\"处理过期 token\",\"先这样\"]}\n\n")
113
+ b.WriteString("用户刚才说:\n")
114
+ if u == "" {
115
+ b.WriteString("(本轮由系统派发,没有用户原话)\n")
116
+ } else {
117
+ b.WriteString(u + "\n")
118
+ }
119
+ b.WriteString("\nAI 的回复:\n")
120
+ b.WriteString(r)
121
+ b.WriteString("\n")
122
+ return b.String()
123
+ }
124
+
125
+ // Schema 结构化输出契约(llm 引擎会按它做一次 JSON 后处理;其余引擎靠提示词约束)。
126
+ func Schema() *agent.JSONSchema {
127
+ return &agent.JSONSchema{
128
+ Type: "object",
129
+ Properties: map[string]map[string]any{
130
+ "suggestions": {"type": "array", "items": map[string]any{"type": "string"}},
131
+ },
132
+ Required: []string{"suggestions"},
133
+ }
134
+ }
135
+
136
+ // Parse 从模型输出里抽建议。容忍三种形态:JSON 对象 {"suggestions":[…]}
137
+ // (带不带 ``` 围栏都行)、裸 JSON 数组、以及一眼能认出的逐行短句。
138
+ // 解析失败 / 全是废句 → 返回空切片(不是错误:没有建议是合法结果)。
139
+ func Parse(raw string, count int) []string {
140
+ if count <= 0 || count > DefaultCount {
141
+ count = DefaultCount
142
+ }
143
+ text := strings.TrimSpace(raw)
144
+ if text == "" {
145
+ return []string{}
146
+ }
147
+ if obj := firstJSONValue(text); obj != nil {
148
+ var arr []any
149
+ switch v := obj.(type) {
150
+ case map[string]any:
151
+ if a, ok := v["suggestions"].([]any); ok {
152
+ arr = a
153
+ }
154
+ case []any:
155
+ arr = v
156
+ }
157
+ if arr != nil {
158
+ return normalize(arrToStrings(arr), count)
159
+ }
160
+ }
161
+ return normalize(splitLines(text), count)
162
+ }
163
+
164
+ // Generate 跑一次旁路生成。失败时返回空建议 + error(调用方如实降级:不画按钮)。
165
+ func Generate(ctx context.Context, in Request) (Result, error) {
166
+ reply := strings.TrimSpace(in.Reply)
167
+ if reply == "" {
168
+ return Result{Suggestions: []string{}}, nil
169
+ }
170
+ name := strings.TrimSpace(in.Engine)
171
+ if name == "" {
172
+ name = DefaultEngine
173
+ }
174
+ engine := agent.Lookup(name)
175
+ if engine == nil {
176
+ return Result{}, fmt.Errorf("建议生成:未知引擎 %q", name)
177
+ }
178
+ timeout := in.Timeout
179
+ if timeout <= 0 {
180
+ timeout = DefaultTimeout
181
+ }
182
+ cctx, cancel := context.WithTimeout(ctx, timeout)
183
+ defer cancel()
184
+ start := time.Now()
185
+ runner := &agent.Runner{Engine: engine, Timeout: timeout}
186
+ resp, err := runner.Run(cctx, agent.Request{
187
+ Model: strings.TrimSpace(in.Model),
188
+ Tools: agent.ToolsOff,
189
+ Reasoning: "off", // 建议要快;思考过程只会拖长等待
190
+ MaxTokens: 300,
191
+ Messages: []agent.Message{{Role: "user", Content: Prompt(in.User, reply, in.Count)}},
192
+ JSONSchema: Schema(),
193
+ })
194
+ if err != nil {
195
+ return Result{}, err
196
+ }
197
+ items := Parse(resp.Text, in.Count)
198
+ items = dropCreativeRestarts(items, in.User, reply)
199
+ return Result{
200
+ Suggestions: items,
201
+ Engine: engine.Name(),
202
+ Model: resp.Model,
203
+ ElapsedMS: time.Since(start).Milliseconds(),
204
+ }, nil
205
+ }
206
+
207
+ // ── 交付完成后的「另起一篇」过滤(2026-10-23 用户实测)─────────────────
208
+ //
209
+ // 提示词已经要求「交付完就给空数组」,但模型仍会把「再写一篇 / 换个题材」当成下一步
210
+ // (用户截图:短篇写完,托管自动发出「再来一篇职场题材的」,又生成一篇)。
211
+ // 这一层是**确定性兜底**:只在创作类轮次里,丢掉「另起一篇」形态的建议;
212
+ // AI 明确问「要不要再来一篇 / 下一篇写什么」时放行。
213
+
214
+ var (
215
+ creativeTurnRe = regexp.MustCompile(`小说|故事|文章|短篇|长篇|诗歌?|剧本|文案|作文|题材|写一篇|再写一篇|再来一篇|创作`)
216
+ // 「再来一篇 / 再写一章 / 再画一首」:量词是篇/章/首/部,基本只用于创作。
217
+ anotherWritingRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|两)?(篇|章|首|部)`)
218
+ // 「再来个职场篇 / 再写一个短篇」:没有创作名词,但以篇/章/首/部收尾。
219
+ anotherPieceRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|个)?[^,。!?]{0,10}(篇|章|首|部)$`)
220
+ // 「再写一个校园故事 / 来个职场段子」:量词是「个/段」时必须带创作名词。
221
+ anotherCreativeRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|个|段)?[^,。!?]{0,8}(小说|故事|文章|散文|诗歌?|剧本|文案|作文|童话|段子|番外|续集|续篇)`)
222
+ switchTopicRe = regexp.MustCompile(`^(换|改)(一|个)?[^,。!?]{0,8}(题材|主题|方向|背景)`)
223
+ nextPieceRe = regexp.MustCompile(`^下一(篇|章|首|部)`)
224
+ continueWriteRe = regexp.MustCompile(`^(继续|接着)(写|创作|生成|画)`)
225
+ bareContinueRe = regexp.MustCompile(`^(继续|接着)(吧|来)?$`)
226
+ inviteAnotherRe = regexp.MustCompile(`(要不要|需要我|想不想|是否).{0,10}(再|下一|续)|要继续吗|接着(写|来)吗|下一(篇|章|个)|续写|告诉我.{0,10}(题材|主题|方向)`)
227
+ )
228
+
229
+ // looksLikeCreativeReply 识别「用户只说继续,但 AI 交的是一篇完整创作」:
230
+ // Markdown 一级标题 + 超过 400 字 + 没有代码块。技术长文也按此处理(成品交付完不该再
231
+ // 建议「另起一篇」),带代码块的工程回答则排除,避免误杀「再写一个测试」。
232
+ func looksLikeCreativeReply(reply string) bool {
233
+ r := strings.TrimSpace(reply)
234
+ if !strings.HasPrefix(r, "# ") || strings.Contains(r, "```") {
235
+ return false
236
+ }
237
+ return utf8.RuneCountInString(r) >= 400
238
+ }
239
+
240
+ // dropCreativeRestarts 把「成品交付完之后的另起一篇」过滤掉。
241
+ // 只在创作类轮次(用户这句本身是创作请求,或 AI 交了一篇完整创作)生效 ——
242
+ // 写代码时「再写一个测试」是正常下一步,不能误杀。
243
+ func dropCreativeRestarts(items []string, user, reply string) []string {
244
+ if !creativeTurnRe.MatchString(strings.TrimSpace(user)) && !looksLikeCreativeReply(reply) {
245
+ return items
246
+ }
247
+ tail := []rune(strings.TrimSpace(reply))
248
+ if len(tail) > 600 {
249
+ tail = tail[len(tail)-600:]
250
+ }
251
+ invited := inviteAnotherRe.MatchString(string(tail))
252
+ unfinished := strings.Contains(string(tail), "未完") || strings.Contains(string(tail), "待续") || strings.Contains(string(tail), "下一章")
253
+ explicitCreative := creativeTurnRe.MatchString(strings.TrimSpace(user))
254
+ out := make([]string, 0, len(items))
255
+ for _, s := range items {
256
+ t := strings.TrimSpace(s)
257
+ if t == "" {
258
+ continue
259
+ }
260
+ restart := anotherWritingRe.MatchString(t) || anotherPieceRe.MatchString(t) ||
261
+ anotherCreativeRe.MatchString(t) || switchTopicRe.MatchString(t) || nextPieceRe.MatchString(t)
262
+ if restart && !invited {
263
+ continue
264
+ }
265
+ if continueWriteRe.MatchString(t) && !invited && !unfinished {
266
+ continue
267
+ }
268
+ /* 光秃秃一句「继续」是最容易把成品续写成第二篇的:只在用户这句本身是创作请求时挡。 */
269
+ if bareContinueRe.MatchString(t) && explicitCreative && !invited && !unfinished {
270
+ continue
271
+ }
272
+ out = append(out, s)
273
+ }
274
+ return out
275
+ }
276
+
277
+ // ── 解析辅助 ──────────────────────────────────────────────────────────────
278
+
279
+ // firstJSONValue 从可能裹着围栏 / 解释文字的输出里抠出**第一个** JSON 值。
280
+ // 用 json.Decoder 逐个 '{' / '[' 试(比正则稳:能正确处理字符串里的括号)。
281
+ func firstJSONValue(s string) any {
282
+ s = stripFence(s)
283
+ for i := 0; i < len(s); i++ {
284
+ if s[i] != '{' && s[i] != '[' {
285
+ continue
286
+ }
287
+ dec := json.NewDecoder(strings.NewReader(s[i:]))
288
+ dec.UseNumber()
289
+ var v any
290
+ if err := dec.Decode(&v); err == nil {
291
+ return v
292
+ }
293
+ }
294
+ return nil
295
+ }
296
+
297
+ // dropFenced 去掉整段 ``` 围栏(含围栏里的代码):逐行兜底时不能把代码示例当建议。
298
+ func dropFenced(s string) string {
299
+ var b strings.Builder
300
+ inFence := false
301
+ for _, ln := range strings.Split(s, "\n") {
302
+ if strings.HasPrefix(strings.TrimSpace(ln), "```") {
303
+ inFence = !inFence
304
+ continue
305
+ }
306
+ if !inFence {
307
+ b.WriteString(ln)
308
+ b.WriteString("\n")
309
+ }
310
+ }
311
+ return b.String()
312
+ }
313
+
314
+ func stripFence(s string) string {
315
+ t := strings.TrimSpace(s)
316
+ if !strings.HasPrefix(t, "```") {
317
+ return t
318
+ }
319
+ if i := strings.Index(t, "\n"); i >= 0 {
320
+ t = t[i+1:]
321
+ } else {
322
+ t = strings.TrimPrefix(t, "```")
323
+ }
324
+ if i := strings.LastIndex(t, "```"); i >= 0 {
325
+ t = t[:i]
326
+ }
327
+ return strings.TrimSpace(t)
328
+ }
329
+
330
+ func arrToStrings(a []any) []string {
331
+ out := make([]string, 0, len(a))
332
+ for _, v := range a {
333
+ if s, ok := v.(string); ok {
334
+ out = append(out, s)
335
+ }
336
+ }
337
+ return out
338
+ }
339
+
340
+ // splitLines 兜底:模型没按 JSON 回(或只回了一串短句)时,按行取。
341
+ // 只保留「像一句指令」的行 —— 带冒号解释、带代码、太长的一律不要。
342
+ func splitLines(text string) []string {
343
+ var out []string
344
+ for _, ln := range strings.Split(dropFenced(text), "\n") {
345
+ ln = strings.TrimSpace(ln)
346
+ if ln == "" {
347
+ continue
348
+ }
349
+ ln = trimListPrefix(ln)
350
+ if strings.ContainsAny(ln, "`{}<>") || strings.Contains(ln, ":") || strings.Contains(ln, "://") {
351
+ continue
352
+ }
353
+ out = append(out, ln)
354
+ }
355
+ return out
356
+ }
357
+
358
+ // normalize 归一 + 校验:剥引号/列表号/句末标点,丢弃过长、多行、像 AI 回答的句子,去重截断。
359
+ func normalize(in []string, count int) []string {
360
+ out := make([]string, 0, count)
361
+ seen := make(map[string]bool, len(in))
362
+ for _, raw := range in {
363
+ s := strings.TrimSpace(raw)
364
+ s = trimListPrefix(s)
365
+ /* 引号与句末标点会互相挡着(「好的」。 里右引号在句号前面):多剥两轮。 */
366
+ for i := 0; i < 2; i++ {
367
+ s = strings.TrimRight(s, "。..!!??;;,,")
368
+ s = strings.Trim(s, " \t\r\n\"'“”‘’「」『』")
369
+ }
370
+ s = strings.TrimRight(s, "。..!!??;;,,")
371
+ s = strings.Join(strings.Fields(s), " ")
372
+ if s == "" || utf8.RuneCountInString(s) > MaxLabelRunes {
373
+ continue
374
+ }
375
+ /* 以省略号开头 = 上游被截断的半句话(见 Prompt 的截断),发出去会缺主语。 */
376
+ if strings.HasPrefix(s, "…") || strings.ContainsAny(s, "\n\r") {
377
+ continue
378
+ }
379
+ /* 「AI 的答复」冒充「用户的话」:这类开头一定不是用户要发的。 */
380
+ if strings.HasPrefix(s, "AI") || strings.HasPrefix(s, "助手") || strings.HasPrefix(s, "Agent") {
381
+ continue
382
+ }
383
+ if seen[s] {
384
+ continue
385
+ }
386
+ seen[s] = true
387
+ out = append(out, s)
388
+ if len(out) >= count {
389
+ break
390
+ }
391
+ }
392
+ return out
393
+ }
394
+
395
+ // trimListPrefix 去掉「1. / 1、/ - / • / (1)」这类列表前缀(模型偶尔会自作主张编号)。
396
+ func trimListPrefix(s string) string {
397
+ t := strings.TrimSpace(s)
398
+ for i := 0; i < 3; i++ { // 最多剥三层(如 "- 1. xxx")
399
+ prev := t
400
+ t = strings.TrimLeft(t, "-*•· ")
401
+ for _, sep := range []string{".", "、", ")", ")", ":"} {
402
+ if j := strings.Index(t, sep); j > 0 && j <= 3 {
403
+ allDigit := true
404
+ for _, r := range t[:j] {
405
+ if r < '0' || r > '9' {
406
+ allDigit = false
407
+ break
408
+ }
409
+ }
410
+ if allDigit {
411
+ t = strings.TrimSpace(t[j+len(sep):])
412
+ break
413
+ }
414
+ }
415
+ }
416
+ if t == prev {
417
+ break
418
+ }
419
+ }
420
+ return strings.TrimSpace(t)
421
+ }