magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -2,18 +2,26 @@ package client
2
2
 
3
3
  import (
4
4
  "bufio"
5
+ "bytes"
5
6
  "context"
6
7
  "encoding/json"
7
8
  "errors"
8
9
  "fmt"
10
+ "image"
11
+ "image/draw"
12
+ _ "image/gif"
13
+ "image/jpeg"
14
+ "image/png"
9
15
  "io"
10
16
  "log"
17
+ "math"
11
18
  "net/http"
12
19
  "net/http/httputil"
13
20
  "net/url"
14
21
  "os"
15
22
  "os/exec"
16
23
  "path/filepath"
24
+ "sort"
17
25
  "strconv"
18
26
  "strings"
19
27
  "sync"
@@ -47,9 +55,17 @@ type Options struct {
47
55
  Profile Profile
48
56
  // UIRoot 静态资源目录(开发态从磁盘加载;发布态由调用方指向内置资源解包目录)。
49
57
  UIRoot string
58
+ // MobileRoot 移动端界面目录(client-ui-mobile)。非空时提供 /m/ 路由 ——
59
+ // APK 壳配对后**远程加载**它,于是改界面只改桌面文件、不用重打 APK。
60
+ // 空 = 自动取与 --ui-dir 同级的 client-ui-mobile。
61
+ MobileRoot string
50
62
  // Token 访问令牌;空 = 启动时随机生成。
51
63
  Token string
52
64
  Logger *log.Logger
65
+ // BrowseUA 内置浏览器中转使用的 User-Agent;空 = 用内置的 Android Chrome。
66
+ // 为什么可配:UA 会决定站点给哪一版页面。中国大陆出口 + 移动 UA 时,Google 会跳到
67
+ // http://www.google.cn/m(该路径本身就是 404);换成桌面 UA 则走香港跳转、可正常打开。
68
+ BrowseUA string
53
69
  }
54
70
 
55
71
  // Service 本地服务。
@@ -64,6 +80,11 @@ type Service struct {
64
80
  mu sync.Mutex
65
81
  runs map[string]*askRun
66
82
  seq int64
83
+
84
+ // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
85
+ // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
86
+ browseOnce sync.Once
87
+ browseHTTP *http.Client
67
88
  }
68
89
 
69
90
  // Contract core `--contract` 里本插件关心的部分。
@@ -135,6 +156,21 @@ func (s *Service) Handler() http.Handler {
135
156
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
136
157
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
137
158
  mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
159
+ mux.HandleFunc("/desk/upload", s.guard(s.handleUpload))
160
+ // 回读已上传附件(缩略图 / 历史回看):<img> 带不了自定义头,靠 guard 支持的 ?token= 过鉴权。
161
+ mux.HandleFunc("/desk/uploads/", s.guard(s.handleUploadGet))
162
+ // 按**绝对路径**回读本机图片 / 视频 / 音频:工具卡缩略图、会话产物(路径在 core 机器上)。
163
+ mux.HandleFunc("/desk/media", s.guard(s.handleMediaFile))
164
+ // 一批本机路径的存在性探测:内核画「会话产物」只画真的落在盘上的那些。
165
+ mux.HandleFunc("/desk/path-probe", s.guard(s.handlePathProbe))
166
+ // 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
167
+ mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
168
+ mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
169
+ // 插件目录(magic-shorts / magic-video 两个工程的 CLI):介绍、安装状态、安装/升级。
170
+ // 与引擎无关 —— core 不认识它们,所以放在这一层而不是 core 的 --engines 里。
171
+ mux.HandleFunc("/desk/plugins", s.guard(s.handlePlugins))
172
+ mux.HandleFunc("/desk/plugins/", s.guard(s.handlePluginAction))
173
+ mux.HandleFunc("/desk/dirs", s.guard(s.handleDirs))
138
174
  // /desk/session/{id}/messages 是**前缀匹配**路由 —— 标准 ServeMux 把它挂上后,
139
175
  // 该前缀下任何 path 都先进这里(其它挂载顺序靠后也拦不住)。
140
176
  // 它同时承担 /desk/session/{id}/events(断线续读)的分派,见 handleSessionMessages。
@@ -151,6 +187,15 @@ func (s *Service) Handler() http.Handler {
151
187
  mux.HandleFunc("/pair", s.guard(s.handlePairPage))
152
188
  mux.HandleFunc("/pair/qr.png", s.guard(s.handlePairQR))
153
189
  mux.HandleFunc("/desk/pair", s.guard(s.handlePairInfo))
190
+ // 远程移动端界面:APK 壳配对后直接加载 /m/ —— 改界面只改桌面文件,不重打 APK。
191
+ // 静态页本身不带令牌(与 "/" 一致);数据接口仍由 /desk/* 的令牌把关。
192
+ mux.HandleFunc("/m", s.handleMobileUI)
193
+ mux.HandleFunc("/m/", s.handleMobileUI)
194
+ // 内置浏览器的**服务端中转**:/b/<scheme>/<host><path>。走桌面端网络抓取并改写,
195
+ // 见 browse.go。注意它自己鉴权(cookie/查询令牌),不再套 s.guard。
196
+ mux.HandleFunc("/b/", s.handleBrowse)
197
+ // SOCKS5 over WebSocket:让 SOCKS5 能穿过 HTTP 隧道(Cloudflare 等)到公网,见 wsproxy.go。
198
+ mux.HandleFunc(proxyWSPath, s.handleProxyWS)
154
199
  mux.HandleFunc("/", s.handleStatic)
155
200
  return s.withCORS(mux)
156
201
  }
@@ -218,6 +263,34 @@ func localOrigin(origin string) bool {
218
263
  return false
219
264
  }
220
265
 
266
+ // originSource 由请求 Origin 判定**来源端**:容器自定义 scheme = 移动端,
267
+ // 其余(桌面壳 / 插件同源页面 / 无 Origin)= 桌面端。
268
+ //
269
+ // 与 localOrigin 的分工:localOrigin 回答「浏览器让不让读」(移动容器与 loopback
270
+ // 都算本机);这里只回答「是谁发起的」,所以两者不是一回事,不能合并。
271
+ func originSource(origin string) string {
272
+ switch strings.TrimSpace(origin) {
273
+ case "capacitor://localhost", "ionic://localhost":
274
+ return session.SourceMobile
275
+ default:
276
+ return session.SourcePC
277
+ }
278
+ }
279
+
280
+ // requestSource 定来源端:**客户端自报优先**,没自报才退回按 Origin 猜。
281
+ //
282
+ // 为什么自报优先:移动端是加载 /m/ 页面跑起来的,与桌面同源,Origin 一模一样,
283
+ // 靠 Origin 分不出来;客户端自己最清楚它是谁。Origin 只作为旧客户端/直连的兜底。
284
+ func requestSource(client, origin string) string {
285
+ switch strings.ToLower(strings.TrimSpace(client)) {
286
+ case session.SourceMobile:
287
+ return session.SourceMobile
288
+ case session.SourcePC:
289
+ return session.SourcePC
290
+ }
291
+ return originSource(origin)
292
+ }
293
+
221
294
  // guard 鉴权:`X-Magic-Token` 头或 `token` 查询参数。静态资源不鉴权(它不含密钥,
222
295
  // 真正的执行面都在 /desk/* 后面)。
223
296
  func (s *Service) guard(next http.HandlerFunc) http.HandlerFunc {
@@ -236,12 +309,17 @@ func (s *Service) guard(next http.HandlerFunc) http.HandlerFunc {
236
309
  }
237
310
 
238
311
  func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
312
+ // core 由本插件 spawn、继承本进程的 cwd;而 core 不传 -w 时默认工作空间就是 cwd。
313
+ // 所以这里的 cwd 就是「桌面默认工作空间」,客户端用它给空 workspace 的会话归类。
314
+ wd, _ := os.Getwd()
239
315
  writeJSON(w, http.StatusOK, map[string]any{
240
- "ok": true,
241
- "contractVersion": s.contract.ContractVersion,
242
- "features": s.contract.Features,
243
- "app": s.opts.Profile.App.Name,
244
- "engines": len(s.contract.Engines),
316
+ "ok": true,
317
+ "contractVersion": s.contract.ContractVersion,
318
+ "features": s.contract.Features,
319
+ "app": s.opts.Profile.App.Name,
320
+ "engines": len(s.contract.Engines),
321
+ "defaultWorkspace": wd,
322
+ "workspacesRoot": filepath.Dir(wd),
245
323
  })
246
324
  }
247
325
 
@@ -263,11 +341,59 @@ func (s *Service) handleProfile(w http.ResponseWriter, r *http.Request) {
263
341
  writeJSON(w, http.StatusOK, s.opts.Profile)
264
342
  }
265
343
 
266
- // handleAsks 会话登记表(core 的 `--sessions`):列表页的「最近会话」用它。
344
+ // extraSessionClients 移动端 H5 的会话列表**额外放行**的来源客户端(2026-10-15)。
345
+ //
346
+ // 背景:handleSessions 原先只回「本插件 AppID(Profile.AppID)」发起的会话。但掌天瓶
347
+ // (magic-test)桌面壳**不经本插件发起** —— 它直接 exec core,并注入
348
+ // MAGIC_AGENT_CLIENT=magic-test(见 magic-test/desktop/agent-cli.cjs 的 AGENT_CLIENT)。
349
+ // 只按 AppID 过滤会把它整条滤掉,于是「掌天瓶新建的会话」在移动端根本看不见
350
+ // (用户 2026-10-15:「通过 magic-test 新建的会话要在 magic-agent 移动端 h5 上看到,
351
+ // 同时 magic-agent 上可以看到 magic-test 和 magic-agent 来源的会话」)。
352
+ //
353
+ // 名单两项:magic-test = 掌天瓶桌面壳;magic-agent = core 自身/直连 CLI 的显式标识。
354
+ // ⚠️ 只放行**同族的 magic-* 来源**:匿名(空 client = 手工跑 / CI)与不相干应用仍被挡在
355
+ // 外面 —— 这正是原过滤要避免的「把别人的任务混进本应用的会话列表」。
356
+ var extraSessionClients = []string{"magic-test", "magic-agent"}
357
+
358
+ // handleSessions 会话登记表(core 的 `--sessions`):列表页的「最近会话」用它。
267
359
  // 与 /desk/asks 的区别:asks 是**本进程正在跑的轮次**(内存里),sessions 是 core 的
268
360
  // 持久登记表(跨进程、含已结束的)。
361
+ //
362
+ // ⚠️ 只回「本客户端」与「同族 magic-* 来源」的会话:登记表目录是全机共享的
363
+ // (~/.magic-agent/sessions),直接跑 CLI(匿名)、CI、别的 magic-* 应用都在往里写 ——
364
+ // 原样转发等于把别人的任务混进本应用的会话列表。认客户端靠 core 落的 Record.Client
365
+ // (本插件在 /desk/ask 时注入自己的 AppID;掌天瓶桌面壳注入 magic-test)。
366
+ // 放行名单见 extraSessionClients(2026-10-15 起,不再是「只认自己」)。
269
367
  func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
270
- s.proxyCore(w, r, "--sessions")
368
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
369
+ defer cancel()
370
+ out, err := exec.CommandContext(ctx, s.opts.MagicAgent, "--sessions").Output()
371
+ if err != nil {
372
+ writeErr(w, http.StatusBadGateway,
373
+ fmt.Sprintf("%s --sessions 失败:%v", s.opts.MagicAgent, err))
374
+ return
375
+ }
376
+ /* 解成 map 再原样编码,而不是在这里定一个结构体:core 往后给会话行加字段时
377
+ 插件不用跟着改 —— 它只用到 client 一个字段。解不动就**原样透传**:core 换了
378
+ 输出形状时宁可让客户端看到异常,也不要静默给一个空列表(空列表看起来像
379
+ 「压根没有会话」,是最难查的一种)。 */
380
+ var rows []map[string]any
381
+ if err := json.Unmarshal(out, &rows); err != nil {
382
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
383
+ _, _ = w.Write(out)
384
+ return
385
+ }
386
+ allowed := map[string]bool{s.opts.Profile.AppID(): true}
387
+ for _, c := range extraSessionClients {
388
+ allowed[c] = true
389
+ }
390
+ mine := make([]map[string]any, 0, len(rows))
391
+ for _, row := range rows {
392
+ if c, _ := row["client"].(string); allowed[c] {
393
+ mine = append(mine, row)
394
+ }
395
+ }
396
+ writeJSON(w, http.StatusOK, mine)
271
397
  }
272
398
 
273
399
  // handleSessionMessages 「看历史」持久化落盘的会话事件(`GET /desk/session/{id}/messages`)。
@@ -289,8 +415,31 @@ func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
289
415
  // 后注册的 pattern 并不会自动赢;而带 `{id}` 通配的新式 pattern 需要 Go 1.22+。
290
416
  // 在入口处显式分派既不看 Go 版本脸色,也让「有哪些子路径」集中在一处可读。
291
417
  func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request) {
418
+ if r.Method == http.MethodDelete {
419
+ s.handleSessionDelete(w, r)
420
+ return
421
+ }
422
+ // POST / PUT 只服务**标题**这两条子路径(写标题 / 让 core 归纳标题)。
423
+ // 别的子路径(messages / events)上这两个方法仍然是「方法不允许」——
424
+ // 不能因为新增了 title 路由就把 POST /messages 从 405 变成 400:
425
+ // 405 说的是「这个方法在这个资源上不行」,400 说的是「请求本身不合法」,含义不同。
426
+ if r.Method == http.MethodPost || r.Method == http.MethodPut {
427
+ id, action, ok := parseSessionPath(r.URL.Path)
428
+ if ok && (action == "title" || action == "title/summarize") {
429
+ if action == "title" {
430
+ // POST 与 PUT 同义:都是**写**标题(人工重命名)。客户端用哪个都行。
431
+ s.handleSessionTitle(w, r, id)
432
+ } else {
433
+ s.handleSessionTitleSummarize(w, r, id)
434
+ }
435
+ return
436
+ }
437
+ writeErr(w, http.StatusMethodNotAllowed,
438
+ "POST / PUT 只支持 /desk/session/{id}/title 与 /desk/session/{id}/title/summarize")
439
+ return
440
+ }
292
441
  if r.Method != http.MethodGet {
293
- writeErr(w, http.StatusMethodNotAllowed, "GET only")
442
+ writeErr(w, http.StatusMethodNotAllowed, "GET / DELETE / POST / PUT only")
294
443
  return
295
444
  }
296
445
  id, action, ok := parseSessionPath(r.URL.Path)
@@ -315,14 +464,190 @@ func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request)
315
464
  writeErr(w, http.StatusInternalServerError, "读历史失败:找不到历史目录:"+derr.Error())
