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,82 @@
1
+ package client
2
+
3
+ /* publicurl.go - 公网入口(cloudflared quick tunnel 域名)的**落盘**。
4
+ *
5
+ * 为什么(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名就可以了」):
6
+ * 隧道是壳(掌天瓶)起的,quick tunnel 的域名只在**隧道重起**时才会变;magic-client
7
+ * 重启根本不碰 cloudflared,域名本该原样不变。但域名探测结果以前只存在内存里 ——
8
+ * 客户端一重启就丢,`/desk/health` 头几次请求报 `publicUrl: ""`,手机于是退回局域网
9
+ * 地址:用户看到的就是「重启后地址变了」。
10
+ *
11
+ * 所以把探测到的域名落盘(profile 数据目录的 `public-url`),启动即读回当「上次已知」:
12
+ * · 隧道没重起 → 域名正确,手机一条连接都不用改;
13
+ * · 隧道真重起了 → 后台探测在 publicTTL 内换成新域名;
14
+ * · cloudflared 比客户端起得慢 → 宽限期(publicGrace)内不采纳「探不到」,
15
+ * 已知域名先顶着,别在这一小段里把用户推到局域网地址上。
16
+ */
17
+
18
+ import (
19
+ "os"
20
+ "path/filepath"
21
+ "strings"
22
+ "time"
23
+ )
24
+
25
+ // publicGrace 重启后的宽限期:这段时间里探测不到隧道也**不撤掉**已落盘的域名。
26
+ //
27
+ // 顺带解决的是「壳重启」这个场景:cloudflared 要几百毫秒到几秒才把 metrics 端口开起来,
28
+ // 客户端如果抢在前面探空并清掉已知域名,手机就会在这段空窗里换地址。
29
+ const publicGrace = 90 * time.Second
30
+
31
+ // publicURLFile 落盘文件(与 instance-id 同目录:同一份 profile = 同一台桌面)。
32
+ func publicURLFile(prof Profile) string {
33
+ dir := strings.TrimSpace(prof.DataDir())
34
+ if dir == "" {
35
+ return ""
36
+ }
37
+ return filepath.Join(dir, "public-url")
38
+ }
39
+
40
+ // loadSavedPublicURL 读上次落盘的域名;没有/读不出 → 空串。
41
+ func (s *Service) loadSavedPublicURL() string {
42
+ p := publicURLFile(s.opts.Profile)
43
+ if p == "" {
44
+ return ""
45
+ }
46
+ b, err := os.ReadFile(p)
47
+ if err != nil {
48
+ return ""
49
+ }
50
+ u := strings.TrimSpace(string(b))
51
+ if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
52
+ return "" // 半截写入 / 手改坏了:当作没有,别把垃圾地址发给手机
53
+ }
54
+ return strings.TrimRight(u, "/")
55
+ }
56
+
57
+ // savePublicURL 落盘当前域名(0600)。写不下不是致命错:本次仍能用,只是下次重启会重新探。
58
+ func (s *Service) savePublicURL(u string) {
59
+ p := publicURLFile(s.opts.Profile)
60
+ if p == "" || strings.TrimSpace(u) == "" {
61
+ return
62
+ }
63
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
64
+ return
65
+ }
66
+ _ = os.WriteFile(p, []byte(strings.TrimSpace(u)+"\n"), 0o600)
67
+ }
68
+
69
+ // clearSavedPublicURL 删掉落盘的域名(连续探测确认隧道真的没了)。
70
+ func (s *Service) clearSavedPublicURL() {
71
+ p := publicURLFile(s.opts.Profile)
72
+ if p == "" {
73
+ return
74
+ }
75
+ _ = os.Remove(p)
76
+ }
77
+
78
+ // publicURLOrEmpty 供交接用:拿当前已知的公网域名(不触发等待)。
79
+ func (s *Service) publicURLOrEmpty() string {
80
+ u := s.publicURL()
81
+ return strings.TrimRight(strings.TrimSpace(u), "/")
82
+ }
@@ -0,0 +1,107 @@
1
+ package client
2
+
3
+ // quotas_merge_test.go - --quotas 现查结果合并进缓存的引擎清单(额度不跟着缓存冻住)。
4
+
5
+ import (
6
+ "encoding/json"
7
+ "testing"
8
+ )
9
+
10
+ func TestMergeQuotaJSONOverridesStaleValues(t *testing.T) {
11
+ engines := []byte(`[
12
+ {"engine":"llm","ok":true,"credit_suffix":"%",
13
+ "model_quota":{"MiniMax-M3.1-Flash":{"interval_left":"98","weekly_left":"53"},
14
+ "minimax-peter":{"interval_left":"98","weekly_left":"53"}}},
15
+ {"engine":"codebuddy","ok":true,"credit_left":"5800.1","checkin_today":false,"checkin_credit":"100"},
16
+ {"engine":"trae","ok":true}
17
+ ]`)
18
+ quotas := []byte(`[
19
+ {"engine":"llm","credit_suffix":"%",
20
+ "model_quota":{"MiniMax-M3.1-Flash":{"interval_left":"98","weekly_left":"53"},
21
+ "minimax-peter":{"interval_left":"1","weekly_left":"0"}}},
22
+ {"engine":"codebuddy","credit_left":"5910.77","checkin_today":true,"checkin_credit":"100"}
23
+ ]`)
24
+
25
+ out := mergeQuotaJSON(engines, quotas)
26
+ var rows []map[string]any
27
+ if err := json.Unmarshal(out, &rows); err != nil {
28
+ t.Fatalf("合并结果不是合法 JSON: %v\n%s", err, out)
29
+ }
30
+ if len(rows) != 3 {
31
+ t.Fatalf("引擎行数变了: %d", len(rows))
32
+ }
33
+
34
+ byEngine := map[string]map[string]any{}
35
+ for _, r := range rows {
36
+ byEngine[r["engine"].(string)] = r
37
+ }
38
+ if got := byEngine["codebuddy"]["credit_left"]; got != "5910.77" {
39
+ t.Errorf("credit_left 未刷新: %v", got)
40
+ }
41
+ if got := byEngine["codebuddy"]["checkin_today"]; got != true {
42
+ t.Errorf("checkin_today 未刷新: %v", got)
43
+ }
44
+ quota := byEngine["llm"]["model_quota"].(map[string]any)
45
+ peter := quota["minimax-peter"].(map[string]any)
46
+ if peter["interval_left"] != "1" || peter["weekly_left"] != "0" {
47
+ t.Errorf("minimax-peter 额度未按新 key 刷新: %v", peter)
48
+ }
49
+ if _, ok := byEngine["trae"]["credit_left"]; ok {
50
+ t.Errorf("没有额度的引擎不该被塞字段: %v", byEngine["trae"])
51
+ }
52
+ }
53
+
54
+ func TestMergeQuotaJSONKeepsOldOnEmpty(t *testing.T) {
55
+ engines := []byte(`[{"engine":"codebuddy","ok":true,"credit_left":"5800.1"}]`)
56
+ // 现查失败(字段缺失 / 空串)时保留缓存里的旧值,不能把数字抹掉。
57
+ quotas := []byte(`[{"engine":"codebuddy","credit_left":""}]`)
58
+
59
+ out := mergeQuotaJSON(engines, quotas)
60
+ var rows []map[string]any
61
+ if err := json.Unmarshal(out, &rows); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ if got := rows[0]["credit_left"]; got != "5800.1" {
65
+ t.Fatalf("空结果覆盖了旧值: %v", got)
66
+ }
67
+ }
68
+
69
+ func TestMergeQuotaJSONBadInputFallsBack(t *testing.T) {
70
+ engines := []byte(`[{"engine":"llm","ok":true}]`)
71
+ if out := mergeQuotaJSON(engines, []byte("not json")); string(out) != string(engines) {
72
+ t.Fatalf("额度输出非法时应原样返回,实际 %s", out)
73
+ }
74
+ }
75
+
76
+ // 登录态也要现查:缓存里是「已登录」,但用户刚登出时下一次打开就该隐藏。
77
+ func TestMergeQuotaJSONLoggedIn(t *testing.T) {
78
+ engines := []byte(`[
79
+ {"engine":"codebuddy","ok":true,"logged_in":true,"account":"旧昵称"},
80
+ {"engine":"codebuddy-ai","ok":true,"logged_in":true}
81
+ ]`)
82
+ quotas := []byte(`[
83
+ {"engine":"codebuddy","logged_in":true,"account":"Darren"},
84
+ {"engine":"codebuddy-ai","logged_in":false}
85
+ ]`)
86
+
87
+ out := mergeQuotaJSON(engines, quotas)
88
+ var rows []map[string]any
89
+ if err := json.Unmarshal(out, &rows); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ got := map[string]any{}
93
+ acct := map[string]any{}
94
+ for _, r := range rows {
95
+ got[r["engine"].(string)] = r["logged_in"]
96
+ acct[r["engine"].(string)] = r["account"]
97
+ }
98
+ if got["codebuddy"] != true {
99
+ t.Errorf("codebuddy logged_in = %v, want true", got["codebuddy"])
100
+ }
101
+ if got["codebuddy-ai"] != false {
102
+ t.Errorf("codebuddy-ai 登出后应合并为 false,实际 %v", got["codebuddy-ai"])
103
+ }
104
+ if acct["codebuddy"] != "Darren" {
105
+ t.Errorf("账号昵称未刷新: %v", acct["codebuddy"])
106
+ }
107
+ }
@@ -28,14 +28,17 @@ import (
28
28
  "fmt"
29
29
  "io"
30
30
  "net/http"
31
+ "net/url"
31
32
  "os"
32
33
  "os/exec"
33
34
  "path/filepath"
35
+ "strconv"
34
36
  "strings"
35
37
  "sync"
36
38
  "time"
37
39
 
38
40
  "github.com/darren/magic-agent/internal/agent"
41
+ "github.com/darren/magic-agent/internal/requirements"
39
42
  )
