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
@@ -15,6 +15,7 @@ import (
15
15
  "io"
16
16
  "log"
17
17
  "math"
18
+ "net"
18
19
  "net/http"
19
20
  "net/http/httputil"
20
21
  "net/url"
@@ -62,6 +63,12 @@ type Options struct {
62
63
  // Token 访问令牌;空 = 启动时随机生成。
63
64
  Token string
64
65
  Logger *log.Logger
66
+ // APKPath 可选:要分发给移动端的 APK 路径。空 = 自动在包内 npm/apk/ 下找
67
+ // (见 apk.go)。显式给路径主要给测试与特殊布局用。
68
+ APKPath string
69
+ // Lan 是否绑到了局域网(--lan)。移动端「环境」菜单要靠它拿到手机可用的入口,
70
+ // 否则用户只能手抄 IP。绑回环时不报(报了也连不上)。
71
+ Lan bool
65
72
  // BrowseUA 内置浏览器中转使用的 User-Agent;空 = 用内置的 Android Chrome。
66
73
  // 为什么可配:UA 会决定站点给哪一版页面。中国大陆出口 + 移动 UA 时,Google 会跳到
67
74
  // http://www.google.cn/m(该路径本身就是 404);换成桌面 UA 则走香港跳转、可正常打开。
@@ -88,6 +95,16 @@ type Service struct {
88
95
 
89
96
  // 需求自动归纳的调度状态(每工作区一个防抖计时器,见 requirements.go)。
90
97
  reqAuto reqAuto
98
+
99
+ // 随包发布的移动端 APK(空 = 这个包里没带)。见 apk.go。
100
+ apkPath string
101
+
102
+ /* 公网入口探测(2026-10-22):隧道是**壳**起的,插件不知道它的域名,而 quick tunnel
103
+ 每次启动都换域名。这里缓存 probePublicURL 的结果,让 /desk/health 不必每次都扫端口。 */
104
+ pubMu sync.Mutex
105
+ pubURL string
106
+ pubExp time.Time
107
+ pubBusy bool
91
108
  }
92
109
 
93
110
  // Contract core `--contract` 里本插件关心的部分。
@@ -134,6 +151,14 @@ func New(opts Options) (*Service, error) {
134
151
  opts.MagicAgent = "magic-agent"
135
152
  }
136
153
  s := &Service{opts: opts, log: opts.Logger, runs: map[string]*askRun{}}
154
+ // 移动端 APK:显式路径优先;否则在包内 npm/apk/ 找(随 npm 包发布,见 apk.go)。
155
+ s.apkPath = strings.TrimSpace(opts.APKPath)
156
+ if s.apkPath == "" {
157
+ s.apkPath = findBundledAPK()
158
+ }
159
+ if s.apkPath != "" {
160
+ s.log.Printf("移动端 APK:%s(v%s)", s.apkPath, apkVersion(s.apkPath))
161
+ }
137
162
 
138
163
  // 项目后端的反代一次建好:profile 合法就给代理,非法就启动即失败
139
164
  //(地址写错比运行时 502 更容易查——启动日志里说清楚)。
@@ -178,6 +203,10 @@ func (s *Service) Handler() http.Handler {
178
203
  mux := http.NewServeMux()
179
204
  mux.HandleFunc("/desk/health", s.guard(s.handleHealth))
180
205
  mux.HandleFunc("/desk/contract", s.guard(s.handleContract))
206
+ // 移动端 APK:版本信息 + 安装包本身(版本号跟 npm 包走,见 apk.go)。
207
+ mux.HandleFunc("/desk/app", s.guard(s.handleAppInfo))
208
+ // 系统浏览器/下载器带不了自定义头,这个接口靠 guard 支持的 ?token= 过鉴权。
209
+ mux.HandleFunc("/desk/app.apk", s.guard(s.handleAppAPK))
181
210
  mux.HandleFunc("/desk/engines", s.guard(s.handleEngines))
182
211
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
183
212
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
@@ -192,6 +221,12 @@ func (s *Service) Handler() http.Handler {
192
221
  // 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
193
222
  mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
194
223
  mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
224
+ // 下一轮快捷回复(2026-10-23):一轮结束后生成 0-3 条「用户下一句」。
225
+ // 旁路调用 core 的 --suggest-followups(不续接会话),界面点击即发。
226
+ mux.HandleFunc("/desk/suggest", s.guard(s.handleSuggest))
227
+ // 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
228
+ // 不再各存各的 localStorage(见 prefs.go)。
229
+ mux.HandleFunc("/desk/prefs", s.guard(s.handlePrefs))
195
230
  // 需求聚合(2026-10-22,见 requirements.go):读清单 / 手动整理一轮。
196
231
  // 自动归纳不在这里 —— 它是每轮 /desk/ask 收尾后的防抖动作。
197
232
  mux.HandleFunc("/desk/requirements", s.guard(s.handleRequirements))
@@ -362,12 +397,171 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
362
397
  // 而且 HEAD 请求在 WebView 里常被记成 ERR_ABORTED 的失败请求(导航抢跑),
363
398
  // 排查时是纯噪音。探活本来就要打这一枪,顺手带一句最省。
364
399
  "remoteUI": s.hasMobileUI(),
400
+ // 手机可用的局域网入口(2026-10-22,只增字段,不升 contractVersion)。
401
+ // 移动端「环境」菜单据此自动给出「公司远程」那条,用户不必手抄 IP。
402
+ // 只给地址、不带令牌:页面自己握着令牌,别把门锁写进响应体里。
403
+ // 没绑局域网 / 拿不到网卡地址 → null。
404
+ "lanUrls": s.lanURLs(r.Host),
405
+ // 公网入口(2026-10-22):本机 cloudflared quick tunnel 的域名,空 = 没开公网。
406
+ // 移动端「公司远程」优先用这条(不在同一个 Wi-Fi 也能连);拿不到才退回 lanUrls。
407
+ "publicUrl": s.publicURL(),
365
408
  // 团队角色清单(2026-10-22):移动端「团队模式」页要按角色配路由,
366
409
  // 角色名不能由客户端硬编码。老 core 没有 teamRoles → null,客户端回落内置清单。
367
410
  "teamRoles": s.contract.TeamRoles,
368
411
  })
369
412
  }
370
413
 
414
+ // lanURLs 服务端自己上报的手机可用入口(形如 http://10.0.0.5:58849/)。
415
+ //
416
+ // 端口从请求的 Host 里取:服务可能绑 0 端口由系统分配,这里不必再存一份。
417
+ // 只返回地址、不带令牌 —— 页面已经握着令牌,别把门锁写进响应体。
418
+ func (s *Service) lanURLs(hostport string) []string {
419
+ if !s.opts.Lan {
420
+ return nil
421
+ }
422
+ _, portStr, err := net.SplitHostPort(hostport)
423
+ if err != nil {
424
+ return nil
425
+ }
426
+ port, err := strconv.Atoi(portStr)
427
+ if err != nil || port <= 0 {
428
+ return nil
429
+ }
430
+ addrs := LanAddresses(port)
431
+ out := make([]string, 0, len(addrs))
432
+ for _, a := range addrs {
433
+ out = append(out, "http://"+a+"/")
434
+ }
435
+ return out
436
+ }
437
+
438
+ // publicTTL 公网入口探测结果的缓存时长。quick tunnel 换域名只发生在重启/开关时,
439
+ // 两分钟足够新;过期后是**后台**刷新,请求路径上不阻塞。
440
+ const publicTTL = 2 * time.Minute
441
+
442
+ // quickTunnelPortLo/Hi cloudflared 的本地 metrics 端口范围。
443
+ // 端口由 cloudflared 自己从 20241 起顺延分配(实测 20241–20244),这里留足余量。
444
+ const (
445
+ quickTunnelPortLo = 20240
446
+ quickTunnelPortHi = 20280
447
+ )
448
+
449
+ // publicURL 返回本服务对外的 Cloudflare quick tunnel 地址(形如 https://xxx.trycloudflare.com)。
450
+ //
451
+ // 为什么要探测而不是配置:隧道是**壳(掌天瓶)**起的,插件不知道它的域名;quick tunnel
452
+ // 每次启动都换域名,写死或让用户手抄都不成立。cloudflared 把域名放在本地 metrics 的
453
+ // /quicktunnel 里,扫一小段端口就能拿到候选,再用**本服务自己的令牌**回打一次
454
+ // /desk/health 确认「这条隧道确实指向我」(别人的隧道令牌不同 → 401)。
455
+ //
456
+ // 空返回 = 没开公网 / 还没扫到;调用方(移动端)退回局域网地址,不假装有。
457
+ func (s *Service) publicURL() string {
458
+ s.pubMu.Lock()
459
+ cur, exp, busy := s.pubURL, s.pubExp, s.pubBusy
460
+ s.pubMu.Unlock()
461
+ if busy || time.Now().Before(exp) {
462
+ return cur
463
+ }
464
+ s.pubMu.Lock()
465
+ if s.pubBusy {
466
+ s.pubMu.Unlock()
467
+ return s.pubURL
468
+ }
469
+ s.pubBusy = true
470
+ s.pubMu.Unlock()
471
+
472
+ go func() {
473
+ u := s.probePublicURL()
474
+ s.pubMu.Lock()
475
+ changed := u != s.pubURL
476
+ s.pubURL = u
477
+ s.pubExp = time.Now().Add(publicTTL)
478
+ s.pubBusy = false
479
+ s.pubMu.Unlock()
480
+ if changed && s.log != nil {
481
+ if u == "" {
482
+ s.log.Printf("公网入口:未发现(本机没有指向本服务的 cloudflared quick tunnel)")
483
+ } else {
484
+ s.log.Printf("公网入口:%s", u)
485
+ }
486
+ }
487
+ }()
488
+ return cur
489
+ }
490
+
491
+ // probePublicURL 扫 cloudflared 的 metrics 端口,找出指向本服务的那条 quick tunnel。
492
+ func (s *Service) probePublicURL() string {
493
+ tok := strings.TrimSpace(s.opts.Token)
494
+ if tok == "" {
495
+ return ""
496
+ }
497
+ hosts := make(chan string, quickTunnelPortHi-quickTunnelPortLo+1)
498
+ for p := quickTunnelPortLo; p <= quickTunnelPortHi; p++ {
499
+ go func(port int) {
500
+ cl := &http.Client{Timeout: 400 * time.Millisecond}
501
+ resp, err := cl.Get(fmt.Sprintf("http://127.0.0.1:%d/quicktunnel", port))
502
+ if err != nil {
503
+ hosts <- ""
504
+ return
505
+ }
506
+ defer resp.Body.Close()
507
+ var v struct {
508
+ Hostname string `json:"hostname"`
509
+ }
510
+ if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&v) != nil {
511
+ hosts <- ""
512
+ return
513
+ }
514
+ hosts <- strings.TrimSpace(v.Hostname)
515
+ }(p)
516
+ }
517
+ var cands []string
518
+ for i := 0; i <= quickTunnelPortHi-quickTunnelPortLo; i++ {
519
+ if h := <-hosts; h != "" {
520
+ cands = append(cands, h)
521
+ }
522
+ }
523
+ if len(cands) == 0 {
524
+ return ""
525
+ }
526
+
527
+ // 用本服务的令牌回打一次:只有真正指向本服务的隧道才认这个令牌。
528
+ var (
529
+ mu sync.Mutex
530
+ found string
531
+ wg sync.WaitGroup
532
+ )
533
+ for _, h := range cands {
534
+ wg.Add(1)
535
+ go func(host string) {
536
+ defer wg.Done()
537
+ req, err := http.NewRequest(http.MethodGet, "https://"+host+"/desk/health", nil)
538
+ if err != nil {
539
+ return
540
+ }
541
+ req.Header.Set("X-Magic-Token", tok)
542
+ resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
543
+ if err != nil {
544
+ return
545
+ }
546
+ defer resp.Body.Close()
547
+ if resp.StatusCode != http.StatusOK {
548
+ return
549
+ }
550
+ var v map[string]any
551
+ if json.NewDecoder(resp.Body).Decode(&v) != nil || v["ok"] != true {
552
+ return
553
+ }
554
+ mu.Lock()
555
+ if found == "" {
556
+ found = "https://" + host
557
+ }
558
+ mu.Unlock()
559
+ }(h)
560
+ }
561
+ wg.Wait()
562
+ return found
563
+ }
564
+
371
565
  // handleContract 原样转发 core 的契约(客户端拿它做能力降级,不需要经过插件再解释一遍)。
