magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,231 @@
1
+ package requirements
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/darren/magic-agent/internal/agent"
13
+ "github.com/darren/magic-agent/internal/session"
14
+ )
15
+
16
+ // TestAppendAndCompactRelations 追加重复行 → compact 后去重、字段取最新、顺序稳定。
17
+ func TestAppendAndCompactRelations(t *testing.T) {
18
+ root := testEnv(t)
19
+ id := DirID(filepath.Join(root, "proj-rel"))
20
+
21
+ base := Relation{Session: "s-1", STitle: "会话一", Module: "任务列表", Feature: "加待办", N: 3}
22
+ r1 := base
23
+ r1.Ts = 100
24
+ r2 := base
25
+ r2.Ts = 200 // 同键重复:compact 应保 Ts=200 这条
26
+ r3 := Relation{Session: "s-1", STitle: "会话一", Module: "任务列表", Feature: "加待办", N: 5, Ts: 300}
27
+ r4 := Relation{Session: "s-2", Module: "任务列表", Feature: "加待办", N: 3, Ts: 400}
28
+ if err := appendRelations(id, []Relation{r1, r3, r4}); err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ if err := appendRelations(id, []Relation{r2}); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ if rels := readRelations(id); len(rels) != 4 {
35
+ t.Fatalf("追加期不去重,应 4 行,实际 %d", len(rels))
36
+ }
37
+ if err := compactRelations(id); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ rels := readRelations(id)
41
+ if len(rels) != 3 {
42
+ t.Fatalf("去重后应 3 行,实际 %d: %+v", len(rels), rels)
43
+ }
44
+ if rels[0].Ts != 200 {
45
+ t.Errorf("同键重复应保最新,实际 Ts=%d", rels[0].Ts)
46
+ }
47
+ if rels[2].STitle != "" {
48
+ t.Errorf("快照缺失就该缺着,不伪造: %+v", rels[2])
49
+ }
50
+ // compact 幂等
51
+ if err := compactRelations(id); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if rels2 := readRelations(id); len(rels2) != 3 {
55
+ t.Errorf("二次 compact 不应变化,实际 %d", len(rels2))
56
+ }
57
+ }
58
+
59
+ // TestConsolidateWritesRelations 归纳落账关系:module 用**改写后**的归属;
60
+ // 模块合并后 relations 里的 from 一并改写成 to。
61
+ func TestConsolidateWritesRelations(t *testing.T) {
62
+ root := testEnv(t)
63
+ proj := filepath.Join(root, "proj-rel-cons")
64
+ if err := os.MkdirAll(proj, 0o700); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ writeSessionLog(t, session.Dir(), "s-rel", proj, "引擎切换加上", "调用时带上超时")
68
+ id := DirID(proj)
69
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
70
+ t.Fatal(err)
71
+ }
72
+
73
+ old := newCaller
74
+ defer func() { newCaller = old }()
75
+ round := 0
76
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
77
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
78
+ switch {
79
+ case strings.Contains(prompt, "== 归纳规则 =="):
80
+ round++
81
+ if round == 1 {
82
+ return `{"features":[{"module":"引擎管理","name":"引擎切换","desc":"用户切换默认引擎","points":["可切换默认引擎"],"sources":[1,2]}]}`, nil
83
+ }
84
+ return `{"features":[{"module":"引擎调用","name":"调用超时","desc":"用户设置调用超时","points":["可设置调用超时"],"sources":[3]}]}`, nil
85
+ case strings.Contains(prompt, "本轮新增模块"):
86
+ return `{"pairs":[]}`, nil
87
+ default:
88
+ return `{"pairs":[{"from":"引擎调用","to":"引擎管理"}]}`, nil
89
+ }
90
+ }, nil
91
+ }
92
+
93
+ if _, err := Consolidate(context.Background(), proj, Options{}); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ rels := readRelations(id)
97
+ if len(rels) != 2 {
98
+ t.Fatalf("第一轮应落 2 条关系(#1#2 → 引擎切换),实际 %+v", rels)
99
+ }
100
+ for _, r := range rels {
101
+ if r.Session != "s-rel" {
102
+ t.Errorf("session 应取记忆条目,实际 %q", r.Session)
103
+ }
104
+ if r.STitle == "" {
105
+ t.Errorf("STitle 应有会话标题快照: %+v", r)
106
+ }
107
+ if r.Feature == "引擎切换" && r.Module != "引擎管理" {
108
+ t.Errorf("module 用改写后的归属,实际 %q", r.Module)
109
+ }
110
+ }
111
+
112
+ // 追加一条记忆触发第二轮 + 模块合并 → relations 的 module 应已改写。
113
+ ev, err := os.OpenFile(filepath.Join(session.Dir(), "s-rel.jsonl"), os.O_APPEND|os.O_WRONLY, 0o600)
114
+ if err != nil {
115
+ t.Fatal(err)
116
+ }
117
+ line := `{"seq":100,"kind":"user","text":"调用默认引擎带上超时","at":"2026-10-25T10:00:00Z","session_id":"s-rel"}` + "\n"
118
+ if _, err := ev.WriteString(line); err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ if err := ev.Close(); err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ if _, err := Consolidate(context.Background(), proj, Options{}); err != nil {
125
+ t.Fatal(err)
126
+ }
127
+ found := false
128
+ for _, r := range readRelations(id) {
129
+ if r.Feature == "调用超时" && r.N == 3 {
130
+ found = true
131
+ }
132
+ if r.Module == "引擎调用" {
133
+ t.Errorf("模块合并后 relations 里的 from 应已改写: %+v", r)
134
+ }
135
+ }
136
+ if !found {
137
+ t.Errorf("第二轮应补上 #3 → 调用超时的关系: %+v", readRelations(id))
138
+ }
139
+ }
140
+
141
+ // TestRewriteRelationsModule 直接改写:from → to。
142
+ func TestRewriteRelationsModule(t *testing.T) {
143
+ root := testEnv(t)
144
+ id := DirID(filepath.Join(root, "proj-rel-rw"))
145
+ writeFeatureDoc(t, id, "内容创作", "写文案", "生成文案")
146
+ if err := appendRelations(id, []Relation{
147
+ {Session: "s-1", Module: "内容创作", Feature: "写文案", N: 1, Ts: 1},
148
+ {Session: "s-1", Module: "团队协作", Feature: "分工", N: 2, Ts: 2},
149
+ }); err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if err := rewriteRelationsModule(id, "内容创作", "创作"); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ got := map[string]string{}
156
+ for _, r := range readRelations(id) {
157
+ got[r.Feature] = r.Module
158
+ }
159
+ if got["写文案"] != "创作" || got["分工"] != "团队协作" {
160
+ t.Fatalf("改写不正确: %v", got)
161
+ }
162
+ }
163
+
164
+ // TestBuildGraph 多会话 × 多功能点多对多 → 节点/边正确;文档不存在的关系丢弃。
165
+ func TestBuildGraph(t *testing.T) {
166
+ root := testEnv(t)
167
+ proj := filepath.Join(root, "proj-graph")
168
+ if err := os.MkdirAll(proj, 0o700); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ id := DirID(proj)
172
+ writeFeatureDoc(t, id, "任务列表", "加待办", "新增待办")
173
+ writeFeatureDoc(t, id, "任务列表", "删待办", "删除待办")
174
+ if err := appendRelations(id, []Relation{
175
+ {Session: "s-a", STitle: "会话A", Module: "任务列表", Feature: "加待办", N: 1, Ts: 1},
176
+ {Session: "s-a", STitle: "会话A", Module: "任务列表", Feature: "删待办", N: 2, Ts: 2},
177
+ {Session: "s-b", Module: "任务列表", Feature: "加待办", N: 3, Ts: 3},
178
+ // 悬空关系:文档已不在 → 节点与边都应丢弃
179
+ {Session: "s-a", STitle: "会话A", Module: "旧模块", Feature: "旧功能", N: 4, Ts: 4},
180
+ }); err != nil {
181
+ t.Fatal(err)
182
+ }
183
+
184
+ b, err := BuildGraph(proj)
185
+ if err != nil {
186
+ t.Fatal(err)
187
+ }
188
+ var out struct {
189
+ OK bool `json:"ok"`
190
+ Workspaces []struct {
191
+ ID string `json:"id"`
192
+ Nodes []graphNode `json:"nodes"`
193
+ Edges []graphEdge `json:"edges"`
194
+ } `json:"workspaces"`
195
+ }
196
+ if err := json.Unmarshal(b, &out); err != nil {
197
+ t.Fatal(err)
198
+ }
199
+ if !out.OK || len(out.Workspaces) != 1 {
200
+ t.Fatalf("应输出 1 个工作区: %+v", out)
201
+ }
202
+ g := out.Workspaces[0]
203
+ if g.ID != id {
204
+ t.Errorf("工作区 id 应是 %s,实际 %s", id, g.ID)
205
+ }
206
+ var feats, sess int
207
+ for _, n := range g.Nodes {
208
+ if n.Type == "feature" {
209
+ feats++
210
+ } else {
211
+ sess++
212
+ if n.ID == "s:s-a" && n.Title != "会话A" {
213
+ t.Errorf("会话标题应取快照,实际 %q", n.Title)
214
+ }
215
+ }
216
+ }
217
+ if feats != 2 || sess != 2 {
218
+ t.Errorf("应 2 个功能点 + 2 个会话节点,实际 feature=%d session=%d: %+v", feats, sess, g.Nodes)
219
+ }
220
+ if len(g.Edges) != 3 {
221
+ t.Fatalf("3 条有效边(悬空关系丢弃),实际 %+v", g.Edges)
222
+ }
223
+ // 多对多:s-a 关联两个功能点、加待办有两个会话
224
+ targets := map[string]int{}
225
+ for _, e := range g.Edges {
226
+ targets[e.Source]++
227
+ }
228
+ if targets["s:s-a"] != 2 {
229
+ t.Errorf("s-a 应有 2 条边,实际 %v", targets)
230
+ }
231
+ }
@@ -86,6 +86,12 @@ type Feature struct {
86
86
  HasFinal bool `json:"has_final"`
87
87
  // FinalPreview 最终版的第一句实质内容(卡片头条)。
88
88
  FinalPreview string `json:"final_preview,omitempty"`
89
+ // PageURL 该功能点对应的页面地址(归纳时从对话里提取;可空)。
90
+ PageURL string `json:"page_url,omitempty"`
91
+ // Shots 页面完成情况截图数。
92
+ Shots int `json:"shots"`
93
+ // ShotAt 最后一张截图的时刻(unix 毫秒,0 = 还没截)。
94
+ ShotAt int64 `json:"shot_at,omitempty"`
89
95
  }