40
43
 
41
44
  // FeatureRequirements core 侧的能力位(与 internal/agent 的同名常量一致)。
@@ -45,6 +48,12 @@ import (
45
48
  // 「插件依赖哪些能力位」在 grep 时只有一处 —— 与 FeatureSessionTeam 的处理方式一致。
46
49
  const FeatureRequirements = "feature.requirements"
47
50
 
51
+ // FeatureRequirementShots core 侧的能力位(与 internal/agent 的同名常量一致)。
52
+ //
53
+ // 与 FeatureRequirements 分开:core 可能支持需求聚合但还不支持截图 ——
54
+ // 界面要能分别判断,别把「需求」页签整个藏掉。
55
+ const FeatureRequirementShots = "feature.requirement.shots"
56
+
48
57
  // reqAutoDebounce 一轮收尾后等多久再归纳(期间再来一轮就顺延)。
49
58
  const reqAutoDebounce = 12 * time.Second
50
59
 
@@ -54,6 +63,12 @@ const reqAutoDebounce = 12 * time.Second
54
63
  // core 侧单次调用默认 4 分钟。给到 10 分钟覆盖「首次回填一大段历史」这种最慢的情况。
55
64
  const reqConsolidateTimeout = 10 * time.Minute
56
65
 
66
+ // reqShotTimeout 追加截图的上限。
67
+ //
68
+ // 落盘本身是毫秒级的,但要先抢 core 的归纳锁(另一轮归纳在跑时要等)——
69
+ // 给 30 秒足够,又不至于让界面一直转圈。
70
+ const reqShotTimeout = 30 * time.Second
71
+
57
72
  // reqAutoOffEnv 关掉自动归纳的环境变量(排查 / 省额度用)。
58
73
  const reqAutoOffEnv = "MAGIC_CLIENT_NO_REQ_AUTO"
59
74
 
@@ -80,6 +95,25 @@ func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
80
95
  s.proxyCore(w, r, "--requirements", ws)
81
96
  }