316
465
  return
317
466
  }
318
- evs, err := session.ReadSessionLog(dir, id, sessionEventMapFromJSON)
467
+ /* 客户端手里的 id 可能还是 run_id(正在跑的会话在引擎吐 session_id 之前,
468
+ 登记表里只有 run_id)—— 日志文件名按 session_id 落,先翻一次名,
469
+ 否则「点开正在跑的会话」读到空历史(静默失败)。见 session.ResolveLogID。
470
+ 翻出来的 id 照实回报(下面的 session 字段):客户端因此能拿到真正的 session_id。 */
471
+ id = session.ResolveLogID(dir, id)
472
+ /* lean 要在**解码前**生效:snapshot 是二次方膨胀的累积串(实测单会话 283MB),
473
+ 先 Unmarshal 再删字段等于把这份开销白付一遍 —— 读一次 6~7s。
474
+ ReadSessionLogLean 在字节层整段摘掉它,输出字段集与「先读后删」一致。 */
475
+ lean := queryLean(r)
476
+ var evs []map[string]any
477
+ var err error
478
+ /* `?before=N&limit=M`:反向分页(默认只画最近一轮,上翻再要更早的)。
479
+ `before` 是「从文件尾跳过多少条非空行」,不是 seq —— 老日志每轮 seq 从 1 重来,
480
+ 拿 seq 当游标会在 run 边界整段丢掉更早的历史(见 session.ReadSessionLogBefore)。
481
+ 只要请求没带 `before`,行为与字段与旧版逐字段一致(只增参数,不升契约版本)。 */
482
+ if q := r.URL.Query(); q.Has("before") {
483
+ before, plerr := strconv.ParseUint(strings.TrimSpace(q.Get("before")), 10, 64)
484
+ if plerr != nil {
485
+ writeErr(w, http.StatusBadRequest, "before 必须是非负整数:"+q.Get("before"))
486
+ return
487
+ }
488
+ limit := 200
489
+ if v := strings.TrimSpace(q.Get("limit")); v != "" {
490
+ n, lerr := strconv.Atoi(v)
491
+ if lerr != nil || n <= 0 {
492
+ writeErr(w, http.StatusBadRequest, "limit 必须是正整数:"+v)
493
+ return
494
+ }
495
+ if n > 2000 {
496
+ n = 2000
497
+ }
498
+ limit = n
499
+ }
500
+ // 一页必须含轮次起点:客户端按轮渲染,页头切在轮中间会让思考/正文各半截。
501
+ alignToUser := func(e map[string]any) bool { return eventKind(e) == "user" }
502
+ var nextBefore uint64
503
+ var more bool
504
+ if lean {
505
+ evs, nextBefore, more, err = session.ReadSessionLogBeforeLean(dir, id, before, limit, alignToUser, sessionEventMapFromJSON)
506
+ } else {
507
+ evs, nextBefore, more, err = session.ReadSessionLogBefore(dir, id, before, limit, alignToUser, sessionEventMapFromJSON)
508
+ }
509
+ if err != nil {
510
+ writeErr(w, http.StatusInternalServerError, "读历史失败:"+err.Error())
511
+ return
512
+ }
513
+ if lean {
514
+ evs = leanSessionEvents(evs)
515
+ }
516
+ var firstSeq uint64
517
+ if len(evs) > 0 {
518
+ firstSeq = mapSeqOf(evs[0])
519
+ }
520
+ writeJSON(w, http.StatusOK, map[string]any{
521
+ "session": id, "count": len(evs), "firstSeq": firstSeq,
522
+ "nextBefore": nextBefore, "more": more, "events": evs,
523
+ })
524
+ return
525
+ }
526
+ if lean {
527
+ /* 走「after=0 的增量读」而不是 ReadSessionLogLean:两者输出完全一致,但前者
528
+ 会顺带算出文件最大 seq 并留下尾部游标(见 session.tailMark)—— 客户端紧接着
529
+ 用 /events?after=<最后一条 seq> 轮询时就能直接 seek 到尾部,否则首次轮询还要
530
+ 把整份文件再全量扫一遍(长会话 = 又赔 2~3s)。 */
531
+ evs, _, err = session.ReadSessionLogAfterLean(dir, id, 0, mapSeqOf, sessionEventMapFromJSON)
532
+ } else {
533
+ evs, err = session.ReadSessionLog(dir, id, sessionEventMapFromJSON)
534
+ }
319
535
  if err != nil {
320
536
  writeErr(w, http.StatusInternalServerError, "读历史失败:"+err.Error())
321
537
  return
322
538
  }
539
+ if lean {
540
+ evs = leanSessionEvents(evs)
541
+ }
323
542
  writeJSON(w, http.StatusOK, map[string]any{"session": id, "events": evs})
324
543
  }
325
544
 
545
+ // queryLean 解析 `?lean=1`:与 CLI 的 `--session-log-lean` 同一口径(**只增参数**,
546
+ // 不传时输出与旧版逐字段一致 —— 所以不需要升 contractVersion)。
547
+ //
548
+ // 为什么 HTTP 侧也要这个开关:移动端(client-ui-mobile)与 PC Web UI 都直接吃
549
+ // 这两个接口,CLI 侧的裁剪对它们无效。长会话里 snapshot 是二次方膨胀
550
+ // (实测 9.4 万条 thinking = 283MB 快照),不裁就要在手机上解析整份 300MB+ JSON。
551
+ func queryLean(r *http.Request) bool {
552
+ switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("lean"))) {
553
+ case "1", "true", "yes", "on":
554
+ return true
555
+ }
556
+ return false
557
+ }
558
+
559
+ // leanToolOutMax 是 lean 输出里单条 tool_result 的字符上限。
560
+ //
561
+ // 与对话内核的 QA_TOOL_OUT_MAX(client-ui/shared/conv/09-stream-apply.js)取同一个值:
562
+ // 渲染侧本来就把工具输出截到这里,再多传的字节**一个都不会显示**。实测一条 338MB
563
+ // 的会话,404 条 tool_result 全文占 26.9MB(最长单条 1.6MB),截断后整包从 51MB 降到 25MB。
564
+ // 按 rune 截断(不是字节),避免把中文/emoji 切成半个字符。
565
+ const leanToolOutMax = 12000
566
+
567
+ // leanSessionEvents 输出侧做三件事:裁快照、截超长工具输出、合并相邻思考增量。
568
+ //
569
+ // 与 CLI 的 stripSnapshots 同源:回放只用增量 `text` 自己拼,全仓没有消费方读
570
+ // 事件级 snapshot(见 cli/sessionlog.go 的说明)。删键而不是置空串:CLI 侧
571
+ // 该字段是 omitempty,两边输出才能继续「逐字段对齐」(见 CLIENT-CONTRACT 3.4)。
572
+ //
573
+ // 相邻 thinking 合并:日志是按流式增量落盘的(实测一条长会话 95838 条事件里
574
+ // 94015 条是 ~3 字符的思考分片)。客户端的思考区本来就是「一段连续 thinking
575
+ // 一块」,逐条喂只是把同一段文本追加九万次 —— 手机浏览器实测这一步独占 16s
576
+ // (整段回放 19.6s 的长任务)。合并后同一条会话只剩 2020 条事件,画面完全等价。
577
+ // ⚠️ 只合并**相邻**的(中间夹了别的 kind 就断开),且保留末条的 seq/at/status:
578
+ // 客户端游标按 seq 前进,取首条会让它下次重拉同一批。
579
+ // ⚠️ 只改输出,磁盘上的 jsonl 原样保留。
580
+ func leanSessionEvents(evs []map[string]any) []map[string]any {
581
+ if len(evs) == 0 {
582
+ return evs // 空日志保持 nil / 原样:/messages 的 null 语义已有测试钉着
583
+ }
584
+ out := make([]map[string]any, 0, len(evs))
585
+ for i := 0; i < len(evs); i++ {
586
+ e := evs[i]
587
+ delete(e, "snapshot")
588
+ kind, _ := e["kind"].(string)
589
+ if kind == "thinking" {
590
+ j := i + 1
591
+ for j < len(evs) && eventKind(evs[j]) == "thinking" {
592
+ delete(evs[j], "snapshot")
593
+ j++
594
+ }
595
+ if j > i+1 {
596
+ var b strings.Builder
597
+ for k := i; k < j; k++ {
598
+ s, _ := evs[k]["text"].(string)
599
+ b.WriteString(s)
600
+ }
601
+ merged := make(map[string]any, len(e)+1)
602
+ for k, v := range e {
603
+ merged[k] = v
604
+ }
605
+ last := evs[j-1]
606
+ for _, k := range []string{"seq", "at", "item_revision", "status"} {
607
+ if v, ok := last[k]; ok {
608
+ merged[k] = v
609
+ }
610
+ }
611
+ merged["text"] = b.String()
612
+ e = merged
613
+ i = j - 1
614
+ }
615
+ out = append(out, e)
616
+ continue
617
+ }
618
+ if kind == "tool_result" {
619
+ if text, ok := e["text"].(string); ok && len(text) > leanToolOutMax {
620
+ e["text"] = truncRunes(text, leanToolOutMax)
621
+ }
622
+ }
623
+ out = append(out, e)
624
+ }
625
+ return out
626
+ }
627
+
628
+ func eventKind(e map[string]any) string {
629
+ k, _ := e["kind"].(string)
630
+ return k
631
+ }
632
+
633
+ // truncRunes 把 s 截到最多 max 个字符(rune),max <= 0 视为不截。
634
+ //
635
+ // 为什么不写 []rune(s)[:max]:工具输出可能是几 MB,整串转 []rune 要按 4 字节/字符
636
+ // 重新分配一遍;这里只在 rune 边界上前进到第 max 个就返回,代价与截断长度成正比。
637
+ func truncRunes(s string, max int) string {
638
+ if max <= 0 {
639
+ return s
640
+ }
641
+ n := 0
642
+ for i := range s {
643
+ if n == max {
644
+ return s[:i]
645
+ }
646
+ n++
647
+ }
648
+ return s
649
+ }
650
+
326
651
  // sessionEventMapFromJSON 把 jsonl 的一行解析成 map(plugin 不依赖 cli 的 schema)。