372
566
  func (s *Service) handleContract(w http.ResponseWriter, r *http.Request) {
373
567
  s.proxyCore(w, r, "--contract")
@@ -883,6 +1077,8 @@ func truncateForMsg(s string) string {
883
1077
  // 2026-10-19:实现挪进 `session.Delete`(internal/session/delete.go)—— CLI 新增的
884
1078
  // `--delete-session` 走的是同一份,两条路不再各写一遍(此前只有本接口能做真删除,
885
1079
  // 于是桌面端只能在自己本地藏起来,出现「PC 删了、移动端还在」)。
1080
+ // 2026-10-23:`session.Delete` 会沿父指针级联删掉团队主会话下的子任务会话,
1081
+ // 本接口因此也一并生效(响应多一个只增字段 childSessionsRemoved)。
886
1082
  func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
887
1083
  id, action, ok := parseSessionPath(r.URL.Path)
888
1084
  if !ok || action != "" {
@@ -894,7 +1090,12 @@ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
894
1090
  writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
895
1091
  return
896
1092
  }
897
- writeJSON(w, http.StatusOK, map[string]any{"id": res.ID, "recordsRemoved": res.RecordsRemoved})
1093
+ // 子会话计数只增字段(旧客户端忽略):告诉 UI「这次连子任务一起删了几条」。
1094
+ writeJSON(w, http.StatusOK, map[string]any{
1095
+ "id": res.ID,
1096
+ "recordsRemoved": res.RecordsRemoved,
1097
+ "childSessionsRemoved": res.ChildSessionsRemoved,
1098
+ })
898
1099
  }
899
1100
 
900
1101
  // parseSessionPath 从 `/desk/session/{id}/{action}` 拆出 id 与 action,并做**路径安全校验**。
@@ -0,0 +1,131 @@
1
+ package client
2
+
3
+ // suggest.go - `POST /desk/suggest`:生成「下一轮快捷回复」(一轮结束后可点的下一句)。
4
+ //
5
+ // 为什么放在插件而不是各端自己调模型(用户 2026-10-23:「在回复结束后 ai 自动生成
6
+ // 下一轮的建议,点击即可发送」,先给了方案、选了「B. 模型生成」):
7
+ // 1. 提示词与解析口径只能有一份 —— 移动端 / PC 各写一份,同一轮回答会给出不同建议;
8
+ // 2. 模型调用要落在桌面端(引擎 CLI、凭据、配额都在那台机器上),手机只拿结果;
9
+ // 3. **不续接当前会话**:core 的 `--suggest-followups` 是一次独立旁路调用(ToolsOff、
10
+ // 不带 SessionID),不会把「请生成建议」这类指令写进用户正在聊的上下文。
11
+ //
12
+ // 与 `/desk/session/{id}/title/summarize` 同一套做法:插件 exec core,不自己实现归纳/提炼。
13
+ // 失败一律如实回错(502/504),界面侧静默不画按钮 —— 没有建议是合法结果,不拿模板凑。
14
+
15
+ import (
16
+ "bytes"
17
+ "context"
18
+ "encoding/json"
19
+ "io"
20
+ "net/http"
21
+ "os/exec"
22
+ "strings"
23
+ "time"
24
+ "unicode/utf8"
25
+
26
+ "github.com/darren/magic-agent/internal/suggest"
27
+ )
28
+
29
+ const (
30
+ // suggestTimeout 单次生成的超时。界面侧 20s 就放弃(见 conversation.js),
31
+ // 这里留一点余量,避免「进程还没退、上下文已经被撤」的夹缝报错。
32
+ suggestTimeout = 25 * time.Second
33
+ // suggestMaxBody 请求体上限:只该带「这一轮」的问答,超了说明调用方塞了整条会话。
34
+ suggestMaxBody = 64 << 10
35
+ // suggestMaxUserRunes / suggestMaxReplyRunes 服务端兜底截断(core 还会再截一次)。
36
+ suggestMaxUserRunes = 4000
37
+ suggestMaxReplyRunes = 16000
38
+ )
39
+
40
+ // suggestSlots 并发闸:建议是锦上添花,不能因为客户端重试/多开把引擎进程堆起来。
41
+ // 2 = 允许「当前会话收尾」与「刚切过去的另一个会话」各一条,超出直接 429。
42
+ var suggestSlots = make(chan struct{}, 2)
43
+
44
+ // handleSuggest POST /desk/suggest。
45
+ //
46
+ // 请求:{"user":"用户上一句","reply":"本轮回答正文","engine":"可选","model":"可选"}
47
+ // 响应:{"suggestions":["…", "…"], "engine":"llm", "elapsedMs":1234}
48
+ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
49
+ if r.Method != http.MethodPost {
50
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
51
+ return
52
+ }
53
+ var body struct {
54
+ User string `json:"user"`
55
+ Reply string `json:"reply"`
56
+ Engine string `json:"engine"`
57
+ Model string `json:"model"`
58
+ }
59
+ if r.Body != nil {
60
+ if err := json.NewDecoder(io.LimitReader(r.Body, suggestMaxBody)).Decode(&body); err != nil {
61
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+truncateForMsg(err.Error()))
62
+ return
63
+ }
64
+ }
65
+ body.Reply = strings.TrimSpace(body.Reply)
66
+ if body.Reply == "" {
67
+ writeErr(w, http.StatusBadRequest, `缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
68
+ return
69
+ }
70
+ body.User = clipRunes(strings.TrimSpace(body.User), suggestMaxUserRunes)
71
+ body.Reply = clipRunes(body.Reply, suggestMaxReplyRunes)
72
+
73
+ select {
74
+ case suggestSlots <- struct{}{}:
75
+ defer func() { <-suggestSlots }()
76
+ default:
77
+ writeErr(w, http.StatusTooManyRequests, "建议生成太频繁:上一轮还没结束,请稍后")
78
+ return
79
+ }
80
+
81
+ ctx, cancel := context.WithTimeout(r.Context(), suggestTimeout)
82
+ defer cancel()
83
+
84
+ payload, _ := json.Marshal(suggest.RequestBody{User: body.User, Reply: body.Reply})
85
+ args := []string{"--suggest-followups", "-"}
86
+ /* 引擎/模型由界面带(当前引擎一定装过);不传则 core 用 llm(见 runSuggestFollowups)。 */
87
+ if e := strings.TrimSpace(body.Engine); e != "" {
88
+ args = append(args, "-e", e)
89
+ }
90
+ if m := strings.TrimSpace(body.Model); m != "" {
91
+ args = append(args, "-m", m)
92
+ }
93
+ cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
94
+ cmd.Stdin = bytes.NewReader(payload)
95
+ var stdout, stderr bytes.Buffer
96
+ cmd.Stdout = &stdout
97
+ cmd.Stderr = &stderr
98
+ if err := cmd.Run(); err != nil {
99
+ if ctx.Err() == context.DeadlineExceeded {
100
+ writeErr(w, http.StatusGatewayTimeout, "生成建议超时(>25s):这一轮先不给建议")
101
+ return
102
+ }
103
+ msg := strings.TrimSpace(stderr.String())
104
+ if msg == "" {
105
+ msg = strings.TrimSpace(stdout.String())
106
+ }
107
+ if msg == "" {
108
+ msg = err.Error()
109
+ }
110
+ writeErr(w, http.StatusBadGateway, "生成建议失败:"+truncateForMsg(msg))
111
+ return
112
+ }
113
+ var res suggest.Result
114
+ if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
115
+ // core 换了输出形状就如实报错,别静默回空(那会表现成「功能时灵时不灵」)。
116
+ writeErr(w, http.StatusBadGateway, "core 回的建议不是 JSON:"+truncateForMsg(stdout.String()))
117
+ return
118
+ }
119
+ if res.Suggestions == nil {
120
+ res.Suggestions = []string{}
121
+ }
122
+ writeJSON(w, http.StatusOK, res)
123
+ }
124
+
125
+ // clipRunes 按 rune 截断(中文不能按字节切)。
126
+ func clipRunes(s string, max int) string {
127
+ if max <= 0 || utf8.RuneCountInString(s) <= max {
128
+ return s
129
+ }
130
+ return string([]rune(s)[:max])
131
+ }
@@ -0,0 +1,116 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ // fakeSuggestCore 写一个假 core 脚本:记录 argv → 吃掉 stdin → 打印一份建议 JSON。
14
+ func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
15
+ t.Helper()
16
+ argLog := filepath.Join(dir, "args.txt")
17
+ p := filepath.Join(dir, "magic-agent")
18
+ body := "#!/bin/sh\nprintf '%s' \"$*\" > " + argLog + "\ncat >/dev/null\n" + scriptBody + "\n"
19
+ if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ return p, argLog
23
+ }
24
+
25
+ func TestSuggestEndpoint(t *testing.T) {
26
+ dir := t.TempDir()
27
+ core, argLog := fakeSuggestCore(t, dir, `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`)
28
+ s := &Service{opts: Options{MagicAgent: core}}
29
+
30
+ req := httptest.NewRequest(http.MethodPost, "/desk/suggest",
31
+ strings.NewReader(`{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"fake-engine","model":"fake-model"}`))
32
+ rec := httptest.NewRecorder()
33
+ s.handleSuggest(rec, req)
34
+ if rec.Code != http.StatusOK {
35
+ t.Fatalf("code = %d,body=%s", rec.Code, rec.Body.String())
36
+ }
37
+ var res struct {
38
+ Suggestions []string `json:"suggestions"`
39
+ Engine string `json:"engine"`
40
+ }
41
+ if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
42
+ t.Fatalf("响应不是 JSON:%v", err)
43
+ }
44
+ if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
45
+ t.Fatalf("suggestions = %#v", res.Suggestions)
46
+ }
47
+ args, _ := os.ReadFile(argLog)
48
+ if !strings.Contains(string(args), "--suggest-followups -") {
49
+ t.Fatalf("没走 --suggest-followups:%s", args)
50
+ }
51
+ if !strings.Contains(string(args), "-e fake-engine") || !strings.Contains(string(args), "-m fake-model") {
52
+ t.Fatalf("引擎/模型没透传:%s", args)
53
+ }
54
+ }
55
+
56
+ func TestSuggestEndpointBadRequests(t *testing.T) {
57
+ dir := t.TempDir()
58
+ core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":[]}\n'`)
59
+ s := &Service{opts: Options{MagicAgent: core}}
60
+
61
+ cases := []struct {
62
+ name string
63
+ req *http.Request
64
+ code int
65
+ }{
66
+ {"GET 不允许", httptest.NewRequest(http.MethodGet, "/desk/suggest", nil), http.StatusMethodNotAllowed},
67
+ {"缺 reply", httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(`{"user":"只说了半句"}`)), http.StatusBadRequest},
68
+ {"非法 JSON", httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(`nope`)), http.StatusBadRequest},
69
+ }
70
+ for _, c := range cases {
71
+ rec := httptest.NewRecorder()
72
+ s.handleSuggest(rec, c.req)
73
+ if rec.Code != c.code {
74
+ t.Errorf("%s:code = %d,期望 %d(body=%s)", c.name, rec.Code, c.code, rec.Body.String())
75
+ }
76
+ }
77
+ }
78
+
79
+ func TestSuggestEndpointCoreFailure(t *testing.T) {
80
+ dir := t.TempDir()
81
+ core, _ := fakeSuggestCore(t, dir, "echo 'llm CLI not found' >&2\nexit 3")
82
+ s := &Service{opts: Options{MagicAgent: core}}
83
+ rec := httptest.NewRecorder()
84
+ s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest",
85
+ strings.NewReader(`{"user":"x","reply":"要我继续吗?"}`)))
86
+ if rec.Code != http.StatusBadGateway {
87
+ t.Fatalf("code = %d,期望 502", rec.Code)
88
+ }
89
+ if !strings.Contains(rec.Body.String(), "llm CLI not found") {
90
+ t.Fatalf("core 的失败原因没带回:%s", rec.Body.String())
91
+ }
92
+ }
93
+
94
+ func TestSuggestEndpointGarbageCoreOutput(t *testing.T) {
95
+ dir := t.TempDir()
96
+ core, _ := fakeSuggestCore(t, dir, `printf 'not json\n'`)
97
+ s := &Service{opts: Options{MagicAgent: core}}
98
+ rec := httptest.NewRecorder()
99
+ s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest",
100
+ strings.NewReader(`{"user":"x","reply":"要我继续吗?"}`)))
101
+ if rec.Code != http.StatusBadGateway {
102
+ t.Fatalf("code = %d,期望 502", rec.Code)
103
+ }
104
+ if !strings.Contains(rec.Body.String(), "不是 JSON") {
105
+ t.Fatalf("应明确说 core 的输出不是 JSON:%s", rec.Body.String())
106
+ }
107
+ }
108
+
109
+ func TestClipRunes(t *testing.T) {
110
+ if got := clipRunes("一二三四五", 3); got != "一二三" {
111
+ t.Fatalf("clipRunes = %q", got)
112
+ }
113
+ if got := clipRunes("短", 10); got != "短" {
114
+ t.Fatalf("短文本不该变:%q", got)
115
+ }
116
+ }
@@ -1,4 +1,4 @@
1
- // delete.go - 删除一条会话(登记记录 + 事件日志 + 标题覆盖)。
1
+ // delete.go - 删除一条会话(登记记录 + 事件日志 + 标题/状态覆盖,含子任务会话)。
2
2
  //