82
97
 
98
+ // handleRequirementsGraph GET /desk/requirements/graph?ws=<ws_id|目录>:
99
+ // 会话↔需求关系图谱(nodes=会话/功能点,edges=关系)。纯读转发,同族范式。
100
+ func (s *Service) handleRequirementsGraph(w http.ResponseWriter, r *http.Request) {
101
+ if r.Method != http.MethodGet {
102
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
103
+ return
104
+ }
105
+ if !hasStr(s.contract.Features, FeatureRequirements) {
106
+ writeErr(w, http.StatusNotImplemented,
107
+ "当前 core 不支持需求聚合(缺少 "+FeatureRequirements+" 能力位,请升级 magic-agent)")
108
+ return
109
+ }
110
+ ws := strings.TrimSpace(r.URL.Query().Get("ws"))
111
+ if ws == "" {
112
+ ws = "all"
113
+ }
114
+ s.proxyCore(w, r, "--requirements-graph", ws)
115
+ }
116
+
83
117
  // handleRequirementsConsolidate POST /desk/requirements/consolidate:
84
118
  // 立即归纳一轮(用户点「整理」)。
85
119
  //
@@ -149,6 +183,156 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
149
183
  writeCoreJSON(w, out)
150
184
  }
151
185
 
186
+ // handleRequirementsShots POST /desk/requirements/shots:
187
+ // 给一个功能点追加一张「页面完成情况」截图。
188
+ //
189
+ // 请求体 = **PNG 原始字节**(不是 multipart):这条通道只收图,形状越简单越不容易出错。
190
+ // 参数走查询串:ws(工作区 id 或目录,空 = 当前目录)、module、name(功能点名)、caption。
191
+ //
192
+ // 为什么图片不直接落到需求目录、而要过 core:
193
+ //
194
+ // 需求文档是 core 的资产(~/.magic-agent/requirements/<wsid>/),它有自己的一套
195
+ // 锁(与归纳共用)、路径归一(Windows 反斜杠)与文档段落格式。让 core 来写,
196
+ // 客户端就只负责「拿到字节、传过去」—— 两边不会各写一套格式。
197
+ func (s *Service) handleRequirementsShots(w http.ResponseWriter, r *http.Request) {
198
+ if r.Method != http.MethodPost {
199
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
200
+ return
201
+ }
202
+ /* 能力位 gate:老 core 没有 --requirement-shot(exit 2)。先查能力位并**说清楚为什么** ——
203
+ 让用户看到「core 太旧,请升级」,比让它报一句 usage error 强得多。 */
204
+ if !hasStr(s.contract.Features, FeatureRequirementShots) {
205
+ writeErr(w, http.StatusNotImplemented,
206
+ "当前 core 不支持需求截图(缺少 "+FeatureRequirementShots+" 能力位,请升级 magic-agent)")
207
+ return
208
+ }
209
+ q := r.URL.Query()
210
+ name := strings.TrimSpace(q.Get("name"))
211
+ if name == "" {
212
+ writeErr(w, http.StatusBadRequest, "缺少 name(功能点名)")
213
+ return
214
+ }
215
+ module := strings.TrimSpace(q.Get("module"))
216
+ caption := strings.TrimSpace(q.Get("caption"))
217
+ ws := strings.TrimSpace(q.Get("ws"))
218
+ if ws == "" {
219
+ if wd, err := os.Getwd(); err == nil {
220
+ ws = wd
221
+ }
222
+ }
223
+ if ws == "" {
224
+ writeErr(w, http.StatusBadRequest, "ws 不能为空")
225
+ return
226
+ }
227
+ // 先收进内存再判大小。
228
+ //
229
+ // ⚠️ 不用 http.MaxBytesReader:它一超限就直接写 400("request body too large"),
230
+ // 分不清「图片太大」和「请求体坏了」—— 用户看到的是一句看不懂的错。
231
+ // 这里自己读,超限的单独回 413。
232
+ body, err := io.ReadAll(io.LimitReader(r.Body, requirements.MaxShotBytes+1))
233
+ if err != nil {
234
+ writeErr(w, http.StatusBadRequest, "读取截图失败:"+err.Error())
235
+ return
236
+ }
237
+ if len(body) == 0 {
238
+ writeErr(w, http.StatusBadRequest, "截图内容为空")
239
+ return
240
+ }
241
+ if len(body) > requirements.MaxShotBytes {
242
+ writeErr(w, http.StatusRequestEntityTooLarge,
243
+ fmt.Sprintf("截图过大(上限 %d 字节)", requirements.MaxShotBytes))
244
+ return
245
+ }
246
+ args := []string{"--requirement-shot", ws, "--requirement-name", name}
247
+ if module != "" {
248
+ args = append(args, "--requirement-module", module)
249
+ }
250
+ if caption != "" {
251
+ args = append(args, "--requirement-caption", caption)
252
+ }
253
+ out, err := s.runCoreTimedStdin(r.Context(), reqShotTimeout, bytes.NewReader(body), args...)
254
+ if err != nil {
255
+ code := http.StatusBadGateway
256
+ if isCoreTimeout(err) {
257
+ code = http.StatusGatewayTimeout
258
+ }
259
+ writeErr(w, code, fmt.Sprintf("%s --requirement-shot 失败:%v", s.opts.MagicAgent, err))
260
+ return
261
+ }
262
+ writeCoreJSON(w, out)
263
+ }
264
+
265
+ // handleRequirementShotRemote POST /desk/requirements/shot-remote:
266
+ // 服务端 agent-browser 打开 url 截一张全页图,落到 /desk/uploads/ 并回图片地址。
267
+ //
268
+ // 2026-10-25 内置浏览器 MCP 改基 agent-browser 后,手机端的「截图」不再依赖
269
+ // 页面内 relay 的 html2canvas(跨域污染 / iframe 抓不到 / 慢页超时),改由
270
+ // 服务端真实 Chromium 截图。请求体 JSON:{"url":"…"}(必须 http/https)。
271
+ // 响应:{ok, data:"/desk/uploads/…png"}(data 直接喂给标注浮层的 <img>)。
272
+ //
273
+ // 能力位与 /desk/requirements/shots 同一杆秤:缺 feature.requirement.shots 回 501;
274
+ // 服务端没装 agent-browser 也回 501 + 安装提示。
275
+ func (s *Service) handleRequirementShotRemote(w http.ResponseWriter, r *http.Request) {
276
+ if r.Method != http.MethodPost {
277
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
278
+ return
279
+ }
280
+ if !hasStr(s.contract.Features, FeatureRequirementShots) {
281
+ writeErr(w, http.StatusNotImplemented,
282
+ "当前 core 不支持需求截图(缺少 "+FeatureRequirementShots+" 能力位,请升级 magic-agent)")
283
+ return
284
+ }
285
+ var req struct {
286
+ URL string `json:"url"`
287
+ FullPage bool `json:"full_page,omitempty"`
288
+ }
289
+ body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
290
+ if err != nil {
291
+ writeErr(w, http.StatusBadRequest, "读取请求失败:"+err.Error())
292
+ return
293
+ }
294
+ if err := json.Unmarshal(body, &req); err != nil {
295
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON")
296
+ return
297
+ }
298
+ u := strings.TrimSpace(req.URL)
299
+ if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
300
+ writeErr(w, http.StatusBadRequest, "url 必须是 http/https 地址")
301
+ return
302
+ }
303
+ if abBin() == "" {
304
+ writeErr(w, http.StatusNotImplemented,
305
+ "服务端未安装 agent-browser(npm install -g agent-browser && agent-browser install,或设 "+EnvAgentBrowserBin+")")
306
+ return
307
+ }
308
+ // 截图落 uploads 目录(与 /desk/upload 同一个家,/desk/uploads/<name> 可回读)。
309
+ dir := filepath.Join(os.TempDir(), "magic-client-uploads")
310
+ if err := os.MkdirAll(dir, 0o700); err != nil {
311
+ writeErr(w, http.StatusInternalServerError, "创建上传目录失败:"+err.Error())
312
+ return
313
+ }
314
+ out := filepath.Join(dir, strconv.FormatInt(time.Now().UnixNano(), 10)+"-reqshot.png")
315
+ if err := abOpen(r.Context(), u); err != nil {
316
+ writeErr(w, http.StatusBadGateway, "agent-browser 打开页面失败:"+err.Error())
317
+ return
318
+ }
319
+ if err := abShot(r.Context(), out, req.FullPage); err != nil {
320
+ _ = os.Remove(out)
321
+ writeErr(w, http.StatusBadGateway, "agent-browser 截图失败:"+err.Error())
322
+ return
323
+ }
324
+ st, err := os.Stat(out)
325
+ if err != nil || st.Size() == 0 {
326
+ writeErr(w, http.StatusBadGateway, "截图文件没有生成")
327
+ return
328
+ }
329
+ writeJSON(w, http.StatusOK, map[string]any{
330
+ "ok": true,
331
+ "data": "/desk/uploads/" + url.PathEscape(filepath.Base(out)),
332
+ "size": st.Size(),
333
+ })
334
+ }
335
+
152
336
  // consolidateArgs 组归纳命令。
