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
@@ -0,0 +1,98 @@
1
+ package cli
2
+
3
+ // suggest.go - `--suggest-followups <file|->`:一轮结束后生成「用户下一句」的快捷回复。
4
+ //
5
+ // 为什么要单独一条 CLI(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,
6
+ // 点击即可发送」):界面(移动端 / PC)不该自己拼提示词、自己调模型 —— 那样每端一份,
7
+ // 同一轮回答在两处会给出不同建议。口径收进 core 之后只有一份(internal/suggest),
8
+ // 客户端(magic-client 的 /desk/suggest)exec 这一条即可。
9
+ //
10
+ // 与 `--summarize-title` 同一套旁路调用:独立进程、新会话、ToolsOff,不续接当前对话,
11
+ // 因此不会污染用户正在聊的那条上下文;也不写会话登记 / 日志(不经过 ask 主流程)。
12
+ //
13
+ // 退出码:
14
+ //
15
+ // 0 成功(哪怕建议为空数组 —— 「没有下一步」是合法结果,调用方不画按钮)
16
+ // 1 引擎失败(不可用 / 超时 / 报错)
17
+ // 2 参数错(请求 JSON 读不到 / 不合法 / 缺 reply)
18
+
19
+ import (
20
+ "encoding/json"
21
+ "fmt"
22
+ "io"
23
+ "os"
24
+ "strings"
25
+
26
+ "github.com/spf13/cobra"
27
+
28
+ "github.com/darren/magic-agent/internal/suggest"
29
+ )
30
+
31
+ // runSuggestFollowups 读取请求 JSON → 调 suggest.Generate → 打印 JSON 结果。
32
+ func runSuggestFollowups(cmd *cobra.Command, opts *askOptions) error {
33
+ body, err := readSuggestInput(cmd, opts.suggestInput)
34
+ if err != nil {
35
+ return &usageError{err}
36
+ }
37
+ /* 引擎:没显式传 -e 时用 llm(纯文本、无工具、没有 CLI 会话副作用,与 --title-engine 同口径);
38
+ 传了就以调用方的为准 —— 移动端把当前引擎带下来,用户的机器上一定装过它。 */
39
+ engine := suggest.DefaultEngine
40
+ if flagChanged(cmd, "engine") {
41
+ engine = opts.engine
42
+ }
43
+ model := ""
44
+ if flagChanged(cmd, "model") {
45
+ model = opts.model
46
+ }
47
+ res, err := suggest.Generate(cmd.Context(), suggest.Request{
48
+ User: body.User,
49
+ Reply: body.Reply,
50
+ Engine: engine,
51
+ Model: model,
52
+ Timeout: suggest.DefaultTimeout,
53
+ })
54
+ if err != nil {
55
+ return fmt.Errorf("--suggest-followups: 生成失败: %w", err)
56
+ }
57
+ if res.Suggestions == nil {
58
+ res.Suggestions = []string{}
59
+ }
60
+ return printJSON(cmd.OutOrStdout(), res)
61
+ }
62
+
63
+ // readSuggestInput 读请求 JSON:`-` = stdin,其余按文件路径(支持 `@file` 写法)。
64
+ // 上限 1MB:建议只该带「这一轮」的正文,超了说明调用方把整条会话塞进来了。
65
+ func readSuggestInput(cmd *cobra.Command, spec string) (suggest.RequestBody, error) {
66
+ var body suggest.RequestBody
67
+ src := strings.TrimSpace(spec)
68
+ switch src {
69
+ case "":
70
+ return body, fmt.Errorf("--suggest-followups 需要请求 JSON:传文件路径或 \"-\"(stdin)")
71
+ case "-":
72
+ b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
73
+ if err != nil {
74
+ return body, fmt.Errorf("读 stdin 失败:%w", err)
75
+ }
76
+ if err := json.Unmarshal(b, &body); err != nil {
77
+ return body, fmt.Errorf("stdin 不是合法 JSON:%w", err)
78
+ }
79
+ default:
80
+ path := strings.TrimPrefix(src, "@")
81
+ if strings.HasPrefix(path, "~/") {
82
+ if home, herr := os.UserHomeDir(); herr == nil {
83
+ path = home + path[1:]
84
+ }
85
+ }
86
+ b, err := os.ReadFile(path)
87
+ if err != nil {
88
+ return body, fmt.Errorf("读请求文件失败:%w", err)
89
+ }
90
+ if err := json.Unmarshal(b, &body); err != nil {
91
+ return body, fmt.Errorf("请求文件不是合法 JSON:%w", err)
92
+ }
93
+ }
94
+ if strings.TrimSpace(body.Reply) == "" {
95
+ return body, fmt.Errorf(`请求 JSON 缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
96
+ }
97
+ return body, nil
98
+ }
@@ -0,0 +1,54 @@
1
+ package cli
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/darren/magic-agent/internal/suggest"
9
+ )
10
+
11
+ // TestSuggestFollowupsJSON 走完整 CLI:读 stdin 的请求 JSON → 假引擎 → 输出建议。
12
+ func TestSuggestFollowupsJSON(t *testing.T) {
13
+ registerFake(&stringEngine{
14
+ name: "fake-suggest-cli",
15
+ text: "这是建议:\n```json\n{\"suggestions\":[\"好的,执行吧\",\"先看一下 diff\"]}\n```",
16
+ })
17
+ body := `{"user":"改完了吗","reply":"要我继续吗?"}`
18
+ out, errOut, err := runAskCmd(t, body, "--suggest-followups", "-", "-e", "fake-suggest-cli")
19
+ if err != nil {
20
+ t.Fatalf("suggest: %v(stderr=%s)", err, errOut)
21
+ }
22
+ var res suggest.Result
23
+ if uerr := json.Unmarshal([]byte(out), &res); uerr != nil {
24
+ t.Fatalf("输出不是 JSON:%v\n%s", uerr, out)
25
+ }
26
+ if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
27
+ t.Fatalf("Suggestions = %#v", res.Suggestions)
28
+ }
29
+ if res.Engine != "fake-suggest-cli" {
30
+ t.Fatalf("Engine = %q", res.Engine)
31
+ }
32
+ }
33
+
34
+ // TestSuggestFollowupsEmptyReply 没有 reply 是**参数错**,不能当成空建议静默成功。
35
+ func TestSuggestFollowupsEmptyReply(t *testing.T) {
36
+ _, _, err := runAskCmd(t, `{"user":"只说了一句"}`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
37
+ if err == nil {
38
+ t.Fatal("缺 reply 应报参数错")
39
+ }
40
+ if !strings.Contains(err.Error(), "reply") {
41
+ t.Fatalf("报错没说清缺什么:%v", err)
42
+ }
43
+ }
44
+
45
+ // TestSuggestFollowupsBadJSON 非法 JSON 也必须明确报错(exit 2 的语义)。
46
+ func TestSuggestFollowupsBadJSON(t *testing.T) {
47
+ _, _, err := runAskCmd(t, `not json`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
48
+ if err == nil {
49
+ t.Fatal("非法 JSON 应报参数错")
50
+ }
51
+ if _, ok := err.(*usageError); !ok {
52
+ t.Fatalf("应是 usageError(exit 2),实际 %T: %v", err, err)
53
+ }
54
+ }
@@ -17,6 +17,8 @@ import (
17
17
  "encoding/json"
18
18
  "fmt"
19
19
  "strings"
20
+ "sync"
21
+ "time"
20
22
 
21
23
  "github.com/spf13/cobra"
22
24
 
@@ -98,7 +100,50 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
98
100
  // 事件出口:--no-thinking 只压思考。
99
101
  // 团队模式下主会话只出现任务列表快照(KindTasks)与汇总,子任务的过程事件
100
102
  // 走各自子会话的日志(见 team_session.go),不再带 parent 转发进来。
101
- emit := func(ev agent.StreamEvent) {
103
+ //
104
+ // ⚠️ 登记表的「还活着」靠这里续期(2026-10-22 报障:「团队模式子任务还在跑 主会话
105
+ // 就说结束了」)。流式路径(ask.go)对**每条事件**都写一次状态,团队路径此前只在
106
+ // 开跑前写一次 running —— 计划 + 子任务几分钟里 updated_at 一动不动,网关的尾随
107
+ // 连接(followStateEvery=5s)据 terminalSessionState 判「这一轮已结束」,画面落
108
+ // 红点收尾行,而子任务进程其实还在跑。团队模式没有 turn_end 可依,所以任何**主会话
109
+ // 自己发出的事件**(计划、任务列表快照、心跳、汇总)都当成一次「还在跑」。
110
+ // (h 在下面才建:闭包定义在这里不带值,第一次调用在 beginSession 之后。)
111
+ var emit func(agent.StreamEvent)
112
+
113
+ ctx := cmd.Context()
114
+ if !opts.keepAlive || flagChanged(cmd, "timeout") {
115
+ var cancel context.CancelFunc
116
+ ctx, cancel = context.WithTimeout(ctx, opts.timeout)
117
+ defer cancel()
118
+ }
119
+ // --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
120
+ var cancelAll context.CancelFunc
121
+ if opts.control {
122
+ ctx, cancelAll = context.WithCancel(ctx)
123
+ defer cancelAll()
124
+ }
125
+
126
+ h := beginSession(engine.Name(), req, opts.saveRegistry)
127
+ /* 团队模式**不挂** spawn hook:登记表里的 pid 必须一直是 magic-agent 自己。
128
+ 团队路径会多次 spawn 主引擎(计划 / 汇总 / 补充规划),hook 会把 pid 换成
129
+ 这些**短命**子进程的 pid;第一个(计划阶段)一退,normalizeDead 见 pid 不在
130
+ 就判整条主会话 gone —— 而此刻子任务正在跑。真机复现:24 分钟的一轮里主会话
131
+ 在第 32 秒就被判死,直到收尾才写回 done。
132
+ 子引擎进程不靠这条登记收:它们各有自己的子会话记录(openSubSession),
133
+ 异常退出时由 orphanSubs 按进程组收。 */
134
+ _ = withSessionHook
135
+ if h != nil {
136
+ // 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
137
+ h.SetMode(session.ModeTeam)
138
+ h.Mark(session.StateRunning)
139
+ }
140
+ emit = func(ev agent.StreamEvent) {
141
+ h.Mark(session.StateRunning)
142
+ /* 续期(2026-10-22 真机复现):Mark 在新旧状态相同时**不写盘**,所以上面那一句
143
+ 对「已经 running」的会话等于空操作 —— updated_at 从第一条事件起就冻住了,
144
+ 网关尾随连接据此判终态,画面在子任务还在跑时就落「这一轮已结束」。
145
+ Touch 才是真正的续期;5 秒节流对齐网关的 followStateEvery。 */
146
+ h.Touch(5 * time.Second)
102
147
  if ev.Kind == agent.KindThinking && opts.noThinking {
103
148
  return
104
149
  }
@@ -136,27 +181,6 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
136
181
  })
137
182
  }
138
183
  }
139
-
140
- ctx := cmd.Context()
141
- if !opts.keepAlive || flagChanged(cmd, "timeout") {
142
- var cancel context.CancelFunc
143
- ctx, cancel = context.WithTimeout(ctx, opts.timeout)
144
- defer cancel()
145
- }
146
- // --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
147
- var cancelAll context.CancelFunc
148
- if opts.control {
149
- ctx, cancelAll = context.WithCancel(ctx)
150
- defer cancelAll()
151
- }
152
-
153
- h := beginSession(engine.Name(), req, opts.saveRegistry)
154
- ctx = withSessionHook(ctx, h)
155
- if h != nil {
156
- // 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
157
- h.SetMode(session.ModeTeam)
158
- h.Mark(session.StateRunning)
159
- }
160
184
  if sessionLog != nil && h != nil {
161
185
  sessionLog.setRunID(h.RunID())
162
186
  }
@@ -180,6 +204,10 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
180
204
  if h != nil {
181
205
  runID = h.RunID()
182
206
  }
207
+ /* 本次运行开出来的子会话(按 pid 收进程组用,见 orphanSubs)。
208
+ 写它的是跑子任务的 goroutine,读的是异常退出那一支 —— 加锁。 */
209
+ var mu sync.Mutex
210
+ var subs []*subSession
183
211
  res, err := team.Run(ctx, team.Options{
184
212
  Engine: engine,
185
213
  Model: req.Model,
@@ -204,13 +232,48 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
204
232
  PlanOnly: opts.teamPlanOnly,
205
233
  Adaptive: opts.teamAdaptive,
206
234
  // 每个子任务 = 一条普通会话:登记记录 + 自己的日志,父指针挂在本会话下。
235
+ //
236
+ // 开出来的子会话都记进 subs:父进程异常退出时要按它们的 pid 收进程组
237
+ // (见下面的 orphan sweep),否则主会话没了、子引擎还在跑,列表里挂一条永远
238
+ // running 的子会话。
207
239
  OpenSubSession: func(t team.Task, runDir, engine, model string) team.SubSession {
208
- return openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
240
+ s := openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
241
+ if ss, ok := s.(*subSession); ok && ss != nil {
242
+ mu.Lock()
243
+ subs = append(subs, ss)
244
+ mu.Unlock()
245
+ }
246
+ return s
209
247
  },
210
248
  OnEvent: emit,
249
+ // 计划阶段不发事件的续期(见 team.Options.OnTick)。
250
+ OnTick: func() { h.Touch(0) },
211
251
  })
252
+ mu.Lock()
253
+ running := append([]*subSession(nil), subs...)
254
+ mu.Unlock()
212
255
  if err != nil {
213
- finishSession(h, "", session.StateFailed)
256
+ /* 异常退出也要有**终态**(2026-10-22):团队模式没有 turn_end 可依,父进程失败时
257
+ 一条终态事件都不发 —— 客户端只能看登记表,而登记表停在 running,网关按「还在跑」
258
+ 一直等(或按内核被杀归一成 gone)。这里补一条 turn_failed:走的是流式路径
259
+ 同一条收尾口径(turn_failed → 画面一行「本轮异常收尾」),调用方不必再猜。 */
260
+ interrupted := ctx.Err() != nil
261
+ reason := ""
262
+ if interrupted {
263
+ reason = "已中断(主进程被取消或被杀)"
264
+ } else {
265
+ reason = err.Error()
266
+ }
267
+ emit(agent.StreamEvent{Kind: agent.KindTurnFailed, Text: reason, Reason: reason})
268
+ state := session.StateFailed
269
+ if interrupted {
270
+ state = session.StateInterrupted
271
+ }
272
+ finishSession(h, "", state)
273
+ /* 子引擎是独立进程组(子会话上下文里挂的 SpawnHook 按组登记 pid):
274
+ 父这一轮已经作废,把还在跑的一起收掉 —— 否则会话列表里永远挂着一条 running
275
+ 的子会话,用户点进去只看到半截过程,而且它会一直烧额度。 */
276
+ orphanSubs(running)
214
277
  if format == agent.FormatJSON {
215
278
  _ = sink.event(streamErrorPayload(engine.Name(), err))
216
279
  _ = agent.WriteError(stderr, format, engine.Name(), 1, err)
@@ -253,6 +316,30 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
253
316
  return nil
254
317
  }
255
318
 
319
+ // orphanSubs 收掉还没落定的子会话对应的子引擎进程组。
320
+ //
321
+ // 只在团队这一轮**异常退出**时调:正常收尾时每个子会话都在 runTask 的 end() 里
322
+ // Finish 过(进程已经自己退了)。这里兜的是「父进程这一轮作废、子引擎还在跑」那条路
323
+ // —— 不收就是孤儿进程:它一边烧额度,一边让会话列表里那条子会话永远 running。
324
+ //
325
+ // 幂等:进程已经不在(PIDAlive=false)就只补登记表状态,不再发信号。
326
+ func orphanSubs(list []*subSession) {
327
+ for _, s := range list {
328
+ if s == nil {
329
+ continue
330
+ }
331
+ rec := s.h.Record()
332
+ if rec.PID > 0 && agent.PIDAlive(rec.PID) {
333
+ _ = agent.KillPID(rec.PID, rec.KillGroup)
334
+ }
335
+ s.h.Finish(rec.SessionID, session.StateInterrupted)
336
+ if s.log != nil {
337
+ s.log.close()
338
+ s.log = nil
339
+ }
340
+ }
341
+ }
342
+
256
343
  // agentRefs 把子任务结果按「类别 + 引擎 + 模型」归并成登记表里的 Agents。
257
344
  //
258
345
  // 为什么按类别归并而不是一个任务一条:登记表是列表页的数据源,一个 6 任务的团队会话
@@ -0,0 +1,135 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "net/http"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ )
11
+
12
+ // 移动端 APK 的分发(2026-10-22,用户:「移动端版本跟着 npm 走」+「在版本号的地方
13
+ // 可以显示版本也可检测最新版本号并触发升级」)。
14
+ //
15
+ // 版本号只有一个来源:npm 包里的 package.json。构建时 magic-test 的
16
+ // tools/build-mobile-apk.cjs 把同一个版本写进 APK 的 versionName,并把 release 包放到
17
+ // npm/apk/ 下随包发布。本插件负责把它通过局域网递给手机:
18
+ //
19
+ // GET /desk/app -> {version, size, url}(手机「我的」→「版本」点一下先问这个)
20
+ // GET /desk/app.apk -> 真正的安装包(浏览器/系统下载器用 ?token= 过鉴权)
21
+ //
22
+ // 为什么由桌面端发:手机和桌面已经配对、这条路一定通;不依赖 GitHub Release / 应用市场,
23
+ // 也不需要把 7MB 的 APK 塞进每个平台的 npm 二进制目录。
24
+
25
+ // apkManifest 与 npm/apk/version.json 对应,由 magic-test 的打包脚本生成。
26
+ type apkManifest struct {
27
+ Version string `json:"version"`
28
+ VersionCode int `json:"versionCode"`
29
+ }
30
+
31
+ // findBundledAPK 从可执行文件所在目录向上找 npm/apk/magic-agent-mobile-release.apk。
32
+ //
33
+ // 两种布局都要能命中:
34
+ // - 开发态:<repo>/bin/magic-client → <repo>/npm/apk/...
35
+ // - npm 全局:<pkg>/npm/dist/<os>-<arch>/magic-client → <pkg>/npm/apk/...
36
+ //
37
+ // 不写死层级:向上最多找 6 层,遇到根就停。cwd 也找一遍 —— 开发时从别的目录启动、
38
+ // 但包目录在 cwd 下面时仍能命中。
39
+ func findBundledAPK() string {
40
+ const rel = "npm/apk/magic-agent-mobile-release.apk"
41
+ var roots []string
42
+ addAncestors := func(start string) {
43
+ dir, err := filepath.Abs(start)
44
+ if err != nil {
45
+ return
46
+ }
47
+ for i := 0; i < 6; i++ {
48
+ roots = append(roots, dir)
49
+ parent := filepath.Dir(dir)
50
+ if parent == dir {
51
+ break
52
+ }
53
+ dir = parent
54
+ }
55
+ }
56
+ if exe, err := os.Executable(); err == nil {
57
+ if real, err := filepath.EvalSymlinks(exe); err == nil {
58
+ exe = real
59
+ }
60
+ addAncestors(filepath.Dir(exe))
61
+ }
62
+ if wd, err := os.Getwd(); err == nil {
63
+ addAncestors(wd)
64
+ }
65
+ for _, root := range roots {
66
+ p := filepath.Join(root, filepath.FromSlash(rel))
67
+ if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
68
+ return p
69
+ }
70
+ }
71
+ return ""
72
+ }
73
+
74
+ // apkVersion 读 version.json;缺失时从文件名兜底(magic-agent-mobile-<ver>-release.apk)。
75
+ func apkVersion(path string) string {
76
+ b, err := os.ReadFile(filepath.Join(filepath.Dir(path), "version.json"))
77
+ if err == nil {
78
+ var m apkManifest
79
+ if json.Unmarshal(b, &m) == nil && strings.TrimSpace(m.Version) != "" {
80
+ return strings.TrimSpace(m.Version)
81
+ }
82
+ }
83
+ name := filepath.Base(path)
84
+ name = strings.TrimSuffix(name, ".apk")
85
+ name = strings.TrimPrefix(name, "magic-agent-mobile-")
86
+ name = strings.TrimSuffix(name, "-release")
87
+ if name != "" && name != filepath.Base(path) {
88
+ return name
89
+ }
90
+ return ""
91
+ }
92
+
93
+ func (s *Service) apkAvailable() bool {
94
+ if s.apkPath == "" {
95
+ return false
96
+ }
97
+ st, err := os.Stat(s.apkPath)
98
+ return err == nil && st.Mode().IsRegular()
99
+ }
100
+
101
+ // handleAppInfo GET /desk/app:告诉手机「桌面端这份 npm 包里带的 APK 是哪一版」。
102
+ // 没带 APK 时也回 200 + 空 version —— 手机据此说「暂无可安装包」,而不是把 404 当网络错误。
103
+ func (s *Service) handleAppInfo(w http.ResponseWriter, r *http.Request) {
104
+ out := struct {
105
+ Version string `json:"version"`
106
+ Size int64 `json:"size"`
107
+ URL string `json:"url"`
108
+ }{}
109
+ if s.apkAvailable() {
110
+ if st, err := os.Stat(s.apkPath); err == nil {
111
+ out.Version = apkVersion(s.apkPath)
112
+ out.Size = st.Size()
113
+ out.URL = "/desk/app.apk"
114
+ }
115
+ }
116
+ writeJSON(w, http.StatusOK, out)
117
+ }
118
+
119
+ // handleAppAPK GET /desk/app.apk:把包里的 APK 原样送出去。
120
+ // token 可以走查询参数 —— 系统浏览器/下载器带不了 X-Magic-Token,guard 已经支持 ?token=。
121
+ func (s *Service) handleAppAPK(w http.ResponseWriter, r *http.Request) {
122
+ if !s.apkAvailable() {
123
+ writeErr(w, http.StatusNotFound, "这个 magic-client 包里没有 APK(npm/apk/ 为空)")
124
+ return
125
+ }
126
+ ver := apkVersion(s.apkPath)
127
+ name := "magic-agent-mobile.apk"
128
+ if ver != "" {
129
+ name = "magic-agent-mobile-" + ver + ".apk"
130
+ }
131
+ w.Header().Set("Content-Type", "application/vnd.android.package-archive")
132
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
133
+ w.Header().Set("Cache-Control", "no-store")
134
+ http.ServeFile(w, r, s.apkPath)
135
+ }
@@ -0,0 +1,99 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "testing"
10
+ )
11
+
12
+ // 2026-10-22:移动端「我的」→「版本」点一下要能问桌面端「包里带的 APK 是哪一版」,
13
+ // 再决定是否触发下载。这里钉住两个接口的形状与空包时的行为。
14
+
15
+ func writeTestAPK(t *testing.T, dir, version string) string {
16
+ t.Helper()
17
+ apk := filepath.Join(dir, "magic-agent-mobile-release.apk")
18
+ body := []byte("fake-apk-" + version)
19
+ if err := os.WriteFile(apk, body, 0o644); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ m := apkManifest{Version: version, VersionCode: 20102}
23
+ b, _ := json.Marshal(m)
24
+ if err := os.WriteFile(filepath.Join(dir, "version.json"), b, 0o644); err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ return apk
28
+ }
29
+
30
+ func TestAppInfoAndAPK(t *testing.T) {
31
+ dir := t.TempDir()
32
+ apk := writeTestAPK(t, dir, "2.1.2")
33
+ s := &Service{apkPath: apk}
34
+
35
+ rec := httptest.NewRecorder()
36
+ s.handleAppInfo(rec, httptest.NewRequest(http.MethodGet, "/desk/app", nil))
37
+ if rec.Code != http.StatusOK {
38
+ t.Fatalf("app info code = %d, want 200", rec.Code)
39
+ }
40
+ var got struct {
41
+ Version string `json:"version"`
42
+ Size int64 `json:"size"`
43
+ URL string `json:"url"`
44
+ }
45
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if got.Version != "2.1.2" || got.URL != "/desk/app.apk" || got.Size != int64(len("fake-apk-2.1.2")) {
49
+ t.Fatalf("app info = %+v", got)
50
+ }
51
+
52
+ rec = httptest.NewRecorder()
53
+ s.handleAppAPK(rec, httptest.NewRequest(http.MethodGet, "/desk/app.apk", nil))
54
+ if rec.Code != http.StatusOK {
55
+ t.Fatalf("apk code = %d, want 200", rec.Code)
56
+ }
57
+ if rec.Body.String() != "fake-apk-2.1.2" {
58
+ t.Fatalf("apk body = %q", rec.Body.String())
59
+ }
60
+ if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.android.package-archive" {
61
+ t.Fatalf("content-type = %q", ct)
62
+ }
63
+ if cd := rec.Header().Get("Content-Disposition"); cd == "" {
64
+ t.Fatal("缺少 Content-Disposition,浏览器会拿不到 .apk 文件名")
65
+ }
66
+ }
67
+
68
+ func TestAppInfoWithoutAPK(t *testing.T) {
69
+ s := &Service{}
70
+ rec := httptest.NewRecorder()
71
+ s.handleAppInfo(rec, httptest.NewRequest(http.MethodGet, "/desk/app", nil))
72
+ if rec.Code != http.StatusOK {
73
+ t.Fatalf("code = %d, want 200(没包也要让手机能说「暂无」)", rec.Code)
74
+ }
75
+ var got map[string]any
76
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if got["version"] != "" || got["url"] != "" {
80
+ t.Fatalf("空包 info = %+v", got)
81
+ }
82
+
83
+ rec = httptest.NewRecorder()
84
+ s.handleAppAPK(rec, httptest.NewRequest(http.MethodGet, "/desk/app.apk", nil))
85
+ if rec.Code != http.StatusNotFound {
86
+ t.Fatalf("空包 apk code = %d, want 404", rec.Code)
87
+ }
88
+ }
89
+
90
+ func TestAPKVersionFallsBackToFilename(t *testing.T) {
91
+ dir := t.TempDir()
92
+ apk := filepath.Join(dir, "magic-agent-mobile-3.2.1-release.apk")
93
+ if err := os.WriteFile(apk, []byte("x"), 0o644); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ if got := apkVersion(apk); got != "3.2.1" {
97
+ t.Fatalf("apkVersion = %q, want 3.2.1", got)
98
+ }
99
+ }
@@ -0,0 +1,99 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "net/http"
7
+ "os"
8
+ "path/filepath"
9
+ )
10
+
11
+ /* 移动端「环境配置」的远端存储(2026-10-22 用户:
12
+ * 「环境的配置应该在远程上 不是浏览器缓存 否则同一个环境多个客户端配置还不一致」)。
13
+ *
14
+ * 为什么放在桌面端:默认引擎/模型、团队配置、空间选择这些描述的是**这台桌面怎么干活**,
15
+ * 不是某台手机的屏幕习惯。存在浏览器 localStorage 里,手机 A 改了手机 B 不知道;
16
+ * 换一台手机连同一台桌面,看到的还是另一套配置。
17
+ *
18
+ * GET /desk/prefs -> 这台桌面当前的配置 JSON(没有就是 {})
19
+ * PUT /desk/prefs -> 整份覆盖(移动端改完推上来)
20
+ *
21
+ * 只存一个 JSON 文件,字段由移动端定义、桌面端不解释 —— 这样加一个配置项不用改这里。
22
+ * 落在 ~/.magic-agent/mobile-prefs.json,与 core 的其它数据同层次。
23
+ *
24
+ * 不做版本/合并:多端同时改就是「后写覆盖」。这些是偏好而不是业务数据,
25
+ * 冲突代价低;真需要并发控制时再加 version 字段也不迟。
26
+ */
27
+
28
+ const mobilePrefsMaxBytes = 256 << 10
29
+
30
+ func mobilePrefsPath() string {
31
+ home, err := os.UserHomeDir()
32
+ if err != nil || home == "" {
33
+ return ""
34
+ }
35
+ return filepath.Join(home, ".magic-agent", "mobile-prefs.json")
36
+ }
37
+
38
+ func (s *Service) handlePrefs(w http.ResponseWriter, r *http.Request) {
39
+ switch r.Method {
40
+ case http.MethodGet:
41
+ w.Header().Set("Cache-Control", "no-store")
42
+ p := mobilePrefsPath()
43
+ if p == "" {
44
+ writeJSON(w, http.StatusOK, map[string]any{})
45
+ return
46
+ }
47
+ b, err := os.ReadFile(p)
48
+ if err != nil || len(b) == 0 {
49
+ // 没存过 = 空配置,不把「文件不存在」当成错误。
50
+ writeJSON(w, http.StatusOK, map[string]any{})
51
+ return
52
+ }
53
+ if !json.Valid(b) {
54
+ writeJSON(w, http.StatusOK, map[string]any{})
55
+ return
56
+ }
57
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
58
+ _, _ = w.Write(b)
59
+
60
+ case http.MethodPut:
61
+ body, err := io.ReadAll(io.LimitReader(r.Body, mobilePrefsMaxBytes+1))
62
+ if err != nil {
63
+ writeErr(w, http.StatusBadRequest, "读请求体失败:"+err.Error())
64
+ return
65
+ }
66
+ if len(body) > mobilePrefsMaxBytes {
67
+ writeErr(w, http.StatusRequestEntityTooLarge, "配置超过 256KB")
68
+ return
69
+ }
70
+ var v map[string]any
71
+ if err := json.Unmarshal(body, &v); err != nil {
72
+ writeErr(w, http.StatusBadRequest, "不是合法的 JSON 对象")
73
+ return
74
+ }
75
+ p := mobilePrefsPath()
76
+ if p == "" {
77
+ writeErr(w, http.StatusInternalServerError, "找不到用户目录")
78
+ return
79
+ }
80
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
81
+ writeErr(w, http.StatusInternalServerError, "建目录失败:"+err.Error())
82
+ return
83
+ }
84
+ // 先写临时文件再 rename:多端同时推的时候不会读到写了一半的 JSON。
85
+ tmp := p + ".tmp"
86
+ if err := os.WriteFile(tmp, body, 0o600); err != nil {
87
+ writeErr(w, http.StatusInternalServerError, "写配置失败:"+err.Error())
88
+ return
89
+ }
90
+ if err := os.Rename(tmp, p); err != nil {
91
+ writeErr(w, http.StatusInternalServerError, "落盘失败:"+err.Error())
92
+ return
93
+ }
94
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
95
+
96
+ default:
97
+ writeErr(w, http.StatusMethodNotAllowed, "只支持 GET / PUT")
98
+ }
99
+ }