3
3
  // 为什么要它(2026-10-19 用户:「从pc端删除的会话移动端还在显示」):
4
4
  // 此前「删除」只发生在**客户端本地**(掌天瓶记一座 localStorage 墓碑、从自己的列表拿掉),
@@ -36,9 +36,17 @@ type DeleteResult struct {
36
36
  LogsRemoved int `json:"logs_removed"`
37
37
  TitlesRemoved int `json:"titles_removed"`
38
38
  StatusesRemoved int `json:"statuses_removed"`
39
+ // ChildSessionsRemoved 连同主会话一起删掉的**子任务会话**条数。
40
+ //
41
+ // 团队模式拆出的子任务各是一条普通会话(自己的 run/session、自己的日志),
42
+ // 靠父指针挂在主会话下;只删主会话会留下「父行没了、子行还挂在空处」的孤儿
43
+ // (见下面的父子闭包)。这里按**子会话 id** 去重(每轮都带 session_id 时同一条子会话只算 1),
44
+ // 不是按登记记录计数。
45
+ ChildSessionsRemoved int `json:"child_sessions_removed"`
39
46
  }
40
47
 
41
- // Delete 删掉一条会话:登记记录 + 事件日志 + 标题覆盖。
48
+ // Delete 删掉一条会话:登记记录 + 事件日志 + 标题/状态覆盖。
49
+ // 团队主会话还会沿父指针级联删掉挂在它下面的全部子任务会话。
42
50
  // id 可以是 session_id 或 run_id(与 --stop / --session-log 同一套寻址)。