153
337
  //
154
338
  // engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
@@ -180,9 +364,19 @@ func consolidateArgs(ws, module string, force, all, auto bool, engine, model str
180
364
 
181
365
  // runCoreTimed 与 runCore 同形,但超时可调(归纳不是 45s 能跑完的)。
182
366
  func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args ...string) ([]byte, error) {
367
+ return s.runCoreTimedStdin(ctx, timeout, nil, args...)
368
+ }
369
+
370
+ // runCoreTimedStdin 与 runCoreTimed 同形,但把 stdin 接到子进程。
371
+ //
372
+ // 截图走这条路:PNG 几百 KB,放进 argv 会撞命令行长度上限、也会把二进制搅进进程列表。
373
+ func (s *Service) runCoreTimedStdin(ctx context.Context, timeout time.Duration, stdin io.Reader, args ...string) ([]byte, error) {
183
374
  ctx, cancel := context.WithTimeout(ctx, timeout)
184
375
  defer cancel()
185
- cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
376
+ cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
377
+ if stdin != nil {
378
+ cmd.Stdin = stdin
379
+ }
186
380
  // 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
187
381
  cmd.WaitDelay = 5 * time.Second
188
382
  var stdout, stderr bytes.Buffer
@@ -288,9 +482,17 @@ func (s *Service) runAutoConsolidate(ws string) {
288
482
  s.reqAuto.running[ws] = true
289
483
  s.reqAuto.mu.Unlock()
290
484
 
485
+ /* 引擎/模型:**与手动「整理」同一口径** —— 「整理的引擎就是配置的默认引擎」。
486
+ 后台没有请求体可带,于是从唯一的权威配置源(/desk/prefs 落的那份 JSON)读;
487
+ 读不到(还没配过 / 文件坏了)= 传空,core 仍按自己的默认(llm)跑,与旧行为一致。 */
488
+ engine, model := s.defaultEngineModel()
489
+ if engine != "" {
490
+ s.logf("需求自动归纳 %s: 引擎 %s%s", ws, engine, modelSuffix(model))
491
+ }
492
+
291
493
  // 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
292
494
  ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
293
- out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, "", "")...)
495
+ out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, engine, model)...)
294
496
  cancel()