327
652
  func sessionEventMapFromJSON(line []byte) (map[string]any, error) {
328
653
  var m map[string]any
@@ -332,6 +657,197 @@ func sessionEventMapFromJSON(line []byte) (map[string]any, error) {
332
657
  return m, nil
333
658
  }
334
659
 
660
+ // ── 会话标题 ─────────────────────────────────────────────────────────────
661
+ // core 的会话标题原本是从首条提示词派生的(prompt_head),没有「可改标题」的存储;
662
+ // 覆盖表由本插件维护(落 ~/.magic-agent/session-titles.json),由 GET /desk/titles
663
+ // 传给客户端合并(不改动 core 的 --sessions 输出)。
664
+ //
665
+ // 2026-10-15:归纳口径收进 core(internal/session/title.go 的 Summarize)之后,
666
+ // **存储也跟着搬到 core** —— 两份实现意味着两份磁盘格式,迟早不一致。
667
+ // 这里改成薄转发:读写都走 session 包,落盘路径不变(用户已有的重命名结果不丢)。
668
+ // 另:--sessions 现在直接给 `title` 字段(core 已合并「覆盖 → prompt_head」),
669
+ // 客户端可以只读它;/desk/titles 保留给仍在用覆盖表合并的老客户端。
670
+ func titleStorePath() (string, error) { return session.TitleStorePath() }
671
+ func loadTitles() map[string]string { return session.Titles() }
672
+ func saveTitles(m map[string]string) error {
673
+ // 整表覆盖的语义在 core 侧是「按 id 逐条改」,这里转一遍:
674
+ // 先把差异算出来,只写真实变化的那几条,避免无谓的写盘与并发覆盖。
675
+ want := map[string]string{}
676
+ for k, v := range m {
677
+ if cur := session.TitleFor(k); cur != v {
678
+ want[k] = v
679
+ }
680
+ }
681
+ for k := range session.Titles() {
682
+ if _, keep := m[k]; !keep {
683
+ want[k] = "" // 空标题 = 清除覆盖
684
+ }
685
+ }
686
+ var firstErr error
687
+ for k, v := range want {
688
+ if err := session.SetTitle(k, v); err != nil && firstErr == nil {
689
+ firstErr = err
690
+ }
691
+ }
692
+ return firstErr
693
+ }
694
+
695
+ // handleDirs GET /desk/dirs?path=<abs>:列某目录下的子目录(默认 = 工作空间根 = 插件 cwd 的父目录)。
696
+ // 只允许浏览 $HOME 之内的路径 —— 令牌只是本机访问控制,别把它当成整个文件系统的浏览器。
697
+ func (s *Service) handleDirs(w http.ResponseWriter, r *http.Request) {
698
+ if r.Method != http.MethodGet {
699
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
700
+ return
701
+ }
702
+ home, _ := os.UserHomeDir()
703
+ p := strings.TrimSpace(r.URL.Query().Get("path"))
704
+ if p == "" {
705
+ wd, _ := os.Getwd()
706
+ p = filepath.Dir(wd)
707
+ }
708
+ abs, err := filepath.Abs(p)
709
+ if err != nil {
710
+ writeErr(w, http.StatusBadRequest, "路径不合法:"+err.Error())
711
+ return
712
+ }
713
+ if home != "" && abs != home && !strings.HasPrefix(abs, home+string(filepath.Separator)) {
714
+ writeErr(w, http.StatusForbidden, "只允许浏览家目录内的路径")
715
+ return
716
+ }
717
+ ents, err := os.ReadDir(abs)
718
+ if err != nil {
719
+ writeErr(w, http.StatusBadRequest, "读取目录失败:"+err.Error())
720
+ return
721
+ }
722
+ dirs := make([]map[string]any, 0, len(ents))
723
+ for _, e := range ents {
724
+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
725
+ continue
726
+ }
727
+ dirs = append(dirs, map[string]any{"name": e.Name(), "path": filepath.Join(abs, e.Name())})
728
+ }
729
+ sort.Slice(dirs, func(i, j int) bool { return dirs[i]["name"].(string) < dirs[j]["name"].(string) })
730
+ writeJSON(w, http.StatusOK, map[string]any{"path": abs, "dirs": dirs})
731
+ }
732
+
733
+ // handleTitles GET /desk/titles:返回全部 sid→标题 覆盖(客户端拿去与 /desk/sessions 合并)。
734
+ func (s *Service) handleTitles(w http.ResponseWriter, r *http.Request) {
735
+ if r.Method != http.MethodGet {
736
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
737
+ return
738
+ }
739
+ writeJSON(w, http.StatusOK, loadTitles())
740
+ }
741
+
742
+ // handleSessionTitle PUT /desk/session/{id}/title:设置/清除会话标题(空标题 = 清除覆盖)。
743
+ func (s *Service) handleSessionTitle(w http.ResponseWriter, r *http.Request, id string) {
744
+ var body struct {
745
+ Title string `json:"title"`
746
+ }
747
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
748
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
749
+ return
750
+ }
751
+ if err := session.SetTitle(id, body.Title); err != nil {
752
+ writeErr(w, http.StatusInternalServerError, "保存标题失败:"+err.Error())
753
+ return
754
+ }
755
+ t := strings.TrimSpace(body.Title)
756
+ if t == "" {
757
+ t = session.TitleFor(id)
758
+ }
759
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": t})
760
+ }
761
+
762
+ // handleSessionTitleSummarize POST /desk/session/{id}/title/summarize:
763
+ // 让 **core** 归纳这条会话的标题(`magic-agent --summarize-title <id>`)。
764
+ //
765
+ // 为什么要有这条(用户 2026-10-15:「应该 magic-agent 统一归纳作为会话标题」):
766
+ // 归纳此前只有业务项目在做,于是同一会话在 PC 与移动端显示不同标题,且**从本插件
767
+ // (/desk/ask)发起的会话根本没人替它归纳** —— 只能回落 prompt_head 首问原文。
768
+ // 口径收进 core 之后,两个客户端都调这一条,同一句话必然得到同一个标题。
769
+ //
770
+ // 输入不给原文、由 core 从会话历史自己取:调用方不再各截各的(截断长度不同 → 归纳不同)。
771
+ //
772
+ // 幂等:core 侧已有覆盖就直接返回(cached=true),不重复调 LLM。force=true 才重来一次。
773
+ func (s *Service) handleSessionTitleSummarize(w http.ResponseWriter, r *http.Request, id string) {
774
+ if r.Method != http.MethodPost && r.Method != http.MethodPut {
775
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
776
+ return
777
+ }
778
+ var body struct {
779
+ Force bool `json:"force"`
780
+ }
781
+ // 允许空体:客户端只想「帮我归纳」时不必凑 JSON。
782
+ if r.Body != nil {
783
+ _ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body)
784
+ }
785
+ ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
786
+ defer cancel()
787
+ args := []string{"--summarize-title", id}
788
+ if body.Force {
789
+ args = append(args, "--title-force")
790
+ }
791
+ out, err := exec.CommandContext(ctx, s.opts.MagicAgent, args...).Output()
792
+ if err != nil {
793
+ // core 的失败原因写在 stderr,原样带回:只回「归纳失败」调用方没法排障。
794
+ msg := strings.TrimSpace(string(out))
795
+ if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
796
+ msg = strings.TrimSpace(string(ee.Stderr))
797
+ }
798
+ if msg == "" {
799
+ msg = err.Error()
800
+ }
801
+ writeErr(w, http.StatusBadGateway, "归纳标题失败:"+truncateForMsg(msg))
802
+ return
803
+ }
804
+ var res session.SummaryResult
805
+ if err := json.Unmarshal(out, &res); err != nil {
806
+ // core 换了输出形状就如实报错,不要静默给空标题(那正是「两端不一致」的温床)。
807
+ writeErr(w, http.StatusBadGateway, "core 回的归纳结果不是 JSON:"+truncateForMsg(string(out)))
808
+ return
809
+ }
810
+ writeJSON(w, http.StatusOK, res)
811
+ }
812
+
813
+ // truncateForMsg 报错文案截断(core 的 stderr 可能带整段提示词回显)。
814
+ func truncateForMsg(s string) string {
815
+ s = strings.TrimSpace(s)
816
+ const cap = 200
817
+ r := []rune(s)
818
+ if len(r) <= cap {
819
+ return s
820
+ }
821
+ return string(r[:cap]) + "…"
822
+ }
823
+
824
+ // handleSessionDelete DELETE /desk/session/{id}:删掉一个会话 —— 登记表记录(可多条 run)
825
+ // 与落盘的事件日志。id 可以是 session_id 或 run_id。
826
+ //
827
+ // 为什么两个都删:列表(/desk/sessions)读登记表、历史(/messages)读日志文件 —— 只删一个会
828
+ // 留下「列表有、点开空」或「看不见、磁盘还占着」的错位状态。
829
+ //
830
+ // ⚠️ 日志文件**两个名字都要试**(2026-10-18 修):文件按 session_id 落是**理想**,而引擎
831
+ // 不调 OnSessionID 时(codex 这一脉)它整轮都叫 `<run_id>.jsonl`(见 session.ResolveLogID
832
+ // 那段注释)。只删 `<session_id>.jsonl` 的话,客户端按 session_id 删(列表页的主键就是它)
833
+ // 会留下一个 175KB 的孤儿日志 —— 列表里没了、磁盘上还占着。
834
+ // 2026-10-19:实现挪进 `session.Delete`(internal/session/delete.go)—— CLI 新增的
835
+ // `--delete-session` 走的是同一份,两条路不再各写一遍(此前只有本接口能做真删除,
836
+ // 于是桌面端只能在自己本地藏起来,出现「PC 删了、移动端还在」)。
837
+ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
838
+ id, action, ok := parseSessionPath(r.URL.Path)
839
+ if !ok || action != "" {
840
+ writeErr(w, http.StatusBadRequest, "session id 不能为空,且不接受子路径")
841
+ return
842
+ }
843
+ res, err := session.Delete(id)
844
+ if err != nil {
845
+ writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
846
+ return
847
+ }
848
+ writeJSON(w, http.StatusOK, map[string]any{"id": res.ID, "recordsRemoved": res.RecordsRemoved})
849
+ }
850
+
335
851
  // parseSessionPath 从 `/desk/session/{id}/{action}` 拆出 id 与 action,并做**路径安全校验**。