43
51
  func Delete(id string) (DeleteResult, error) {
44
52
  out := DeleteResult{ID: strings.TrimSpace(id)}
@@ -57,22 +65,52 @@ func Delete(id string) (DeleteResult, error) {
57
65
  return out, err
58
66
  }
59
67
  /* ① 先收集「这条会话所有可能的名字」:传进来的 id(可能两个都不在登记表里、
60
- 只有日志文件在),加上命中记录自己的 session_id 与 run_id。 */
68
+ 只有日志文件在),加上命中记录自己的 session_id 与 run_id,**以及挂在它下面的
69
+ 子任务会话**(团队模式,2026-10-23 用户:「删除主会话时要把子任务的会话也删除」)。
70
+
71
+ 子任务各是一条普通会话(见 internal/session.Record 的 ParentRunID /
72
+ ParentSessionID),删除是物理删除:只删主会话会让子会话既没有父行可挂、
73
+ 又继续占着列表与磁盘。所以这里找一个**闭包**:
74
+ · 同一会话跨轮 —— 任一轮的 run_id / session_id 命中,就把两个名字都收进来;
75
+ · 子会话 —— 父指针命中已收名字的记录,把自己的名字也收进来;
76
+ · 反复直到不再增长(子任务再拆团队 / 多轮登记都能覆盖)。
77
+ 只沿父指针向下找,不会反向把父会话带进来:删一条子会话只删它自己。 */
61
78
  names := map[string]bool{out.ID: true}