295
497
  if err != nil {
296
498
  s.log.Printf("需求自动归纳失败 %s: %v", ws, err)
@@ -327,3 +529,6 @@ func (s *Service) runAutoConsolidate(ws string) {
327
529
 
328
530
  // 编译期断言:能力位常量与 core 侧同值(改错一个字这里就编译不过)。
329
531
  var _ = func() bool { return agent.FeatureRequirements == FeatureRequirements }
532
+
533
+ // 同上,截图能力位。
534
+ var _ = func() bool { return agent.FeatureRequirementShots == FeatureRequirementShots }
@@ -0,0 +1,200 @@
1
+ package client
2
+
3
+ // requirements_shots_test.go - 「页面完成情况」截图端点的三条硬约束:
4
+ // · 老 core 缺能力位 → 501 且点名缺哪个位(不是 400/502 让人猜);
5
+ // · PNG 走 **stdin** 传给 core(不进 argv:几百 KB 会撞命令行长度上限);
6
+ // · 参数(ws / module / name / caption)原样透传。
7
+ //
8
+ // 假 core 用 .bat(Windows 上 exec 不了 shebang 脚本 —— 既有基线,见 jobs_test.go)。
9
+
10
+ import (
11
+ "bytes"
12
+ "net/http"
13
+ "net/http/httptest"
14
+ "os"
15
+ "path/filepath"
16
+ "runtime"
17
+ "strings"
18
+ "testing"
19
+ )
20
+
21
+ // shotCore 假 core:回契约、记下参数、并把从 stdin 收到的字节长度写进日志。
22
+ //
23
+ // ⚠️ Windows 上的两个坑(都只影响**测试桩**,真实 core 是 .exe,直接 exec 时
24
+ // 中文参数与二进制 stdin 都是完好的 —— 实测 arg[2]="需求页" 字节 e9 9c 80…):
25
+ // ① cmd.exe 按**控制台代码页**(936=GBK)传参,桩里要按同一编码解读;
26
+ // ② PNG 头里有 0x1A(EOF 字符),文本模式读 stdin 会**截断**(12 字节只读到 11)——
27
+ // 必须用二进制模式读。
28
+ //
29
+ // 二进制读 stdin 的写法踩过坑:[Console]::OpenStandardInput() 返回的是 Stream,
30
+ // **没有 ReadToEnd**(PowerShell 5.1 会报 method not found,$b 保持 null → 记成 0 字节)。
31
+ // 正确写法是 CopyTo 到 MemoryStream 再看 Length。
32
+ func shotCore(t *testing.T, features, logPath string) string {
33
+ t.Helper()
34
+ dir := t.TempDir()
35
+ if runtime.GOOS == "windows" {
36
+ // 读 stdin 的小脚本单独放一个 .ps1:批处理里的 -Command 引号嵌套太容易走样。
37
+ ps1 := filepath.Join(dir, "readstdin.ps1")
38
+ psBody := "$stdin = [Console]::OpenStandardInput()\n" +
39
+ "$ms = New-Object System.IO.MemoryStream\n" +
40
+ "$stdin.CopyTo($ms)\n" +
41
+ "[Console]::Out.Write('STDIN_BYTES=' + $ms.Length + \"`n\")\n"
42
+ if err := os.WriteFile(ps1, []byte(psBody), 0o755); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ path := filepath.Join(dir, "magic-agent.bat")
46
+ lines := []string{
47
+ `@echo off`,
48
+ `set "A=%*"`,
49
+ `echo %A%>>"` + logPath + `"`,
50
+ `if "%A:--contract=%" == "%A%" goto other`,
51
+ `echo {"contractVersion":2,"features":[` + features + `],"engines":[]}`,
52
+ `exit /b 0`,
53
+ `:other`,
54
+ // 二进制读 stdin,记下字节数(追加进同一个日志)。
55
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "` + ps1 + `">>"` + logPath + `"`,
56
+ `echo {"ok":true,"workspace":"ws-1","dir":"/x","file":"/x/f.md","path":"/x/1.png","shots":1}`,
57
+ `exit /b 0`,
58
+ }
59
+ // 批处理按代码页存(cmd.exe 按它解释脚本里的字节)。
60
+ body := strings.Join(lines, "\r\n") + "\r\n"
61
+ if err := os.WriteFile(path, encodeWindowsScript(body), 0o755); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ return path
65
+ }
66
+ path := filepath.Join(dir, "magic-agent")
67
+ script := `#!/bin/sh
68
+ echo "$*" >> ` + logPath + `
69
+ case "$*" in
70
+ *--contract*)
71
+ echo '{"contractVersion":2,"features":[` + features + `],"engines":[]}'
72
+ exit 0
73
+ ;;
74
+ esac
75
+ # stdin 字节数(证明 PNG 走了 stdin)
76
+ n=$(wc -c)
77
+ echo "STDIN_BYTES=$n" >> ` + logPath + `
78
+ echo '{"ok":true,"workspace":"ws-1","dir":"/x","file":"/x/f.md","path":"/x/1.png","shots":1}'
79
+ exit 0
80
+ `
81
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ return path
85
+ }
86
+
87
+ // shotsService 造一个带截图能力位的测试服务。
88
+ func shotsService(t *testing.T, logPath string) *Service {
89
+ t.Helper()
90
+ core := shotCore(t, `"feature.session.events","feature.session.control",`+
91
+ `"`+FeatureRequirements+`","`+FeatureRequirementShots+`"`, logPath)
92
+ return newTestService(t, core)
93
+ }
94
+
95
+ func TestRequirementShotsRouteNeedsFeature(t *testing.T) {
96
+ logPath := filepath.Join(t.TempDir(), "calls.log")
97
+ core := shotCore(t, `"feature.session.events","feature.session.control"`, logPath)
98
+ svc := newTestService(t, core)
99
+
100
+ req := httptest.NewRequest(http.MethodPost, "/desk/requirements/shots?token=tok&name=需求页", bytes.NewReader([]byte("x")))
101
+ rec := httptest.NewRecorder()
102
+ svc.Handler().ServeHTTP(rec, req)
103
+ if rec.Code != http.StatusNotImplemented {
104
+ t.Fatalf("缺能力位应回 501,实际 %d: %s", rec.Code, rec.Body.String())
105
+ }
106
+ if !strings.Contains(rec.Body.String(), FeatureRequirementShots) {
107
+ t.Errorf("报错应点名缺哪个能力位,实际 %s", rec.Body.String())
108
+ }
109
+ for _, c := range readCalls(t, logPath) {
110
+ if strings.Contains(c, "--requirement-shot") {
111
+ t.Errorf("缺能力位时不该去调 core:%s", c)
112
+ }
113
+ }
114
+ }
115
+
116
+ func TestRequirementShotsRouteForwardsArgsAndStdin(t *testing.T) {
117
+ logPath := filepath.Join(t.TempDir(), "calls.log")
118
+ svc := shotsService(t, logPath)
119
+
120
+ png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03, 0x04}
121
+ url := "/desk/requirements/shots?token=tok&ws=ws-1&module=%E4%BB%BB%E5%8A%A1%E5%88%97%E8%A1%A8&name=%E9%9C%80%E6%B1%82%E9%A1%B5&caption=%E6%94%B9%E6%88%90%E5%8F%8C%E5%88%97"
122
+ req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(png))
123
+ rec := httptest.NewRecorder()
124
+ svc.Handler().ServeHTTP(rec, req)
125
+ if rec.Code != http.StatusOK {
126
+ t.Fatalf("截图应 200,实际 %d: %s", rec.Code, rec.Body.String())
127
+ }
128
+ // 响应是 core 的结构化结果。
129
+ if !strings.Contains(rec.Body.String(), `"ok":true`) {
130
+ t.Errorf("应原样回传 core 的结果,实际 %s", rec.Body.String())
131
+ }
132
+ var sawArgs bool
133
+ var sawStdin bool
134
+ for _, c := range readCalls(t, logPath) {
135
+ // ⚠️ Windows 上 cmd.exe 按控制台代码页传参 → 日志里是 GBK 字节。
136
+ // 按同一编码解读(真实 core 是 .exe,直接 exec 时参数是 UTF-8 —— 那是另一条路)。
137
+ decoded := decodeScriptBytes([]byte(c))
138
+ if strings.Contains(decoded, "--requirement-shot ws-1") &&
139
+ strings.Contains(decoded, "--requirement-name 需求页") &&
140
+ strings.Contains(decoded, "--requirement-module 任务列表") &&
141
+ strings.Contains(decoded, "--requirement-caption 改成双列") {
142
+ sawArgs = true
143
+ }
144
+ if strings.Contains(c, "STDIN_BYTES=") {
145
+ // PNG 字节数(12)应原样过 stdin。
146
+ if !strings.Contains(c, "STDIN_BYTES=12") {
147
+ t.Errorf("PNG 应原样走 stdin(12 字节),实际 %s", c)
148
+ }
149
+ sawStdin = true
150
+ }
151
+ }
152
+ if !sawArgs {
153
+ t.Errorf("参数应原样透传,实际 %v", readCalls(t, logPath))
154
+ }
155
+ if !sawStdin {
156
+ t.Errorf("PNG 应走 stdin 而不是 argv,实际 %v", readCalls(t, logPath))
157
+ }
158
+ }
159
+
160
+ func TestRequirementShotsRejectsBadInput(t *testing.T) {
161
+ logPath := filepath.Join(t.TempDir(), "calls.log")
162
+ svc := shotsService(t, logPath)
163
+ cases := []struct {
164
+ name string
165
+ url string
166
+ body []byte
167
+ code int
168
+ }{
169
+ {"缺 name", "/desk/requirements/shots?token=tok", []byte("x"), http.StatusBadRequest},
170
+ {"空 body", "/desk/requirements/shots?token=tok&name=x", nil, http.StatusBadRequest},
171
+ {"GET 不行", "/desk/requirements/shots?token=tok&name=x", nil, http.StatusMethodNotAllowed},
172
+ }
173
+ for _, c := range cases {
174
+ t.Run(c.name, func(t *testing.T) {
175
+ var req *http.Request
176
+ if c.code == http.StatusMethodNotAllowed {
177
+ req = httptest.NewRequest(http.MethodGet, c.url, nil)
178
+ } else {
179
+ req = httptest.NewRequest(http.MethodPost, c.url, bytes.NewReader(c.body))
180
+ }
181
+ rec := httptest.NewRecorder()
182
+ svc.Handler().ServeHTTP(rec, req)
183
+ if rec.Code != c.code {
184
+ t.Errorf("应回 %d,实际 %d: %s", c.code, rec.Code, rec.Body.String())
185
+ }
186
+ })
187
+ }
188
+ }
189
+
190
+ func TestRequirementShotsRejectsOversize(t *testing.T) {
191
+ logPath := filepath.Join(t.TempDir(), "calls.log")
192
+ svc := shotsService(t, logPath)
193
+ big := make([]byte, 9<<20) // > 8MB 上限
194
+ req := httptest.NewRequest(http.MethodPost, "/desk/requirements/shots?token=tok&name=x", bytes.NewReader(big))
195
+ rec := httptest.NewRecorder()
196
+ svc.Handler().ServeHTTP(rec, req)
197
+ if rec.Code != http.StatusRequestEntityTooLarge {
198
+ t.Errorf("超限应回 413,实际 %d: %s", rec.Code, rec.Body.String())
199
+ }
200
+ }