336
852
  //
337
853
  // 返回 ok=false 的唯一情形是 id 为空或等于 `.` / `..`(调用方回 400)。
@@ -398,7 +914,17 @@ func (s *Service) handleSessionEvents(w http.ResponseWriter, r *http.Request, id
398
914
  writeErr(w, http.StatusInternalServerError, "读历史失败:找不到历史目录:"+derr.Error())
399
915
  return
400
916
  }
401
- evs, maxSeq, err := session.ReadSessionLogAfter(dir, id, after, mapSeqOf, sessionEventMapFromJSON)
917
+ // 同 /messages:run_id 先翻成 session_id(正在跑的会话只有 run_id 可用)。
918
+ id = session.ResolveLogID(dir, id)
919
+ lean := queryLean(r)
920
+ var evs []map[string]any
921
+ var maxSeq uint64
922
+ var err error
923
+ if lean {
924
+ evs, maxSeq, err = session.ReadSessionLogAfterLean(dir, id, after, mapSeqOf, sessionEventMapFromJSON)
925
+ } else {
926
+ evs, maxSeq, err = session.ReadSessionLogAfter(dir, id, after, mapSeqOf, sessionEventMapFromJSON)
927
+ }
402
928
  if err != nil {
403
929
  writeErr(w, http.StatusInternalServerError, "读历史失败:"+err.Error())
404
930
  return
@@ -408,6 +934,9 @@ func (s *Service) handleSessionEvents(w http.ResponseWriter, r *http.Request, id
408
934
  if evs == nil {
409
935
  evs = []map[string]any{}
410
936
  }
937
+ if lean {
938
+ evs = leanSessionEvents(evs)
939
+ }
411
940
  writeJSON(w, http.StatusOK, map[string]any{
412
941
  "session": id,
413
942
  "events": evs,
@@ -526,11 +1055,35 @@ func (s *Service) handleAsks(w http.ResponseWriter, r *http.Request) {
526
1055
  ID string `json:"id"`
527
1056
  Engine string `json:"engine"`
528
1057
  Started string `json:"started"`
1058
+ // Session 这一轮挂在哪个会话上(2026-10-20 打开运行中会话续传)。
1059
+ // 客户端打开某个会话时用它认出「这条会话还有一轮在跑」,从而接回实时流。
1060
+ // 空 = 新会话(core 还没吐出 session_id 之前也可能为空)。
1061
+ Session string `json:"session,omitempty"`
1062
+ // Run 本次运行的 run_id(core 的 ready 行带出)。新会话首轮引擎可能整轮都不吐
1063
+ // session_id,此时登记表(/desk/sessions)里这条会话只有 run_id —— 客户端
1064
+ // 拿它与会话 id 比对才能认领这一轮。
1065
+ Run string `json:"run,omitempty"`
529
1066
  }
530
1067
  s.mu.Lock()
531
1068
  items := make([]item, 0, len(s.runs))
532
1069
  for _, run := range s.runs {
533
- items = append(items, item{ID: run.id, Engine: run.engine, Started: run.started.Format(time.RFC3339)})
1070
+ // 跑完的 ask 会在 s.runs 里多留 askRetention(供断线续传补拉终态),
1071
+ // 但「正在跑」列表不能把它们算进去,否则客户端会以为还有任务在排队。
1072
+ run.mu.Lock()
1073
+ ended := run.ended
1074
+ sid := run.session
1075
+ rid := run.run
1076
+ run.mu.Unlock()
1077
+ if ended {
1078
+ continue
1079
+ }
1080
+ items = append(items, item{
1081
+ ID: run.id,
1082
+ Engine: run.engine,
1083
+ Started: run.started.Format(time.RFC3339),
1084
+ Session: sid,
1085
+ Run: rid,
1086
+ })
534
1087
  }
535
1088
  s.mu.Unlock()
536
1089
  writeJSON(w, http.StatusOK, items)
@@ -561,6 +1114,12 @@ type askRequest struct {
561
1114
  // codebuddy-ai);所以这里只在客户端真的要选档位时才加这个参数,不要替 core 猜默认值,
562
1115
  // 否则会把「显式指定」与「沿用默认」两种语义混成一种。
563
1116
  Permission string `json:"permission,omitempty"`
1117
+ // Reasoning 思考档位(off | minimal | low | medium | high | xhigh | max),透传给
1118
+ // core 的 --reasoning(2026-10-20 只增字段)。空 = 不传 —— core 自己的默认档是 max
1119
+ // (开着),所以「关闭思考」必须**显式传 off**,省略等于开着 + 最高档。
1120
+ // ⚠️ 只有 /desk/engines 里 reasoning=true 的引擎才该传;老版本 core 不认识该 flag
1121
+ // (exit 2),客户端据该字段缺失来决定不下发。
1122
+ Reasoning string `json:"reasoning,omitempty"`
564
1123
  // Session 续接一个已有会话(传 /desk/sessions 里的 session_id 或 run_id)。
565
1124
  // 空 = 新会话。
566
1125
  Session string `json:"session,omitempty"`
@@ -568,9 +1127,17 @@ type askRequest struct {
568
1127
  // Idle 常驻会话空闲收工时长;默认 0 = 本轮结束就收工(客户端驱动的轮次语义)。
569
1128
  // >0 才留常驻窗口(此时 answer 命令在轮次结束后仍可用)。
570
1129
  Idle string `json:"idle,omitempty"`
1130
+ // Attachments 附件**本机绝对路径**清单(移动端先 POST /desk/upload 拿到路径再回传)。
1131
+ // 逐条透传给 core 的 -a/--attach;core 侧再校验存在性与体积。附件是本地路径,
1132
+ // 只有 core 所在的这台机器认识它(跨机路径无意义)。
1133
+ Attachments []string `json:"attachments,omitempty"`
1134
+ // Client 发起方自报的客户端类型:mobile | pc。
1135
+ // 为什么不能只看 Origin:移动端是**加载 /m/ 页面**(与桌面同源)跑起来的,
1136
+ // 同源请求分不出手机还是电脑,所以由客户端显式声明(见 shared/transport-http.js)。
1137
+ Client string `json:"client,omitempty"`
571
1138
  }
572
1139
 
573
- // askRun 一次正在跑的 ask。
1140
+ // askRun 一次 ask(跑完的还会短时保留,供断线续传补拉终态)。
574
1141
  type askRun struct {
575
1142
  id string
576
1143
  engine string
@@ -578,8 +1145,145 @@ type askRun struct {
578
1145
  cmd *exec.Cmd
579
1146
  stdin io.WriteCloser
580
1147
  done chan struct{}
581
- code int
582
1148
  stderr *cappedBuffer
1149
+
1150
+ /* session 这一轮所属的会话 id(供 /desk/asks 暴露,客户端据此把「打开中的会话」
1151
+ 接回实时流)。建 run 时取请求里的 session;请求没带(新会话)就等第一条带
1152
+ session_id 的事件到达时补写(见 appendEvent)。受 mu 保护。 */
1153
+ session string
1154
+
1155
+ /* run core 本次运行的 run_id(ready 行里带出)。引擎不吐 session_id 时,
1156
+ 登记表里这条会话只有 run_id,/desk/asks 要把两者都给客户端才能认领。受 mu 保护。 */
1157
+ run string
1158
+
1159
+ /* 事件回放缓冲(2026-10-19 断线续传):core 的每一行原样留一份,客户端拿
1160
+ `GET /desk/ask/{id}/events?after=<seq>` 按 seq 增量补拉。
1161
+ 为什么不直接复用会话日志(`/desk/session/{id}/events`):
1162
+ 日志里的 seq 是**跨轮接号**的(文件级),而 SSE 实时流里的 seq 是**每轮从 1**
1163
+ 重来(进程级)—— 客户端手里的游标与日志对不上,续读会把整轮事件重新发一遍
1164
+ 或者一条都不发。这份缓冲存的正是**与实时流同一套 seq** 的行。 */
1165
+ mu sync.Mutex
1166
+ events []askEvent
1167
+ seq uint64 // 已缓冲的最大 seq(事件自带 seq 缺失时它也不会退)
1168
+ dropped bool // 缓冲超限裁过旧事件(游标落在窗口外 ⇒ snapshotRequired)
1169
+ ended bool
1170
+ code int
1171
+ }
1172
+
1173
+ // askEvent 缓冲里的一条:seq 用于增量过滤,data 是 core 原样的 NDJSON 行。
1174
+ type askEvent struct {
1175
+ seq uint64
1176
+ data []byte
1177
+ }
1178
+
1179
+ // askBufMaxEvents / askBufMaxBytes 一轮事件缓冲的上限(约 16MB)。
1180
+ //
1181
+ // 缓冲只活在「任务运行中 + 结束后保留期」这一段,正常一轮几十 KB 到几 MB;
1182
+ // 上限是给失控输出兜底的 —— 超过就丢最旧的并置 dropped,宁可少补中间几行,
1183
+ // 也不能让一个跑飞的引擎把网关进程撑爆。
1184
+ const (
1185
+ askBufMaxEvents = 20000
1186
+ askBufMaxBytes = 16 << 20
1187
+ // askRetention 跑完的 ask 保留多久(断线重连 / 补拉终态的时间窗)。
1188
+ askRetention = 10 * time.Minute
1189
+ )
1190
+
1191
+ // appendPrompt 把本轮的用户提问合成一条 `type:"user"` 事件塞进回放缓冲(2026-10-20)。
1192
+ //
1193
+ // 为什么必须在网关合成:core 的 stdout 事件流**从不发 user 事件**(`kind:"user"` 只写进
1194
+ // 会话日志),而断线续传补的是这份 ask 缓冲 —— 用户自己刚问的那句在补出来的画面上一个字
1195
+ // 都没有(真实报障:PC 端发了新追问,移动端打开只剩「生成中」)。
1196
+ //
1197
+ // 三条约束:
1198
+ // - **只进缓冲、不进 SSE 直播流**(见 handleAsk):实时那条路径上客户端已经自己画过
1199
+ // 用户气泡了,再中继一次就是两个气泡;
1200
+ // - seq 固定 0:core 的流 seq 每轮从 1 起,0 不会与任何真实事件撞号,也不会推进客户端
1201
+ // 游标(handleAskEvents 里 `after == 0` 才放行它);
1202
+ // - `prompt_id` 是客户端去重的键:core 首行到达前客户端会反复以 after=0 轮询,没有它
1203
+ // 同一句提问会被画很多遍(见 client-ui/shared/conversation.js 的 render)。
1204
+ func (r *askRun) appendPrompt(text string, attachments []string) {
1205
+ atts := make([]map[string]string, 0, len(attachments))
1206
+ for _, p := range attachments {
1207
+ if p = strings.TrimSpace(p); p != "" {
1208
+ atts = append(atts, map[string]string{"path": p})
1209
+ }
1210
+ }
1211
+ ev := map[string]any{
1212
+ "type": "user",
1213
+ "text": text,
1214
+ "prompt_id": "u1",
1215
+ "at": r.started.UTC().Format(time.RFC3339Nano),
1216
+ }
1217
+ if len(atts) > 0 {
1218
+ ev["attachments"] = atts
1219
+ }
1220
+ b, err := json.Marshal(ev)
1221
+ if err != nil {
1222
+ return
1223
+ }
1224
+ r.mu.Lock()
1225
+ defer r.mu.Unlock()
1226
+ // 必须排在本轮所有 core 事件之前:数组顺序就是续传画面的顺序。调用点在 cmd.Start
1227
+ // 之前(读 stdout 的 goroutine 还没起来),所以直接追加头一条即可。
1228
+ r.events = append(r.events, askEvent{seq: 0, data: b})
1229
+ }
1230
+
1231
+ // appendEvent 缓冲一行 core 事件(在**读 stdout 的 goroutine**里调用)。
1232
+ func (r *askRun) appendEvent(line string) {
1233
+ seq, sid, rid := eventMetaOf(line)
1234
+ r.mu.Lock()
1235
+ defer r.mu.Unlock()
1236
+ if sid != "" && r.session == "" {
1237
+ // 新会话的 run 建的时候还不知道 session id,靠第一条带 session_id 的事件补上。
1238
+ r.session = sid
1239
+ }
1240
+ if rid != "" && r.run == "" {
1241
+ r.run = rid
1242
+ }
1243
+ if seq == 0 {
1244
+ // core --events 的每一行都带 seq;真出现不带的行(历史版本 / 畸形行)就按
1245
+ // 「到达序号」兜一个 —— 只用于续传过滤,不写回事件本身。
1246
+ seq = r.seq + 1
1247
+ }
1248
+ if seq > r.seq {
1249
+ r.seq = seq
1250
+ }
1251
+ r.events = append(r.events, askEvent{seq: seq, data: []byte(line)})
1252
+ size := 0
1253
+ for _, e := range r.events {
1254
+ size += len(e.data)
1255
+ }
1256
+ drop := 0
1257
+ for drop < len(r.events) && (len(r.events)-drop > askBufMaxEvents || size > askBufMaxBytes) {
1258
+ size -= len(r.events[drop].data)
1259
+ drop++
1260
+ }
1261
+ if drop > 0 {
1262
+ r.events = append([]askEvent(nil), r.events[drop:]...)
1263
+ r.dropped = true
1264
+ }
1265
+ }
1266
+
1267
+ // eventMetaOf 从一行 NDJSON 里取 seq / session_id / run_id(取不到就是零值)。
1268
+ // 同一行只 unmarshal 一次:这几个字段都要用,拆成多次解析纯属浪费。
1269
+ func eventMetaOf(line string) (uint64, string, string) {
1270
+ var probe struct {
1271
+ Seq uint64 `json:"seq"`
1272
+ SessionID string `json:"session_id"`
1273
+ RunID string `json:"run_id"`
1274
+ }
1275
+ if json.Unmarshal([]byte(line), &probe) != nil {
1276
+ return 0, "", ""
1277
+ }
1278
+ return probe.Seq, probe.SessionID, probe.RunID
1279
+ }
1280
+
1281
+ // finish 标记任务结束并记录退出码(读 stdout 的 goroutine 在 cmd.Wait 之后调用)。
1282
+ func (r *askRun) finish(code int) {
1283
+ r.mu.Lock()
1284
+ r.ended = true
1285
+ r.code = code
1286
+ r.mu.Unlock()
583
1287
  }
584
1288
 
585
1289
  // handleAsk 发起一轮并**把 core 的 NDJSON 事件原样中继**给调用方(SSE)。
@@ -618,6 +1322,11 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
618
1322
  "-e", engine,
619
1323
  "--stream", "--events", "--control",
620
1324
  "--idle", idle,
1325
+ // 客户端要「列出/停止会话」与「点开看历史」,所以两个存档开关都恒开
1326
+ // (core 侧默认都关,见 --save-registry / --save-history):
1327
+ // · 少了 --save-registry → 会话不出现在列表、不能按 id 追加;
1328
+ // · 少了 --save-history → has_log=false,进详情页历史空白(静默失败)。
1329
+ "--save-registry", "--save-history",
621
1330
  }
622
1331
  if req.Model != "" {
623
1332
  args = append(args, "-m", req.Model)
@@ -629,18 +1338,63 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
629
1338
  if p := strings.TrimSpace(req.Permission); p != "" {
630
1339
  args = append(args, "--permission", p)
631
1340
  }
1341
+ // 思考档位:同样只在显式给了才传(空 = core 的默认 max)。
1342
+ // off 也是显式值,不能因为「看起来像关闭」就省略 —— 省略恰好是「开着 + max」。
1343
+ if r := strings.TrimSpace(req.Reasoning); r != "" {
1344
+ args = append(args, "--reasoning", r)
1345
+ }
632
1346
  if req.Session != "" {
633
1347
  args = append(args, "--session", req.Session)
634
1348
  }
635
1349
  if req.NoThinking {
636
1350
  args = append(args, "--no-thinking")
637
1351
  }
1352
+ // 附件:逐个 -a/--attach。这里先做与 core 同口径的校验(普通文件 + 32MB 上限),
1353
+ // 免得把非法路径丢给 core 再让它以 usage error 退出(那时错误更难定位)。
1354
+ for _, a := range req.Attachments {
1355
+ p := strings.TrimSpace(a)
1356
+ if p == "" {
1357
+ continue
1358
+ }
1359
+ if abs, err := filepath.Abs(p); err == nil {
1360
+ p = abs
1361
+ }
1362
+ st, err := os.Stat(p)
1363
+ if err != nil {
1364
+ writeErr(w, http.StatusBadRequest, "附件不可访问:"+p)
1365
+ return
1366
+ }
1367
+ if !st.Mode().IsRegular() {
1368
+ writeErr(w, http.StatusBadRequest, "附件不是普通文件:"+p)
1369
+ return
1370
+ }
1371
+ if st.Size() > maxUploadBytes {
1372
+ writeErr(w, http.StatusBadRequest, fmt.Sprintf("附件超过 %dMB:%s", maxUploadBytes>>20, p))
1373
+ return
1374
+ }
1375
+ args = append(args, "-a", p)
1376
+ }
638
1377
  args = append(args, "-p", req.Prompt)
639
1378
 
640
- ctx, cancel := context.WithCancel(r.Context())
641
- defer cancel()
1379
+ /* 子进程**不绑 HTTP 请求的生命周期**(2026-10-19):此前用 r.Context(),手机端 SSE
1380
+ 一断(锁屏 / 切后台 / WebView 回收 / 页面刷新)就会 SIGKILL core —— core 来不及写
1381
+ 终态,登记表里那条会话就永远停在 running:界面显示「一直不结束」,移动端的串行门
1382
+ (同空间有 running 就排队)还会被它永久挡住,新任务发不出去。
1383
+ 客户端断连只意味着「没人看直播了」,不是「取消任务」;任务只应由显式控制
1384
+ (/desk/ask/{id}/control 的 stop)或自然结束来终止。
1385
+ WithoutCancel 保留请求里的值(便于排障),但不继承取消信号。
1386
+ 也**不能**在这里 WithCancel + defer cancel:handler 断连就会返回,defer 一执行
1387
+ 就等于把仍在跑的任务杀掉 —— 那正是要修的行为。 */
1388
+ ctx := context.WithoutCancel(r.Context())
642
1389
 
643
1390
  cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
1391
+ /* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
1392
+ · EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
1393
+ · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**,
1394
+ 不把直接跑 CLI / 别的应用的任务混进来(用户 2026-10-12)。 */
1395
+ cmd.Env = append(os.Environ(),
1396
+ session.EnvSource+"="+requestSource(req.Client, r.Header.Get("Origin")),
1397
+ session.EnvClient+"="+s.opts.Profile.AppID())
644
1398
  stdin, err := cmd.StdinPipe()
645
1399
  if err != nil {
646
1400
  writeErr(w, http.StatusInternalServerError, "stdin pipe: "+err.Error())
@@ -659,8 +1413,12 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
659
1413
  stdin: stdin,
660
1414
  done: make(chan struct{}),
661
1415
  stderr: newCappedBuffer(8 << 10),
1416
+ session: strings.TrimSpace(req.Session),
662
1417
  }
663
1418
  cmd.Stderr = run.stderr
1419
+ /* 用户这条提问先落进回放缓冲(不进 SSE 直播流):core 的事件流里没有 user 事件,
1420
+ 断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
1421
+ run.appendPrompt(req.Prompt, req.Attachments)
664
1422
  if err := cmd.Start(); err != nil {
665
1423
  writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
666
1424
  return
@@ -668,11 +1426,8 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
668
1426
  s.mu.Lock()
669
1427
  s.runs[run.id] = run
670
1428
  s.mu.Unlock()
671
- defer func() {
672
- s.mu.Lock()
673
- delete(s.runs, run.id)
674
- s.mu.Unlock()
675
- }()
1429
+ // 注意:这里**不** defer 摘除 s.runs —— 断连后 handler 会先返回,任务仍在跑;
1430
+ // 摘除由后台读 stdout 的 goroutine 在 core 真正退出时做(见下)。
676
1431
 
677
1432
  // SSE 头必须在**第一行事件之前**发出,否则客户端拿到的是 application/json。
678
1433
  w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
@@ -691,43 +1446,114 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
691
1446
  }
692
1447
  send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent})
693
1448
 
694
- sc := bufio.NewScanner(stdout)
695
- sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
696
- for sc.Scan() {
697
- line := strings.TrimSpace(sc.Text())
698
- if line == "" {
699
- continue
1449
+ /* SSE 心跳(2026-10-19 真实报障:手机端 17.7s 就「连接中断」,而 core 在首段输出前
1450
+ 静默了约 27s —— 期间一个字节都没写,移动网络 / WebView 会把这条连接当成死连接掐掉,
1451
+ 服务端任务其实一直跑到了 turn_end)。
1452
+ 注释帧(以 ':' 开头、没有 data 行)按 SSE 规范会被客户端忽略,
1453
+ 本仓库的 transport-http.js 解析器也只认 data 行,因此安全。 */
1454
+ ping := time.NewTicker(10 * time.Second)
1455
+ defer ping.Stop()
1456
+
1457
+ /* 读 stdout 与写 SSE 分开成两个 goroutine(2026-10-19):
1458
+ 客户端断连时 handler 立即返回,但**后台必须继续把 core 的 stdout 抽干** ——
1459
+ 否则管道写满后 core 会阻塞在写事件上,任务反而被卡死。
1460
+ handler 走人之前关掉 detached,后台就改为「只 drain、不再入队」。 */
1461
+ lines := make(chan string, 64)
1462
+ detached := make(chan struct{})
1463
+ finished := make(chan int, 1)
1464
+ go func() {
1465
+ sc := bufio.NewScanner(stdout)
1466
+ sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
1467
+ gone := false
1468
+ for sc.Scan() {
1469
+ line := strings.TrimSpace(sc.Text())
1470
+ if line == "" {
1471
+ continue
1472
+ }
1473
+ // 先入回放缓冲,再判断有没有人在看直播:断连之后客户端要靠这份缓冲
1474
+ // 按 seq 续传,漏掉「gone 之后」的行就等于把断点之后的内容丢了。
1475
+ run.appendEvent(line)
1476
+ if gone {
1477
+ continue // 没人看了:只 drain,避免 core 写满管道
1478
+ }
1479
+ select {
1480
+ case lines <- line:
1481
+ case <-detached:
1482
+ gone = true
1483
+ }
700
1484
  }
701
- // 原样中继:核心事件流自己就是 NDJSON,不再包一层。
702
- fmt.Fprintf(w, "data: %s\n\n", line)
703
- if flusher != nil {
704
- flusher.Flush()
1485
+ close(lines)
1486
+ werr := cmd.Wait()
1487
+ code := 0
1488
+ if werr != nil {
1489
+ var ee *exec.ExitError
1490
+ if errors.As(werr, &ee) {
1491
+ code = ee.ExitCode()
1492
+ } else {
1493
+ code = -1
1494
+ }
705
1495
  }
706
- }
707
- werr := cmd.Wait()
708
- code := 0
709
- if werr != nil {
710
- var ee *exec.ExitError
711
- if errors.As(werr, &ee) {
712
- code = ee.ExitCode()
713
- } else {
714
- code = -1
1496
+ run.finish(code)
1497
+ close(run.done)
1498
+ /* 任务结束后**不立刻摘除**:客户端可能在断线重连后在补拉终态
1499
+ (`GET /desk/ask/{id}/events`),留 askRetention 的时间窗。
1500
+ 保留期内若同一个 id 被重新登记(不可能,id 唯一)也不覆盖。
1501
+ handleAsks 会按 ended 过滤,所以这个「僵尸条目」不会出现在运行中列表。 */
1502
+ time.AfterFunc(askRetention, func() {
1503
+ s.mu.Lock()
1504
+ if cur := s.runs[run.id]; cur == run {
1505
+ delete(s.runs, run.id)
1506
+ }
1507
+ s.mu.Unlock()
1508
+ })
1509
+ finished <- code
1510
+ }()
1511
+ // 断连后后台继续跑完任务;handler 只负责把已有事件送出去。
1512
+ defer close(detached)
1513
+
1514
+ for {
1515
+ select {
1516
+ case line, ok := <-lines:
1517
+ if !ok {
1518
+ lines = nil // 等 finished(stdout 已读尽,core 正在退出)
1519
+ continue
1520
+ }
1521
+ // 原样中继:核心事件流自己就是 NDJSON,不再包一层。
1522
+ fmt.Fprintf(w, "data: %s\n\n", line)
1523
+ if flusher != nil {
1524
+ flusher.Flush()
1525
+ }
1526
+ case code := <-finished:
1527
+ send("end", map[string]any{"id": run.id, "code": code, "stderr": run.stderr.String()})
1528
+ return
1529
+ case <-ping.C:
1530
+ // 心跳:只养连接,不下发业务数据(解析端会忽略没有 data 行的帧)。
1531
+ fmt.Fprint(w, ": ping\n\n")
1532
+ if flusher != nil {
1533
+ flusher.Flush()
1534
+ }
1535
+ case <-r.Context().Done():
1536
+ return
715
1537
  }
716
1538
  }
717
- run.code = code
718
- close(run.done)
719
- send("end", map[string]any{"id": run.id, "code": code, "stderr": run.stderr.String()})
720
1539
  }
721
1540
 
722
- // handleAskSub 处理 `/desk/ask/{id}/control`:把一条控制命令写进 core 的 stdin。
723
- //
724
- // 这就是「手机回审批」的服务侧路径 —— 命令格式与 core 的 --control 契约一致,
725
- // 插件不解释 op,只转发(解析权在 core)。
1541
+ // handleAskSub 处理 `/desk/ask/{id}/{action}`:
1542
+ // - `control`:把一条控制命令写进 core 的 stdin;
1543
+ // - `events` :按 seq 增量补拉本轮事件(断线续传 / 补拉终态)。
726
1544
  func (s *Service) handleAskSub(w http.ResponseWriter, r *http.Request) {
727
1545
  rest := strings.TrimPrefix(r.URL.Path, "/desk/ask/")
728
1546
  parts := strings.Split(strings.Trim(rest, "/"), "/")
729
- if len(parts) != 2 || parts[1] != "control" {
730
- writeErr(w, http.StatusNotFound, "用法:POST /desk/ask/{id}/control")
1547
+ if len(parts) != 2 {
1548
+ writeErr(w, http.StatusNotFound, "用法:POST /desk/ask/{id}/control 或 GET /desk/ask/{id}/events?after=<seq>")
1549
+ return
1550
+ }
1551
+ if parts[1] == "events" {
1552
+ s.handleAskEvents(w, r, parts[0])
1553
+ return
1554
+ }
1555
+ if parts[1] != "control" {
1556
+ writeErr(w, http.StatusNotFound, "用法:POST /desk/ask/{id}/control 或 GET /desk/ask/{id}/events?after=<seq>")
731
1557
  return
732
1558
  }
733
1559
  if r.Method != http.MethodPost {
@@ -741,6 +1567,13 @@ func (s *Service) handleAskSub(w http.ResponseWriter, r *http.Request) {
741
1567
  writeErr(w, http.StatusNotFound, "没有这个 ask(可能已结束):"+parts[0])
742
1568
  return
743
1569
  }
1570
+ run.mu.Lock()
1571
+ runEnded := run.ended
1572
+ run.mu.Unlock()
1573
+ if runEnded {
1574
+ writeErr(w, http.StatusConflict, "本轮已结束,不能再下发控制:"+parts[0])
1575
+ return
1576
+ }
744
1577
  var cmd struct {
745
1578
  Op string `json:"op"`
746
1579
  Text string `json:"text,omitempty"`
@@ -762,6 +1595,83 @@ func (s *Service) handleAskSub(w http.ResponseWriter, r *http.Request) {
762
1595
  writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "op": cmd.Op, "id": run.id})
763
1596
  }
764
1597
 
1598
+ /*
1599
+ handleAskEvents GET /desk/ask/{id}/events?after=<seq>
1600
+
1601
+ 断线续传的服务端一半(2026-10-19):客户端手里只有 SSE 流里的 seq(每轮从 1 起),
1602
+ 不能用会话日志的跨轮 seq 续读,所以这里回放的是**与实时流同一套 seq** 的原始事件行。
1603
+
1604
+ 返回:
1605
+ - events :seq > after 的事件对象(core 原样的 NDJSON,已解成 JSON)
1606
+ - lastSeq :本轮已缓冲的最大 seq(客户端下次带它来)
1607
+ - done / code :本轮是否结束 + 退出码(结束前 code 无意义)
1608
+ - stderr :结束后的 stderr(仅 done 时返回)
1609
+ - snapshotRequired:游标落在被裁掉的窗口之前,客户端应重建本轮视角(清空重放)
1610
+ */
1611
+ func (s *Service) handleAskEvents(w http.ResponseWriter, r *http.Request, id string) {
1612
+ if r.Method != http.MethodGet {
1613
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
1614
+ return
1615
+ }
1616
+ var after uint64
1617
+ if v := strings.TrimSpace(r.URL.Query().Get("after")); v != "" {
1618
+ n, err := strconv.ParseUint(v, 10, 64)
1619
+ if err != nil {
1620
+ writeErr(w, http.StatusBadRequest, "after 必须是十进制序号:"+v)
1621
+ return
1622
+ }
1623
+ after = n
1624
+ }
1625
+ s.mu.Lock()
1626
+ run := s.runs[id]
1627
+ s.mu.Unlock()
1628
+ if run == nil {
1629
+ writeErr(w, http.StatusNotFound, "没有这个 ask(已过保留期):"+id)
1630
+ return
1631
+ }
1632
+ run.mu.Lock()
1633
+ events := make([]json.RawMessage, 0, len(run.events))
1634
+ for _, e := range run.events {
1635
+ /* seq == 0 是网关合成的「用户提问」(见 appendPrompt):它不是 core 流里的序号,
1636
+ 固定为 0、只在**从头补**(after == 0)时给一次 —— 客户端拿 prompt_id 去重。 */
1637
+ if e.seq > after || (e.seq == 0 && after == 0) {
1638
+ events = append(events, json.RawMessage(append([]byte(nil), e.data...)))
1639
+ }
1640
+ }
1641
+ lastSeq := run.seq
1642
+ dropped := run.dropped
1643
+ firstSeq := uint64(0)
1644
+ for _, e := range run.events {
1645
+ // 跳过合成的提问事件(seq=0):裁剪窗口的判据只认 core 事件的真实序号。
1646
+ if e.seq > 0 {
1647
+ firstSeq = e.seq
1648
+ break
1649
+ }
1650
+ }
1651
+ done := run.ended
1652
+ code := run.code
1653
+ run.mu.Unlock()
1654
+
1655
+ // after 落在裁剪窗口之前(或缓冲裁过且 after 追不上首条)⇒ 客户端缺了中间事件,
1656
+ // 只能重建本轮画面;after >= firstSeq-1 表示断点仍在窗口内,可以安全增量。
1657
+ snapshotRequired := dropped && len(events) > 0 && (firstSeq == 0 || after+1 < firstSeq)
1658
+
1659
+ resp := map[string]any{
1660
+ "id": id,
1661
+ "events": events,
1662
+ "count": len(events),
1663
+ "after": after,
1664
+ "lastSeq": lastSeq,
1665
+ "done": done,
1666
+ "snapshotRequired": snapshotRequired,
1667
+ }
1668
+ if done {
1669
+ resp["code"] = code
1670
+ resp["stderr"] = run.stderr.String()
1671
+ }
1672
+ writeJSON(w, http.StatusOK, resp)
1673
+ }
1674
+
765
1675
  // handleModulesStatic 业务模块的静态资源:**先**项目的模块目录(profile 的 ui.modulesDir),
766
1676
  // 再退回插件的 UI 目录(--ui-dir)。
767
1677
  //
@@ -836,6 +1746,63 @@ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
836
1746
  http.ServeFile(w, r, full)
837
1747
  }
838
1748
 
1749
+ // mobileRoot 解析移动端界面目录:显式 --mobile-dir 优先,否则取与 --ui-dir 同级的 client-ui-mobile。
1750
+ func (s *Service) mobileRoot() string {
1751
+ if d := strings.TrimSpace(s.opts.MobileRoot); d != "" {
1752
+ return d
1753
+ }
1754
+ if d := strings.TrimSpace(s.opts.UIRoot); d != "" {
1755
+ return filepath.Join(filepath.Dir(d), "client-ui-mobile")
1756
+ }
1757
+ return ""
1758
+ }
1759
+
1760
+ // handleMobileUI 远程移动端界面。
1761
+ //
1762
+ // /m/ → <mobile>/index.html(页面本身)
1763
+ // /m/assets/* → <mobile>/assets/*(字体等)
1764
+ // /m/shared/* → <ui-dir>/shared/*(公共对话组件唯一来源,不在移动端目录里再存一份)
1765
+ //
1766
+ // 这样 APK 壳只要加载 <base>/m/?token=… 就能跑最新界面,改界面无需重打 APK。
1767
+ func (s *Service) handleMobileUI(w http.ResponseWriter, r *http.Request) {
1768
+ mobile := s.mobileRoot()
1769
+ if mobile == "" {
1770
+ writeErr(w, http.StatusNotFound, "没有配置移动端界面目录(--mobile-dir)")
1771
+ return
1772
+ }
1773
+ rel := strings.TrimPrefix(r.URL.Path, "/m")
1774
+ if rel == "" || rel == "/" {
1775
+ rel = "/index.html"
1776
+ }
1777
+ var root string
1778
+ if strings.HasPrefix(rel, "/shared/") && strings.TrimSpace(s.opts.UIRoot) != "" {
1779
+ root, _ = filepath.Abs(s.opts.UIRoot) // shared 复用 --ui-dir 下的那一份
1780
+ } else {
1781
+ root, _ = filepath.Abs(mobile)
1782
+ }
1783
+ full := filepath.Join(root, filepath.Clean("/"+rel))
1784
+ if !strings.HasPrefix(full, root) {
1785
+ writeErr(w, http.StatusForbidden, "路径越界")
1786
+ return
1787
+ }
1788
+ f, err := os.Open(full)
1789
+ if err != nil {
1790
+ writeErr(w, http.StatusNotFound, "移动端资源不存在:"+rel)
1791
+ return
1792
+ }
1793
+ defer f.Close()
1794
+ st, err := f.Stat()
1795
+ if err != nil || st.IsDir() {
1796
+ writeErr(w, http.StatusNotFound, "移动端资源不存在:"+rel)
1797
+ return
1798
+ }
1799
+ // 远程界面必须「改完立即生效」:不设这个头,WebView 会按启发式缓存把旧 HTML 留着,
1800
+ // 桌面端改了界面、手机却还是老样子(今天就踩了一次)。no-cache = 每次回源校验。
1801
+ w.Header().Set("Cache-Control", "no-cache")
1802
+ // 与 /modules/ 同理:不用 ServeFile,避免 `/index.html` 触发 301 到 `/`。
1803
+ http.ServeContent(w, r, st.Name(), st.ModTime(), f)
1804
+ }
1805
+
839
1806
  func (s *Service) nextID() string {
840
1807
  s.mu.Lock()
841
1808
  defer s.mu.Unlock()
@@ -877,6 +1844,455 @@ func hasStr(list []string, want string) bool {
877
1844
  return false
878
1845
  }
879
1846
 
1847
+ // maxUploadBytes 上传/附件单文件大小上限,与 core 的 maxAttachBytes(32MB)同口径。
1848
+ const maxUploadBytes = 32 << 20
1849
+
1850
+ // handleUpload 收移动端上传的附件,落到 core 所在机器的临时目录,返回其**绝对路径**。
1851
+ // 路径随后由 /desk/ask 的 attachments 透传给 core 的 -a/--attach —— 附件是本地路径,
1852
+ // 只有 core 这台机器认识它,所以远端文件必须先上传过来。
1853
+ func (s *Service) handleUpload(w http.ResponseWriter, r *http.Request) {
1854
+ if r.Method != http.MethodPost {
1855
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
1856
+ return
1857
+ }
1858
+ r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
1859
+ if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
1860
+ writeErr(w, http.StatusBadRequest, "解析上传失败:"+err.Error())
1861
+ return
1862
+ }
1863
+ file, hdr, err := r.FormFile("file")
1864
+ if err != nil {
1865
+ writeErr(w, http.StatusBadRequest, "缺少 file 字段:"+err.Error())
1866
+ return
1867
+ }
1868
+ defer file.Close()
1869
+ dir := filepath.Join(os.TempDir(), "magic-client-uploads")
1870
+ if err := os.MkdirAll(dir, 0o700); err != nil {
1871
+ writeErr(w, http.StatusInternalServerError, "创建上传目录失败:"+err.Error())
1872
+ return
1873
+ }
1874
+ name := sanitizeUploadName(hdr.Filename)
1875
+ final := filepath.Join(dir, strconv.FormatInt(time.Now().UnixNano(), 10)+"-"+name)
1876
+ out, err := os.OpenFile(final, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
1877
+ if err != nil {
1878
+ writeErr(w, http.StatusInternalServerError, "写入失败:"+err.Error())
1879
+ return
1880
+ }
1881
+ n, err := io.Copy(out, file)
1882
+ cerr := out.Close()
1883
+ if err != nil || cerr != nil {
1884
+ _ = os.Remove(final)
1885
+ writeErr(w, http.StatusInternalServerError, "写入失败")
1886
+ return
1887
+ }
1888
+ writeJSON(w, http.StatusOK, map[string]any{
1889
+ "path": final, "name": name, "size": n, "mime": hdr.Header.Get("Content-Type"),
1890
+ // url 供 <img src> 回读(缩略图 / 历史回看):相对路径,客户端拼上自己的 base + token。
1891
+ "url": "/desk/uploads/" + url.PathEscape(filepath.Base(final)),
1892
+ })
1893
+ }
1894
+
1895
+ // handleUploadGet GET /desk/uploads/{name}:回读已上传的附件(缩略图 / 历史回看)。
1896
+ // 只允许读上传目录内、文件名不含路径分隔的单层文件 —— 防目录穿越。
1897
+ // <img> 带不了 X-Magic-Token 自定义头,所以这条路径靠 guard 支持的 ?token= 过鉴权。
1898
+ func (s *Service) handleUploadGet(w http.ResponseWriter, r *http.Request) {
1899
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
1900
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
1901
+ return
1902
+ }
1903
+ name := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/desk/uploads/"))
1904
+ if name == "" || name == "." || name == ".." ||
1905
+ strings.ContainsAny(name, `/\`) || filepath.Base(name) != name {
1906
+ writeErr(w, http.StatusBadRequest, "非法文件名")
1907
+ return
1908
+ }
1909
+ full := filepath.Join(os.TempDir(), "magic-client-uploads", name)
1910
+ f, err := os.Open(full)
1911
+ if err != nil {
1912
+ writeErr(w, http.StatusNotFound, "文件不存在")
1913
+ return
1914
+ }
1915
+ defer f.Close()
1916
+ st, err := f.Stat()
1917
+ if err != nil || st.IsDir() {
1918
+ writeErr(w, http.StatusNotFound, "文件不存在")
1919
+ return
1920
+ }
1921
+ // ?w=<px>:移动端慢网下按需缩放(仅图片;解不了就原样回)。
1922
+ if tw := thumbWidth(r); tw > 0 {
1923
+ if data, mime, ok := scaledImageData(full, st, tw); ok {
1924
+ w.Header().Set("Content-Type", mime)
1925
+ w.Header().Set("Cache-Control", "private, max-age=86400")
1926
+ http.ServeContent(w, r, st.Name(), st.ModTime(), bytes.NewReader(data))
1927
+ return
1928
+ }
1929
+ }
1930
+ w.Header().Set("Cache-Control", "private, max-age=86400")
1931
+ http.ServeContent(w, r, st.Name(), st.ModTime(), f)
1932
+ }
1933
+
1934
+ // handleMediaFile GET /desk/media?path=<abs>:按**绝对路径**回读一个本机媒体文件。
1935
+ // 用途:对话里「被操作过的文件」出缩略图、会话产物出预览(图 / 视频 / 音频)—— 那些路径
1936
+ // 在 core 这台机器上,且不在上传目录里,所以不能走 /desk/uploads。
1937
+ //
1938
+ // ⚠️ 这是一条「按路径取文件」的通道,所以**只放行媒体**(内容嗅探为主、扩展名兜底)
1939
+ // 且限体积,避免变成任意文件读取。<img>/<video> 带不了 X-Magic-Token 自定义头,故支持 ?token=。
1940
+ // http.ServeContent 自带 Range:视频可以拖进度。
1941
+ func (s *Service) handleMediaFile(w http.ResponseWriter, r *http.Request) {
1942
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
1943
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
1944
+ return
1945
+ }
1946
+ p := strings.TrimSpace(r.URL.Query().Get("path"))
1947
+ if p == "" || !filepath.IsAbs(p) {
1948
+ writeErr(w, http.StatusBadRequest, "path 必须是绝对路径")
1949
+ return
1950
+ }
1951
+ f, err := os.Open(filepath.Clean(p))
1952
+ if err != nil {
1953
+ writeErr(w, http.StatusNotFound, "文件不存在")
1954
+ return
1955
+ }
1956
+ defer f.Close()
1957
+ st, err := f.Stat()
1958
+ if err != nil || !st.Mode().IsRegular() {
1959
+ writeErr(w, http.StatusNotFound, "文件不存在")
1960
+ return
1961
+ }
1962
+ if st.Size() > maxUploadBytes {
1963
+ writeErr(w, http.StatusRequestEntityTooLarge, "文件过大")
1964
+ return
1965
+ }
1966
+ head := make([]byte, 512)
1967
+ n, _ := f.Read(head)
1968
+ if !isMediaFile(head[:n], p) {
1969
+ writeErr(w, http.StatusUnsupportedMediaType, "仅支持图片 / 视频 / 音频")
1970
+ return
1971
+ }
1972
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
1973
+ writeErr(w, http.StatusInternalServerError, "读取失败:"+err.Error())
1974
+ return
1975
+ }
1976
+ // ?w=<px>:移动端慢网下按需缩放(仅图片;解不了就原样回)。
1977
+ if tw := thumbWidth(r); tw > 0 {
1978
+ if data, mime, ok := scaledImageData(filepath.Clean(p), st, tw); ok {
1979
+ w.Header().Set("Content-Type", mime)
1980
+ w.Header().Set("Cache-Control", "private, max-age=86400")
1981
+ http.ServeContent(w, r, filepath.Base(p), st.ModTime(), bytes.NewReader(data))
1982
+ return
1983
+ }
1984
+ }
1985
+ w.Header().Set("Cache-Control", "private, max-age=300")
1986
+ http.ServeContent(w, r, filepath.Base(p), st.ModTime(), f)
1987
+ }
1988
+
1989
+ // isMediaFile 媒体判定:按内容嗅探为主(http.DetectContentType),扩展名兜底。
1990
+ // 与前端 qaArtKind 的后缀表同口径(shared/conv/19-attachments.js)。
1991
+ func isMediaFile(head []byte, path string) bool {
1992
+ ct := http.DetectContentType(head)
1993
+ if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") {
1994
+ return true
1995
+ }
1996
+ switch strings.ToLower(filepath.Ext(path)) {
1997
+ case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
1998
+ ".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi",
1999
+ ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac":
2000
+ return true
2001
+ }
2002
+ return false
2003
+ }
2004
+
2005
+ // maxProbePaths 一次路径探测的上限(内核一批最多 12 条,这里留余量)。
2006
+ const maxProbePaths = 50
2007
+
2008
+ // handlePathProbe POST /desk/path-probe:一批本机路径的**存在性探测**。
2009
+ // 用途:内核画「会话产物」时只画真的落在盘上的路径(正文里提到的 app.js、远端 URL 不画)。
2010
+ // 入参 {base, paths[]}:相对路径按 base(会话的 workspace)解析;`~/` 展开成 home,
2011
+ // `file://` 前缀按本机路径处理。出参 {ok:true, items:[{path, abs, exists, size}]}。
2012
+ // ⚠️ 只回「有没有 / 在哪 / 多大」,**类型判定不在这里**(渲染层按后缀分档,见 qaArtKind)。
2013
+ func (s *Service) handlePathProbe(w http.ResponseWriter, r *http.Request) {
2014
+ if r.Method != http.MethodPost {
2015
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
2016
+ return
2017
+ }
2018
+ var req struct {
2019
+ Base string `json:"base"`
2020
+ Paths []string `json:"paths"`
2021
+ }
2022
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
2023
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
2024
+ return
2025
+ }
2026
+ base := strings.TrimSpace(req.Base)
2027
+ paths := req.Paths
2028
+ if len(paths) > maxProbePaths {
2029
+ paths = paths[:maxProbePaths]
2030
+ }
2031
+ items := make([]map[string]any, 0, len(paths))
2032
+ for _, raw := range paths {
2033
+ p := strings.TrimSpace(raw)
2034
+ if p == "" {
2035
+ continue
2036
+ }
2037
+ p = strings.TrimPrefix(p, "file://")
2038
+ if p == "~" || strings.HasPrefix(p, "~/") {
2039
+ if home, err := os.UserHomeDir(); err == nil {
2040
+ p = filepath.Join(home, strings.TrimPrefix(p, "~/"))
2041
+ }
2042
+ }
2043
+ it := map[string]any{"path": raw, "abs": "", "exists": false, "size": int64(0)}
2044
+ if !filepath.IsAbs(p) {
2045
+ if base == "" {
2046
+ items = append(items, it)
2047
+ continue
2048
+ }
2049
+ p = filepath.Join(base, p)
2050
+ }
2051
+ p = filepath.Clean(p)
2052
+ it["abs"] = p
2053
+ if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
2054
+ it["exists"] = true
2055
+ it["size"] = st.Size()
2056
+ }
2057
+ items = append(items, it)
2058
+ }
2059
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
2060
+ }
2061
+
2062
+ // maxReadBytes 单次文本读取上限(与桌面端 desk:path-read 同口径:512KB)。
2063
+ const maxReadBytes = 512 << 10
2064
+
2065
+ // handlePathRead POST /desk/path-read:按路径读一个**文本**文件给移动端阅读浮层。
2066
+ // 出参与桌面端 desk:path-read 一致(统一 200 + ok 标志,只有方法 / JSON 错才 4xx):
2067
+ //
2068
+ // {ok:true, text, size, path} | {ok:true, tooBig:true, size} |
2069
+ // {ok:true, binary:true, size} | {ok:false, error}
2070
+ //
2071
+ // 入参 {path, base}:相对路径按 base(会话 workspace)解析,`~/` 展开,`file://` 前缀剥离。
2072
+ // ⚠️ 只读文本:前 8000 字节含 NUL 就判二进制,超 512KB 判 tooBig —— 不给任意大文件通道。
2073
+ func (s *Service) handlePathRead(w http.ResponseWriter, r *http.Request) {
2074
+ if r.Method != http.MethodPost {
2075
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
2076
+ return
2077
+ }
2078
+ var req struct {
2079
+ Path string `json:"path"`
2080
+ Base string `json:"base"`
2081
+ }
2082
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
2083
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
2084
+ return
2085
+ }
2086
+ p := strings.TrimSpace(req.Path)
2087
+ if p == "" {
2088
+ writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "path 为空"})
2089
+ return
2090
+ }
2091
+ p = strings.TrimPrefix(p, "file://")
2092
+ if p == "~" || strings.HasPrefix(p, "~/") {
2093
+ if home, err := os.UserHomeDir(); err == nil {
2094
+ p = filepath.Join(home, strings.TrimPrefix(p, "~/"))
2095
+ }
2096
+ }
2097
+ if !filepath.IsAbs(p) {
2098
+ base := strings.TrimSpace(req.Base)
2099
+ if base == "" {
2100
+ writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "相对路径缺少 base"})
2101
+ return
2102
+ }
2103
+ p = filepath.Join(base, p)
2104
+ }
2105
+ p = filepath.Clean(p)
2106
+ st, err := os.Stat(p)
2107
+ if err != nil || !st.Mode().IsRegular() {
2108
+ writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "文件不存在:" + p})
2109
+ return
2110
+ }
2111
+ size := st.Size()
2112
+ if size > maxReadBytes {
2113
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "tooBig": true, "size": size})
2114
+ return
2115
+ }
2116
+ data, err := os.ReadFile(p)
2117
+ if err != nil {
2118
+ writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "读取失败:" + err.Error()})
2119
+ return
2120
+ }
2121
+ head := data
2122
+ if len(head) > 8000 {
2123
+ head = head[:8000]
2124
+ }
2125
+ if bytes.IndexByte(head, 0) >= 0 {
2126
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "binary": true, "size": size})
2127
+ return
2128
+ }
2129
+ writeJSON(w, http.StatusOK, map[string]any{
2130
+ "ok": true, "text": string(data), "size": size, "path": p,
2131
+ })
2132
+ }
2133
+
2134
+ // 缩略图参数与缓存上限:移动端慢网下按需缩放,图省流量。
2135
+ const (
2136
+ minThumbPx = 64
2137
+ maxThumbPx = 4096
2138
+ maxThumbCache = 64 << 20
2139
+ )
2140
+
2141
+ type thumbEntry struct {
2142
+ data []byte
2143
+ mime string
2144
+ }
2145
+
2146
+ var thumbCache = struct {
2147
+ sync.Mutex
2148
+ total int
2149
+ items map[string]thumbEntry
2150
+ }{items: map[string]thumbEntry{}}
2151
+
2152
+ // thumbWidth 解析 ?w=<px>;0 = 不缩放(没带 / 非法 / 越界都当没带)。
2153
+ func thumbWidth(r *http.Request) int {
2154
+ raw := strings.TrimSpace(r.URL.Query().Get("w"))
2155
+ if raw == "" {
2156
+ return 0
2157
+ }
2158
+ n, err := strconv.Atoi(raw)
2159
+ if err != nil || n < minThumbPx || n > maxThumbPx {
2160
+ return 0
2161
+ }
2162
+ return n
2163
+ }
2164
+
2165
+ // scaledImageData 按目标宽度等比缩放并重编码;解不了 / 本来就更小 / 缩完更大 → ok=false 走原文件。
2166
+ // 缓存 key 带 mtime+size:文件改了自然失效。
2167
+ func scaledImageData(p string, st os.FileInfo, targetW int) (data []byte, mime string, ok bool) {
2168
+ key := p + "|" + strconv.FormatInt(st.ModTime().UnixNano(), 10) + "|" +
2169
+ strconv.FormatInt(st.Size(), 10) + "|" + strconv.Itoa(targetW)
2170
+ thumbCache.Lock()
2171
+ if e, hit := thumbCache.items[key]; hit {
2172
+ thumbCache.Unlock()
2173
+ return e.data, e.mime, true
2174
+ }
2175
+ thumbCache.Unlock()
2176
+
2177
+ f, err := os.Open(p)
2178
+ if err != nil {
2179
+ return nil, "", false
2180
+ }
2181
+ defer f.Close()
2182
+ src, _, err := image.Decode(f) // gif/jpeg/png 已注册;webp 等解不了就走原文件
2183
+ if err != nil {
2184
+ return nil, "", false
2185
+ }
2186
+ b := src.Bounds()
2187
+ sw, sh := b.Dx(), b.Dy()
2188
+ if sw <= targetW || sh <= 0 {
2189
+ return nil, "", false
2190
+ }
2191
+ th := int(math.Round(float64(sh) * float64(targetW) / float64(sw)))
2192
+ if th < 1 {
2193
+ th = 1
2194
+ }
2195
+ dst := scaleBilinear(src, targetW, th)
2196
+ var buf bytes.Buffer
2197
+ if isOpaque(dst) {
2198
+ mime = "image/jpeg"
2199
+ if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 78}); err != nil {
2200
+ return nil, "", false
2201
+ }
2202
+ } else {
2203
+ mime = "image/png"
2204
+ if err := png.Encode(&buf, dst); err != nil {
2205
+ return nil, "", false
2206
+ }
2207
+ }
2208
+ out := buf.Bytes()
2209
+ if int64(len(out)) >= st.Size() {
2210
+ return nil, "", false // 缩完反而更大(小图 / 纯色图),没意义
2211
+ }
2212
+ thumbCache.Lock()
2213
+ if thumbCache.total+len(out) > maxThumbCache {
2214
+ thumbCache.items = map[string]thumbEntry{}
2215
+ thumbCache.total = 0
2216
+ }
2217
+ thumbCache.items[key] = thumbEntry{data: out, mime: mime}
2218
+ thumbCache.total += len(out)
2219
+ thumbCache.Unlock()
2220
+ return out, mime, true
2221
+ }
2222
+
2223
+ // scaleBilinear 标准库实现的等比双线性缩放(不引 golang.org/x/image)。
2224
+ // 先转成 NRGBA 再插值:避开每像素 At() 接口调用,大图也够快。
2225
+ func scaleBilinear(src image.Image, w, h int) *image.NRGBA {
2226
+ sb := src.Bounds()
2227
+ sw, sh := sb.Dx(), sb.Dy()
2228
+ flat := image.NewNRGBA(image.Rect(0, 0, sw, sh))
2229
+ draw.Draw(flat, flat.Bounds(), src, sb.Min, draw.Src)
2230
+ dst := image.NewNRGBA(image.Rect(0, 0, w, h))
2231
+ sp, ss := flat.Pix, flat.Stride
2232
+ dp, ds := dst.Pix, dst.Stride
2233
+ for y := 0; y < h; y++ {
2234
+ fy := (float64(y)+0.5)*float64(sh)/float64(h) - 0.5
2235
+ y0 := int(math.Floor(fy))
2236
+ ty := fy - float64(y0)
2237
+ if y0 < 0 {
2238
+ y0, ty = 0, 0
2239
+ }
2240
+ if y0 > sh-1 {
2241
+ y0, ty = sh-1, 0
2242
+ }
2243
+ y1 := y0 + 1
2244
+ if y1 > sh-1 {
2245
+ y1 = sh - 1
2246
+ }
2247
+ for x := 0; x < w; x++ {
2248
+ fx := (float64(x)+0.5)*float64(sw)/float64(w) - 0.5
2249
+ x0 := int(math.Floor(fx))
2250
+ tx := fx - float64(x0)
2251
+ if x0 < 0 {
2252
+ x0, tx = 0, 0
2253
+ }
2254
+ if x0 > sw-1 {
2255
+ x0, tx = sw-1, 0
2256
+ }
2257
+ x1 := x0 + 1
2258
+ if x1 > sw-1 {
2259
+ x1 = sw - 1
2260
+ }
2261
+ o00 := y0*ss + x0*4
2262
+ o10 := y0*ss + x1*4
2263
+ o01 := y1*ss + x0*4
2264
+ o11 := y1*ss + x1*4
2265
+ d := y*ds + x*4
2266
+ for c := 0; c < 4; c++ {
2267
+ top := float64(sp[o00+c])*(1-tx) + float64(sp[o10+c])*tx
2268
+ bot := float64(sp[o01+c])*(1-tx) + float64(sp[o11+c])*tx
2269
+ dp[d+c] = uint8(top*(1-ty) + bot*ty + 0.5)
2270
+ }
2271
+ }
2272
+ }
2273
+ return dst
2274
+ }
2275
+
2276
+ // isOpaque 全不透明 → 可以编成 JPEG(比 PNG 小得多)。
2277
+ func isOpaque(img *image.NRGBA) bool {
2278
+ p := img.Pix
2279
+ for i := 3; i < len(p); i += 4 {
2280
+ if p[i] != 0xff {
2281
+ return false
2282
+ }
2283
+ }
2284
+ return true
2285
+ }
2286
+
2287
+ // sanitizeUploadName 只取文件名部分并去掉路径分隔,避免目录穿越。
2288
+ func sanitizeUploadName(name string) string {
2289
+ name = filepath.Base(strings.TrimSpace(name))
2290
+ if name == "" || name == "." || name == string(filepath.Separator) {
2291
+ name = "upload"
2292
+ }
2293
+ return strings.ReplaceAll(name, string(filepath.Separator), "_")
2294
+ }
2295
+
880
2296
  func writeJSON(w http.ResponseWriter, code int, v any) {
881
2297
  w.Header().Set("Content-Type", "application/json; charset=utf-8")
882
2298
  w.WriteHeader(code)