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.
- package/CLIENT-CONTRACT.md +117 -6
- package/client-ui/pc/app.css +3 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +21 -1
- package/client-ui/shared/conv/03-chat-message.js +43 -0
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +16 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +655 -87
- package/client-ui/shared/transport-http.js +16 -0
- package/client-ui-mobile/index.html +1052 -595
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +507 -0
- package/cmd/magic-client/main.go +2 -1
- package/internal/cli/ask.go +6 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/apk.go +135 -0
- package/internal/client/apk_test.go +99 -0
- package/internal/client/prefs.go +99 -0
- package/internal/client/service.go +202 -1
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +421 -0
- package/internal/suggest/suggest_test.go +218 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +5 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- 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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
names[r.RunID]
|
|
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
|
|
70
|
-
|
|
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 == ".." {
|