90
96
 
91
97
  // Module 一个模块(两级结构的一级)及其下的功能点。
@@ -171,6 +177,10 @@ type Result struct {
171
177
  Replayed int `json:"replayed,omitempty"`
172
178
  // Calls 本轮实际调了几次 LLM(分片)。
173
179
  Calls int `json:"calls"`
180
+ // Merged 本轮合并掉的相似模块个数(模块去重,见 modules.go)。
181
+ Merged int `json:"merged,omitempty"`
182
+ // MergedModules 合并清单(「被合并模块 → 并入模块」,内容不丢、只并目录)。
183
+ MergedModules []string `json:"merged_modules,omitempty"`
174
184
  // Pending 归纳完还剩多少条未归纳。
175
185
  Pending int `json:"pending"`
176
186
  // Error 失败原因(OK=false 时有值)。
@@ -0,0 +1,418 @@
1
+ package requirements
2
+
3
+ // shots.go - 「页面完成情况」截图的落盘与文档追加。
4
+ //
5
+ // 需求(2026-10-25 用户):「需求文档整理时 如果设计页面改造 调用内置浏览器mcp
6
+ // 对完成情况截图标注并放在文档中」。于是每个功能点的文档多出第七段
7
+ // 「页面完成情况」:页面地址 + 若干张带说明的截图。
8
+ //
9
+ // ── 为什么截图落盘在 core 而不在客户端 ──
10
+ // 文档是 core 的资产(~/.magic-agent/requirements/<wsid>/features/…),客户端只
11
+ // 负责发起与回显。截图写进文档必须与归纳写文档走同一套锁与原子写,否则两边
12
+ // 同时写会把对方的段落冲掉。
13
+ //
14
+ // ── 为什么追加截图用**文本级插入**而不是全量重渲染 ──
15
+ // ParseDoc → RenderDoc 的往返会丢东西:未知 ## 段、段内空行、Final 里的缩进、
16
+ // 用户手改过的措辞。归纳每轮都会重写文档(这是既有的既定行为),但**追加截图**
17
+ // 是「往用户手边的文档里加两行」的动作 —— 它不该顺手抹掉用户两次归纳之间
18
+ // 手写的内容。所以这里只做「定位段落 → 追加两行 → 其余字节原样写回」。
19
+ // 代价:文档里若有奇怪的段落顺序,插入点可能不理想 —— 那比丢内容好。
20
+ //
21
+ // ── 存储形状 ──
22
+ // <root>/<wsid>/shots/<模块>/<功能点slug>/<unix毫秒>.png
23
+ // 模块名为空(未归类)时用 unfiled;功能点用 SlugifyFeature 归一。
24
+ // 路径在文档里**一律正斜杠**(Windows 上反斜杠会让 markdown 的 `\` 变成转义)。
25
+
26
+ import (
27
+ "bytes"
28
+ "encoding/json"
29
+ "fmt"
30
+ "os"
31
+ "path/filepath"
32
+ "strconv"
33
+ "strings"
34
+ "time"
35
+ )
36
+
37
+ const (
38
+ // MaxShotBytes 单张截图的大小上限(PNG)。
39
+ //
40
+ // 8MB 对应约 2000×2000 的 PNG —— 手机全屏截图(含标注)远小于此。
41
+ MaxShotBytes = 8 << 20
42
+ // MaxShots 一个功能点最多保留的截图数(超出的丢最旧的)。
43
+ MaxShots = 12
44
+ // shotsDirName 截图目录名(相对 <wsid>)。
45
+ shotsDirName = "shots"
46
+ // unfiledModule 未归类模块的目录名(模块名为空时用它)。
47
+ unfiledModule = "unfiled"
48
+ )
49
+
50
+ // pngMagic PNG 文件头(8 字节签名)。
51
+ var pngMagic = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
52
+
53
+ // ShotResult 追加一张截图的结果(CLI 输出 / HTTP 响应共用)。
54
+ type ShotResult struct {
55
+ OK bool `json:"ok"`
56
+ // Workspace / Dir 归属的工作区。
57
+ Workspace string `json:"workspace,omitempty"`
58
+ Dir string `json:"dir"`
59
+ // Module / Name 落在哪个功能点上。
60
+ Module string `json:"module,omitempty"`
61
+ Name string `json:"name,omitempty"`
62
+ // File 文档路径(绝对路径)。
63
+ File string `json:"file,omitempty"`
64
+ // Path 截图落盘后的绝对路径(正斜杠)。
65
+ Path string `json:"path,omitempty"`
66
+ // Shots 这个功能点现有截图数。
67
+ Shots int `json:"shots"`
68
+ // Error 失败原因(OK=false 时有值)。
69
+ Error string `json:"error,omitempty"`
70
+ }
71
+
72
+ // AttachShot 给一个功能点追加一张「页面完成情况」截图。
73
+ //
74
+ // where 可以是工作区 id(ws-xxxx)或目录路径;module 为空表示不限模块(按名字找)。
75
+ // name 是功能点名(= 文档名)。caption 是标注说明(可空)。
76
+ //
77
+ // 找不到对应文档时**报错**而不是新建 —— 截图是给已有需求点补证据,
78
+ // 凭空造一篇「同名空文档」只会污染清单。
79
+ func AttachShot(where, module, name, caption string, png []byte) (ShotResult, error) {
80
+ res := ShotResult{}
81
+ dir := strings.TrimSpace(where)
82
+ if dir == "" {
83
+ if cwd, err := os.Getwd(); err == nil {
84
+ dir = cwd
85
+ }
86
+ }
87
+ ws, ok := resolveWorkspace(dir)
88
+ if !ok {
89
+ res.Error = "工作区不存在: " + dir
90
+ return res, fmt.Errorf("requirements: workspace not found: %s", dir)
91
+ }
92
+ if Root() == "" {
93
+ res.Error = "无法确定需求存储目录"
94
+ return res, fmt.Errorf("requirements: no storage root")
95
+ }
96
+ res.Workspace, res.Dir = ws.ID, ws.Dir
97
+
98
+ name = strings.TrimSpace(truncRunes(strings.TrimSpace(name), TitleMaxChars))
99
+ if name == "" {
100
+ res.Error = "功能点名不能为空"
101
+ return res, fmt.Errorf("requirements: empty feature name")
102
+ }
103
+ if len(png) == 0 {
104
+ res.Error = "截图内容为空"
105
+ return res, fmt.Errorf("requirements: empty png")
106
+ }
107
+ if len(png) > MaxShotBytes {
108
+ res.Error = fmt.Sprintf("截图过大(%d 字节,上限 %d)", len(png), MaxShotBytes)
109
+ return res, fmt.Errorf("requirements: png too large")
110
+ }
111
+ if !bytes.HasPrefix(png, pngMagic) {
112
+ res.Error = "不是 PNG 图片"
113
+ return res, fmt.Errorf("requirements: not a png")
114
+ }
115
+
116
+ // 与归纳共用一把锁:两个进程同时写同一篇文档会把对方的段落冲掉。
117
+ release, busy := acquireLock(ws.ID)
118
+ if busy {
119
+ res.Error = "另一个进程正在整理这个空间,稍后再试"
120
+ return res, errBusy
121
+ }
122
+ defer release()
123
+
124
+ // 定位文档:经既有文件列表匹配,**不把用户输入拼进路径**(防目录穿越)。
125
+ merger := newDocMerger(ws.ID)
126
+ hit, found := merger.match(name)
127
+ if !found {
128
+ res.Error = "找不到这个功能点的文档:" + name
129
+ return res, fmt.Errorf("requirements: feature not found: %s", name)
130
+ }
131
+ mod := SlugifyModule(hit.Module)
132
+ if m := SlugifyModule(module); m != "" && m != mod {
133
+ res.Error = fmt.Sprintf("功能点「%s」属于模块「%s」,不是「%s」", name, mod, module)
134
+ return res, fmt.Errorf("requirements: module mismatch")
135
+ }
136
+ res.Module, res.Name, res.File = hit.Module, hit.Name, hit.File
137
+
138
+ // 落盘:<wsid>/shots/<模块>/<功能点slug>/<毫秒>.png
139
+ dirName := mod
140
+ if dirName == "" {
141
+ dirName = unfiledModule
142
+ }
143
+ shotDir := filepath.Join(wsDir(ws.ID), shotsDirName, dirName, SlugifyFeature(hit.Name))
144
+ if err := os.MkdirAll(shotDir, 0o700); err != nil {
145
+ res.Error = "创建截图目录失败:" + err.Error()
146
+ return res, err
147
+ }
148
+ now := time.Now()
149
+ // 文件名带毫秒 + 序号(同一毫秒内多张不至于撞名)。
150
+ base := strconv.FormatInt(now.UnixMilli(), 10)
151
+ full := filepath.Join(shotDir, base+".png")
152
+ for k := 2; fileExists(full); k++ {
153
+ full = filepath.Join(shotDir, base+"-"+strconv.Itoa(k)+".png")
154
+ }
155
+ if err := os.WriteFile(full, png, 0o600); err != nil {
156
+ res.Error = "写入截图失败:" + err.Error()
157
+ return res, err
158
+ }
159
+ // 文档里存**正斜杠**路径:Windows 的反斜杠在 markdown 里是转义符。
160
+ rel := filepath.ToSlash(full)
161
+ cap := strings.TrimSpace(caption)
162
+
163
+ // 追加到文档:文本级插入,其余字节不动。
164
+ raw, err := os.ReadFile(hit.File)
165
+ if err != nil {
166
+ res.Error = "读取文档失败:" + err.Error()
167
+ return res, err
168
+ }
169
+ out, count, err := insertShot(string(raw), rel, cap, now.UnixMilli())
170
+ if err != nil {
171
+ res.Error = err.Error()
172
+ return res, err
173
+ }
174
+ if err := writeFileAtomic(hit.File, []byte(out)); err != nil {
175
+ res.Error = "写文档失败:" + err.Error()
176
+ return res, err
177
+ }
178
+ res.Path = rel
179
+ res.Shots = count
180
+ res.OK = true
181
+ return res, nil
182
+ }
183
+
184
+ // fileExists 判断文件是否存在。
185
+ func fileExists(p string) bool {
186
+ _, err := os.Stat(p)
187
+ return err == nil
188
+ }
189
+
190
+ // insertShot 把一行截图插进「页面完成情况」段。
191
+ //
192
+ // 段不存在时新建:插在「## 来源对话」之前(缺来源段就插在「## 更新记录」之前,
193
+ // 再缺就追加到文件末尾)。已有段则追加到该段末尾。
194
+ //
195
+ // 返回:新文档全文、该功能点现有截图数。
196
+ func insertShot(text, path, caption string, at int64) (string, int, error) {
197
+ if strings.TrimSpace(path) == "" {
198
+ return "", 0, fmt.Errorf("requirements: empty shot path")
199
+ }
200
+ // 与归纳重渲染写出的形状一致(列表项 `- ![…]`),免得同一篇文档里两种写法混着。
201
+ line := "- " + shotLine(caption, path, at)
202
+ if line == "" {
203
+ return "", 0, fmt.Errorf("requirements: bad shot line")
204
+ }
205
+ lines := strings.Split(text, "\n")
206
+ // 定位「## 页面完成情况」段。
207
+ start := -1
208
+ for i, l := range lines {
209
+ if strings.TrimSpace(l) == "## 页面完成情况" {
210
+ start = i
211
+ break
212
+ }
213
+ }
214
+ if start < 0 {
215
+ // 新建段:插在来源对话之前(没有就插在更新记录之前,再没有就追加到末尾)。
216
+ pos := len(lines)
217
+ for i, l := range lines {
218
+ t := strings.TrimSpace(l)
219
+ if t == "## 来源对话" || t == "## 更新记录" {
220
+ pos = i
221
+ break
222
+ }
223
+ }
224
+ block := []string{"## 页面完成情况", line, ""}
225
+ out := make([]string, 0, len(lines)+len(block))
226
+ out = append(out, lines[:pos]...)
227
+ out = append(out, block...)
228
+ out = append(out, lines[pos:]...)
229
+ return strings.Join(out, "\n"), countShots(out), nil
230
+ }
231
+ // 已有段:找段尾(下一个 ## 或文件末)。
232
+ end := len(lines)
233
+ for i := start + 1; i < len(lines); i++ {
234
+ if strings.HasPrefix(strings.TrimSpace(lines[i]), "## ") {
235
+ end = i
236
+ break
237
+ }
238
+ }
239
+ // 段内占位符「(暂无)」在有图后就不该留着(它会显示成一条假内容)。
240
+ seg := lines[start:end]
241
+ cleaned := make([]string, 0, len(seg))
242
+ for _, l := range seg {
243
+ if strings.TrimSpace(l) == "(暂无)" {
244
+ continue
245
+ }
246
+ cleaned = append(cleaned, l)
247
+ }
248
+ cleaned = append(cleaned, line)
249
+ cleaned = trimShotLines(cleaned)
250
+ cleaned = dedupeShotLines(cleaned)
251
+ // 段与下一段之间要留一个空行,否则 markdown 会把下一段的标题吃掉。
252
+ tail := lines[end:]
253
+ if len(cleaned) > 1 && strings.TrimSpace(cleaned[len(cleaned)-1]) != "" && len(tail) > 0 {
254
+ cleaned = append(cleaned, "")
255
+ }
256
+ out := make([]string, 0, len(lines)+2)
257
+ out = append(out, lines[:start]...)
258
+ out = append(out, cleaned...)
259
+ out = append(out, tail...)
260
+ return strings.Join(out, "\n"), countShots(out), nil
261
+ }
262
+
263
+ // trimShotLines 把一段里的截图行裁到上限(超出的丢**最旧的**)。
264
+ //
265
+ // 丢最旧而不是最新:最新的截图是「最近一次改造的样子」,最该留。
266
+ func trimShotLines(seg []string) []string {
267
+ idx := []int{}
268
+ for i, l := range seg {
269
+ if shotPathRe.MatchString(strings.TrimSpace(l)) {
270
+ idx = append(idx, i)
271
+ }
272
+ }
273
+ if len(idx) <= MaxShots {
274
+ return seg
275
+ }
276
+ drop := map[int]bool{}
277
+ for _, i := range idx[:len(idx)-MaxShots] {
278
+ drop[i] = true
279
+ }
280
+ out := make([]string, 0, len(seg))
281
+ for i, l := range seg {
282
+ if drop[i] {
283
+ continue
284
+ }
285
+ out = append(out, l)
286
+ }
287
+ return out
288
+ }
289
+
290
+ // dedupeShotLines 去掉段内**路径相同**的重复截图行。
291
+ //
292
+ // 同一张图被追加两次(客户端重试、或用户手动重放了一次归纳)时,
293
+ // 「页面完成情况」不该出现两行一样的图。
294
+ //
295
+ // 语义与 TrimShots 一致:**留最后一条的内容,位置不动** —— 图的先后顺序
296
+ // 是用户「改的次序」,不该被去重动作重排。
297
+ func dedupeShotLines(seg []string) []string {
298
+ pos := map[string]int{}
299
+ content := map[string]string{}
300
+ for i, l := range seg {
301
+ t := strings.TrimSpace(l)
302
+ if !shotPathRe.MatchString(t) {
303
+ continue
304
+ }
305
+ p := shotPathOf(t)
306
+ if p == "" {
307
+ continue
308
+ }
309
+ if _, ok := pos[p]; !ok {
310
+ pos[p] = i
311
+ }
312
+ content[p] = l
313
+ }
314
+ out := make([]string, 0, len(seg))
315
+ for i, l := range seg {
316
+ t := strings.TrimSpace(l)
317
+ if shotPathRe.MatchString(t) {
318
+ if p := shotPathOf(t); p != "" {
319
+ if pos[p] != i {
320
+ continue
321
+ }
322
+ out = append(out, content[p])
323
+ continue
324
+ }
325
+ }
326
+ out = append(out, l)
327
+ }
328
+ return out
329
+ }
330
+
331
+ // shotPathOf 从一行截图里取出路径(去掉 `<>` 包与时间戳注释)。
332
+ func shotPathOf(line string) string {
333
+ m := shotPathRe.FindStringSubmatch(strings.TrimSpace(line))
334
+ if m == nil {
335
+ return ""
336
+ }
337
+ return trimAngle(strings.TrimSpace(m[2]))
338
+ }
339
+
340
+ // countShots 数一篇文档里现有多少张截图。
341
+ func countShots(lines []string) int {
342
+ n := 0
343
+ in := false
344
+ for _, l := range lines {
345
+ t := strings.TrimSpace(l)
346
+ if strings.HasPrefix(t, "## ") {
347
+ in = t == "## 页面完成情况"
348
+ continue
349
+ }
350
+ if in && shotPathRe.MatchString(t) {
351
+ n++
352
+ }
353
+ }
354
+ return n
355
+ }
356
+
357
+ // TrimShots 截断一个功能点的截图到上限(超出的丢最旧的),并**按 path 去重**。
358
+ //
359
+ // 只在归纳重写文档时用:RenderDoc 之前先把 Doc.Shots 裁到 MaxShots。
360
+ //
361
+ // 去重按 path:同一张图被写进文档两遍(比如手动重放了一次归纳)时不该在
362
+ // 「页面完成情况」里出现两次。**留最后一条的内容,但位置不动** —— 重排会把
363
+ // 文档里图的先后顺序打乱(用户是按「改的次序」攒图的,顺序本身有意义)。
364
+ func TrimShots(shots []Shot) []Shot {
365
+ if len(shots) == 0 {
366
+ return shots
367
+ }
368
+ // 记下每个 path 最后一次出现的内容与**首次**出现的位置。
369
+ pos := map[string]int{}
370
+ content := map[string]Shot{}
371
+ for i, s := range shots {
372
+ p := strings.TrimSpace(s.Path)
373
+ if p == "" {
374
+ continue
375
+ }
376
+ if _, ok := pos[p]; !ok {
377
+ pos[p] = i
378
+ }
379
+ content[p] = s
380
+ }
381
+ out := make([]Shot, 0, len(shots))
382
+ for i, s := range shots {
383
+ p := strings.TrimSpace(s.Path)
384
+ if p == "" {
385
+ continue
386
+ }
387
+ if pos[p] != i {
388
+ continue
389
+ }
390
+ out = append(out, content[p])
391
+ }
392
+ if len(out) <= MaxShots {
393
+ return out
394
+ }
395
+ return out[len(out)-MaxShots:]
396
+ }
397
+
398
+ // writeFileAtomic 原子写文件(tmp + rename):并发读不会读到半个文件。
399
+ func writeFileAtomic(path string, data []byte) error {
400
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
401
+ return err
402
+ }
403
+ tmp := path + ".tmp"
404
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
405
+ return err
406
+ }
407
+ if err := os.Rename(tmp, path); err != nil {
408
+ _ = os.Remove(tmp)
409
+ return err
410
+ }
411
+ return nil
412
+ }
413
+
414
+ // shotJSON 便于测试与调试的紧凑表示。
415
+ func shotJSON(s Shot) string {
416
+ b, _ := json.Marshal(s)
417
+ return string(b)
418
+ }