62
- for _, r := range recs {
63
- if r.RunID != out.ID && r.SessionID != out.ID {
64
- continue
79
+ children := map[string]bool{} // 子会话计数(按规范 id 去重,同一条子会话多轮只算 1)
80
+ for {
81
+ before := len(names)
82
+ for _, r := range recs {
83
+ if !names[r.RunID] && !names[r.SessionID] {
84
+ continue
85
+ }
86
+ addDeleteName(names, r.RunID)
87
+ addDeleteName(names, r.SessionID)
65
88
  }
66
- if r.RunID != "" {
67
- names[r.RunID] = true
89
+ for _, r := range recs {
90
+ if names[r.RunID] || names[r.SessionID] {
91
+ continue
92
+ }
93
+ if !names[r.ParentRunID] && !names[r.ParentSessionID] {
94
+ continue
95
+ }
96
+ key := strings.TrimSpace(r.SessionID)
97
+ if key == "" {
98
+ key = strings.TrimSpace(r.RunID)
99
+ }
100
+ if key != "" {
101
+ children[key] = true
102
+ }
103
+ addDeleteName(names, r.RunID)
104
+ addDeleteName(names, r.SessionID)
68
105
  }
69
- if r.SessionID != "" {
70
- names[r.SessionID] = true
106
+ if len(names) == before {
107
+ break
71
108
  }
72
109
  }
110
+ out.ChildSessionsRemoved = len(children)
73
111
  /* ② 再按名字集合删**这一整条会话**的记录:一条会话每轮落一条(run_id 各不同、
74
112
  session_id 相同),只删「id 正好等于传进来那个」的那一条,其它轮还在列表里
75
- —— 表现就是「删了没删干净、刷新又冒出来」。 */
113
+ —— 表现就是「删了没删干净、刷新又冒出来」。子会话的记录同样按名字集合删除。 */
76
114
  for _, r := range recs {
77
115
  if !names[r.RunID] && !names[r.SessionID] {
78
116
  continue
@@ -124,6 +162,15 @@ func logDirsForDelete(registryDir string) []string {
124
162
  return out
125
163
  }
126
164
 
165
+ // addDeleteName 把一个 id 收进删除名字集合(空值忽略:老记录可能没有 session_id)。
166
+ func addDeleteName(names map[string]bool, name string) {
167
+ name = strings.TrimSpace(name)
168
+ if name == "" {
169
+ return
170
+ }
171
+ names[name] = true
172
+ }
173
+
127
174
  // safeDeleteID id 能不能安全地当文件名用:非空、非 `.` / `..`、不含路径分隔符与控制字符。
128
175
  func safeDeleteID(id string) bool {
129
176
  if id == "" || id == "." || id == ".." {