magic-agent 1.0.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,888 @@
1
+ package client
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "log"
11
+ "net/http"
12
+ "net/http/httputil"
13
+ "net/url"
14
+ "os"
15
+ "os/exec"
16
+ "path/filepath"
17
+ "strconv"
18
+ "strings"
19
+ "sync"
20
+ "time"
21
+
22
+ "github.com/darren/magic-agent/internal/session"
23
+ )
24
+
25
+ /* Service 是 magic-client 插件的本地服务:给它一个 profile,它提供
26
+ * 「基础版 Agent UI + 九项扩展接口 + 壳能力」里**服务侧**的那一半。
27
+ *
28
+ * 三件事必须做对(见 magic-client 方案设计):
29
+ * 1) 契约校验:启动时读 core 的 --contract,版本或能力不匹配就**明确报错**,
30
+ * 不带着不匹配的能力跑起来 —— 这类漂移的表现是界面空白,最难定位。
31
+ * 2) 只走 core 的公开面:spawn `magic-agent` 子进程收 NDJSON 事件,**不解析引擎输出**。
32
+ * 3) 默认只绑 127.0.0.1 且要令牌:本地服务对同机所有进程可见,不开鉴权等于把
33
+ * 引擎执行权交给任意本机进程。
34
+ */
35
+
36
+ // RequiredContractVersion 本插件能解析的 core 契约版本。
37
+ const RequiredContractVersion = 1
38
+
39
+ // requiredFeatures 插件依赖的 core 通道能力。缺任何一个都不能工作:
40
+ // 没有 events 就没有带游标的事件流,没有 control 就无法打断与回审批。
41
+ var requiredFeatures = []string{"feature.session.events", "feature.session.control"}
42
+
43
+ // Options 启动参数。
44
+ type Options struct {
45
+ // MagicAgent core 可执行文件;空 = 从 PATH 找 magic-agent。
46
+ MagicAgent string
47
+ Profile Profile
48
+ // UIRoot 静态资源目录(开发态从磁盘加载;发布态由调用方指向内置资源解包目录)。
49
+ UIRoot string
50
+ // Token 访问令牌;空 = 启动时随机生成。
51
+ Token string
52
+ Logger *log.Logger
53
+ }
54
+
55
+ // Service 本地服务。
56
+ type Service struct {
57
+ opts Options
58
+ log *log.Logger
59
+ contract Contract
60
+ // backend `/api/*` 的反向代理;profile 没配 backend.url 时为 nil。
61
+ // 在 New() 里一次建好,避免在请求路径上惰性初始化(那是数据竞争)。
62
+ backend *httputil.ReverseProxy
63
+
64
+ mu sync.Mutex
65
+ runs map[string]*askRun
66
+ seq int64
67
+ }
68
+
69
+ // Contract core `--contract` 里本插件关心的部分。
70
+ type Contract struct {
71
+ ContractVersion int `json:"contractVersion"`
72
+ Features []string `json:"features"`
73
+ Engines []struct {
74
+ Engine string `json:"engine"`
75
+ OK bool `json:"ok"`
76
+ Bin string `json:"bin"`
77
+ Capabilities []string `json:"capabilities"`
78
+ } `json:"engines"`
79
+ }
80
+
81
+ // New 建服务并做契约校验。
82
+ func New(opts Options) (*Service, error) {
83
+ if opts.Logger == nil {
84
+ opts.Logger = log.New(os.Stderr, "magic-client: ", 0)
85
+ }
86
+ if strings.TrimSpace(opts.MagicAgent) == "" {
87
+ opts.MagicAgent = "magic-agent"
88
+ }
89
+ s := &Service{opts: opts, log: opts.Logger, runs: map[string]*askRun{}}
90
+
91
+ // 项目后端的反代一次建好:profile 合法就给代理,非法就启动即失败
92
+ //(地址写错比运行时 502 更容易查——启动日志里说清楚)。
93
+ if target := strings.TrimSpace(opts.Profile.Backend.URL); target != "" {
94
+ rp, err := buildBackendProxy(opts.Profile.Backend, target)
95
+ if err != nil {
96
+ return nil, err
97
+ }
98
+ s.backend = rp
99
+ }
100
+
101
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
102
+ defer cancel()
103
+ out, err := exec.CommandContext(ctx, opts.MagicAgent, "--contract").Output()
104
+ if err != nil {
105
+ return nil, fmt.Errorf("读 core 契约失败(%s --contract):%w", opts.MagicAgent, err)
106
+ }
107
+ if err := json.Unmarshal(out, &s.contract); err != nil {
108
+ return nil, fmt.Errorf("解析 core 契约失败:%w", err)
109
+ }
110
+ if s.contract.ContractVersion != RequiredContractVersion {
111
+ return nil, fmt.Errorf(
112
+ "core 契约版本 %d 与本插件要求的 %d 不一致:请升级 magic-agent(或换用匹配的插件版本)",
113
+ s.contract.ContractVersion, RequiredContractVersion)
114
+ }
115
+ for _, f := range requiredFeatures {
116
+ if !hasStr(s.contract.Features, f) {
117
+ return nil, fmt.Errorf("core 缺通道能力 %s(需要支持 --stream --events 与 --control 的版本)", f)
118
+ }
119
+ }
120
+ return s, nil
121
+ }
122
+
123
+ // Contract 已校验过的 core 契约(给启动日志与 /desk/health 用)。
124
+ func (s *Service) Contract() Contract { return s.contract }
125
+
126
+ // MagicAgentPath 实际使用的 core 可执行文件。
127
+ func (s *Service) MagicAgentPath() string { return s.opts.MagicAgent }
128
+
129
+ // Handler 路由。
130
+ func (s *Service) Handler() http.Handler {
131
+ mux := http.NewServeMux()
132
+ mux.HandleFunc("/desk/health", s.guard(s.handleHealth))
133
+ mux.HandleFunc("/desk/contract", s.guard(s.handleContract))
134
+ mux.HandleFunc("/desk/engines", s.guard(s.handleEngines))
135
+ mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
136
+ mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
137
+ mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
138
+ // /desk/session/{id}/messages 是**前缀匹配**路由 —— 标准 ServeMux 把它挂上后,
139
+ // 该前缀下任何 path 都先进这里(其它挂载顺序靠后也拦不住)。
140
+ // 它同时承担 /desk/session/{id}/events(断线续读)的分派,见 handleSessionMessages。
141
+ mux.HandleFunc("/desk/session/", s.guard(s.handleSessionMessages))
142
+ mux.HandleFunc("/desk/asks", s.guard(s.handleAsks))
143
+ mux.HandleFunc("/desk/ask", s.guard(s.handleAsk))
144
+ mux.HandleFunc("/desk/ask/", s.guard(s.handleAskSub))
145
+ // 业务模块的路由:原样转发到项目自己的后端(见 Profile.Backend 的注释)。
146
+ mux.HandleFunc("/api/", s.guard(s.handleProjectAPI))
147
+ // 业务模块的静态资源:优先项目自己的模块目录(profile 的 ui.modulesDir)。
148
+ mux.HandleFunc("/modules/", s.handleModulesStatic)
149
+ // 二维码配对:把「桌面地址 + 令牌」给手机扫。内容等同于令牌,所以用普通鉴权把门
150
+ // (拿到令牌的人本来就拥有它;没令牌的人只会看到 401)。
151
+ mux.HandleFunc("/pair", s.guard(s.handlePairPage))
152
+ mux.HandleFunc("/pair/qr.png", s.guard(s.handlePairQR))
153
+ mux.HandleFunc("/desk/pair", s.guard(s.handlePairInfo))
154
+ mux.HandleFunc("/", s.handleStatic)
155
+ return s.withCORS(mux)
156
+ }
157
+
158
+ /* withCORS 让**本机页面**能跨源访问本服务。
159
+ *
160
+ * ── 为什么必须有这一层 ──
161
+ * 移动容器里页面是打包在 App 里的本地资源(Android + androidScheme=https ⇒ 源是
162
+ * `https://localhost`),而插件跑在局域网 IP 上 —— 天生跨源。跨源就被 CORS 管,
163
+ * 没有 CORS 头时浏览器**直接掐掉**响应:现象是闸门页探活永远停在「连接中…」,
164
+ * APK 装得上、打得开、就是用不了。
165
+ *
166
+ * ⚠️ 这一层在 curl 里永远看不见:curl 不主动带 Origin,服务端也就没有跨源这回事。
167
+ * 前几轮接口验证全绿,恰恰是因为它们没有浏览器这一层 —— 真浏览器一开就露。
168
+ *
169
+ * ── 为什么是白名单而不是 `*` ──
170
+ * 本服务后面是能跑 claude/codebuddy 的**执行面**,`*` 等于允许用户访问的任意网页
171
+ * 来敲这个本地端口。白名单只放行「本机页面」(容器 scheme 与 loopback 源),
172
+ * 外站拿不到 CORS 头,浏览器照旧拦住它。
173
+ *
174
+ * ── 预检为什么必须绕过鉴权 ──
175
+ * `X-Magic-Token` 是自定义头,浏览器会先发一条 OPTIONS 预检,而**预检请求不带业务头**
176
+ * (它不可能带令牌)。若让预检走 guard,它必然 401,浏览器就把真正的请求判死。
177
+ * 所以 OPTIONS 在 guard 之前就地答完,且它只是能力协商、不碰任何执行面。
178
+ */
179
+ func (s *Service) withCORS(next http.Handler) http.Handler {
180
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181
+ origin := strings.TrimSpace(r.Header.Get("Origin"))
182
+ if origin != "" {
183
+ // Origin 会随请求方变,不加 Vary 会把一个源的响应喂给另一个源。
184
+ w.Header().Add("Vary", "Origin")
185
+ if localOrigin(origin) {
186
+ w.Header().Set("Access-Control-Allow-Origin", origin)
187
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
188
+ w.Header().Set("Access-Control-Allow-Headers", "X-Magic-Token, Content-Type")
189
+ w.Header().Set("Access-Control-Max-Age", "600")
190
+ }
191
+ }
192
+ if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
193
+ // 预检:只回能力协商,不进业务路由(也就不会因为没令牌而 401)。
194
+ w.WriteHeader(http.StatusNoContent)
195
+ return
196
+ }
197
+ next.ServeHTTP(w, r)
198
+ })
199
+ }
200
+
201
+ // localOrigin 判定「这是本机页面」:容器自定义 scheme,或 loopback 上的任意源。
202
+ //
203
+ // 不限端口与 http/https:桌面壳的开发服务器、容器、插件自己同源页面各不相同,
204
+ // 但它们的共同点是**都在这台机器上**。真正把门的是令牌,这一层只回答「浏览器让不让读」。
205
+ func localOrigin(origin string) bool {
206
+ switch origin {
207
+ case "capacitor://localhost", "ionic://localhost":
208
+ return true
209
+ }
210
+ u, err := url.Parse(origin)
211
+ if err != nil {
212
+ return false
213
+ }
214
+ switch u.Hostname() {
215
+ case "localhost", "127.0.0.1", "::1":
216
+ return true
217
+ }
218
+ return false
219
+ }
220
+
221
+ // guard 鉴权:`X-Magic-Token` 头或 `token` 查询参数。静态资源不鉴权(它不含密钥,
222
+ // 真正的执行面都在 /desk/* 后面)。
223
+ func (s *Service) guard(next http.HandlerFunc) http.HandlerFunc {
224
+ return func(w http.ResponseWriter, r *http.Request) {
225
+ want := s.opts.Token
226
+ got := r.Header.Get("X-Magic-Token")
227
+ if got == "" {
228
+ got = r.URL.Query().Get("token")
229
+ }
230
+ if want != "" && got != want {
231
+ writeErr(w, http.StatusUnauthorized, "缺少或错误的访问令牌(X-Magic-Token / ?token=)")
232
+ return
233
+ }
234
+ next(w, r)
235
+ }
236
+ }
237
+
238
+ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
239
+ 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),
245
+ })
246
+ }
247
+
248
+ // handleContract 原样转发 core 的契约(客户端拿它做能力降级,不需要经过插件再解释一遍)。
249
+ func (s *Service) handleContract(w http.ResponseWriter, r *http.Request) {
250
+ s.proxyCore(w, r, "--contract")
251
+ }
252
+
253
+ // handleEngines 引擎清单(含模型;走 --no-models 的快路径由调用方决定)。
254
+ func (s *Service) handleEngines(w http.ResponseWriter, r *http.Request) {
255
+ if r.URL.Query().Get("models") == "1" {
256
+ s.proxyCore(w, r, "--engines")
257
+ return
258
+ }
259
+ s.proxyCore(w, r, "--engines", "--no-models")
260
+ }
261
+
262
+ func (s *Service) handleProfile(w http.ResponseWriter, r *http.Request) {
263
+ writeJSON(w, http.StatusOK, s.opts.Profile)
264
+ }
265
+
266
+ // handleAsks 会话登记表(core 的 `--sessions`):列表页的「最近会话」用它。
267
+ // 与 /desk/asks 的区别:asks 是**本进程正在跑的轮次**(内存里),sessions 是 core 的
268
+ // 持久登记表(跨进程、含已结束的)。
269
+ func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
270
+ s.proxyCore(w, r, "--sessions")
271
+ }
272
+
273
+ // handleSessionMessages 「看历史」持久化落盘的会话事件(`GET /desk/session/{id}/messages`)。
274
+ //
275
+ // 用途:移动端进会话详情时先拉历史 → 已经有内容可渲染;再开 SSE 接续。落盘由
276
+ // core CLI 在跑流时一行一行 append 到 ~/.magic-agent/sessions/<id>.jsonl
277
+ // (与本服务的转发/代理是两条独立路径,写盘独立于此服务 —— 即便用户没装本插件,
278
+ // 直接跑 magic-agent --stream 也会落盘)。
279
+ //
280
+ // 边界:
281
+ // - 没历史 → 200 [],不是 404。UI 上看到「空会话」也是合理状态(不该报错)。
282
+ // - 文件不在 → 200 [],read 端按 os.IsNotExist 静默返回。
283
+ // - 写盘存在但损坏行 → 跳过损坏行;同 session 的其它正常行仍能拿到。
284
+ // - 必须带 X-Magic-Token:这是会话数据(包含历史消息文本),没令牌读不到。
285
+ //
286
+ // 本函数同时是 `/desk/session/{id}/{action}` 这条前缀路由的**唯一入口**:
287
+ // `action` 为空或 `messages` 走全量(下面这段),`events` 走增量续读(见 handleSessionEvents)。
288
+ // 为什么在一个 handler 里分派而不再注册一条更具体的 pattern:`/desk/session/` 是**子树匹配**,
289
+ // 后注册的 pattern 并不会自动赢;而带 `{id}` 通配的新式 pattern 需要 Go 1.22+。
290
+ // 在入口处显式分派既不看 Go 版本脸色,也让「有哪些子路径」集中在一处可读。
291
+ func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request) {
292
+ if r.Method != http.MethodGet {
293
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
294
+ return
295
+ }
296
+ id, action, ok := parseSessionPath(r.URL.Path)
297
+ if !ok {
298
+ writeErr(w, http.StatusBadRequest, "session id 不能为空或路径分量")
299
+ return
300
+ }
301
+ switch action {
302
+ case "", "messages":
303
+ // 全量:落到下面的读盘逻辑。
304
+ case "events":
305
+ s.handleSessionEvents(w, r, id)
306
+ return
307
+ default:
308
+ // 未知子路径要**明说**,不能默默当全量返回 —— 否则客户端拼错路径时会拿到
309
+ // 一份看起来正常的全量历史,于是「增量没生效」被当成「增量没实现」。
310
+ writeErr(w, http.StatusNotFound, "未知子路径:"+action+"(可用:messages / events)")
311
+ return
312
+ }
313
+ dir, derr := session.DefaultLogDir()
314
+ if derr != nil {
315
+ writeErr(w, http.StatusInternalServerError, "读历史失败:找不到历史目录:"+derr.Error())
316
+ return
317
+ }
318
+ evs, err := session.ReadSessionLog(dir, id, sessionEventMapFromJSON)
319
+ if err != nil {
320
+ writeErr(w, http.StatusInternalServerError, "读历史失败:"+err.Error())
321
+ return
322
+ }
323
+ writeJSON(w, http.StatusOK, map[string]any{"session": id, "events": evs})
324
+ }
325
+
326
+ // sessionEventMapFromJSON 把 jsonl 的一行解析成 map(plugin 不依赖 cli 的 schema)。
327
+ func sessionEventMapFromJSON(line []byte) (map[string]any, error) {
328
+ var m map[string]any
329
+ if err := json.Unmarshal(line, &m); err != nil {
330
+ return nil, err
331
+ }
332
+ return m, nil
333
+ }
334
+
335
+ // parseSessionPath 从 `/desk/session/{id}/{action}` 拆出 id 与 action,并做**路径安全校验**。
336
+ //
337
+ // 返回 ok=false 的唯一情形是 id 为空或等于 `.` / `..`(调用方回 400)。
338
+ //
339
+ // ⚠️ id 是**文件名**的一部分(`<id>.jsonl`),所以校验必须在这里、且必须一次做全:
340
+ // - 拒绝 `.` / `..` 整段(否则 id=".." 会指到上一级目录)
341
+ // - 拒绝任何路径分隔符与控制字符(否则 "../../etc/passwd" 能探文件系统)
342
+ //
343
+ // action 允许为空(`/desk/session/{id}` 与 `/desk/session/{id}/messages` 等价)。
344
+ func parseSessionPath(path string) (id, action string, ok bool) {
345
+ rest := strings.Trim(strings.TrimPrefix(path, "/desk/session/"), "/")
346
+ if i := strings.Index(rest, "/"); i >= 0 {
347
+ id, action = rest[:i], strings.Trim(rest[i+1:], "/")
348
+ } else {
349
+ id = rest
350
+ }
351
+ if id == "" || id == "." || id == ".." {
352
+ return "", "", false
353
+ }
354
+ for _, c := range id {
355
+ if c < 0x20 || c == 0x7f || c == '/' || c == '\\' {
356
+ return "", "", false
357
+ }
358
+ }
359
+ return id, action, true
360
+ }
361
+
362
+ // handleSessionEvents 增量续读:`GET /desk/session/{id}/events?after=<seq>`
363
+ // (`action` 已在 handleSessionMessages 里判过,这里只管读)。
364
+ //
365
+ // 语义对齐 agents-anywhere 的 `GET /sessions/{id}/events?after=seq:N`:客户端报
366
+ // 「我已经看到 seq=N」,服务端只回 seq > N 的部分。全量接口是 `/messages`;
367
+ // 这条存在的理由是**断线重连后不必重传整段历史**(长会话下差一个数量级)。
368
+ //
369
+ // 与 anywhere 的一处**刻意简化**:anywhere 在 `after == current` 时仍要回一条
370
+ // (`session.meta.updated` / `runtime.capability.updated`),因为它的「在线状态 / 能力」
371
+ // 是**不推进持久化 seq** 的独立维度 —— 客户端只靠 seq 对账会漏掉这两类变化。
372
+ // 我们没有这类旁路状态(在线与否由连接本身表达,能力是静态契约),所以
373
+ // 「没有新事件」就是真的什么都没有,回空数组即可。
374
+ //
375
+ // 三种结果,调用方按 `snapshotRequired` 分流:
376
+ // 1. 无新事件(含 after 正好等于最后一条)→ 200 + 空数组,snapshotRequired=false
377
+ // 2. 有新事件 → 200 + 增量数组,snapshotRequired=false
378
+ // 3. after **超前**于磁盘上的最大 seq → 200 + 空数组,snapshotRequired=**true**
379
+ // 超前只可能是「日志被重置/截断」或「游标来自另一个会话」;两种情况下
380
+ // 增量都不可信,客户端应改拉 `/messages` 全量重建。
381
+ //
382
+ // 边界:after 省略或为 0 = 全量(等价 /messages,但多带 lastSeq/after 元信息);
383
+ // after 非数字或超 uint64 → 400(**不静默当 0** —— 那样客户端会以为拿到了增量,
384
+ // 实际每次都收全量,性能问题会被伪装成「没问题」)。
385
+ func (s *Service) handleSessionEvents(w http.ResponseWriter, r *http.Request, id string) {
386
+ var after uint64
387
+ afterRaw := strings.TrimSpace(r.URL.Query().Get("after"))
388
+ if afterRaw != "" {
389
+ n, err := strconv.ParseUint(afterRaw, 10, 64)
390
+ if err != nil {
391
+ writeErr(w, http.StatusBadRequest, "after 必须是非负整数(收到 "+strconv.Quote(afterRaw)+")")
392
+ return
393
+ }
394
+ after = n
395
+ }
396
+ dir, derr := session.DefaultLogDir()
397
+ if derr != nil {
398
+ writeErr(w, http.StatusInternalServerError, "读历史失败:找不到历史目录:"+derr.Error())
399
+ return
400
+ }
401
+ evs, maxSeq, err := session.ReadSessionLogAfter(dir, id, after, mapSeqOf, sessionEventMapFromJSON)
402
+ if err != nil {
403
+ writeErr(w, http.StatusInternalServerError, "读历史失败:"+err.Error())
404
+ return
405
+ }
406
+ // 保证是数组而不是 null:客户端对这条接口的用法是「往已有画布上追加」,
407
+ // 拿到 null 得处处判空,而空数组天然可迭代。(/messages 保持原行为不动 —— 已有测试钉着。)
408
+ if evs == nil {
409
+ evs = []map[string]any{}
410
+ }
411
+ writeJSON(w, http.StatusOK, map[string]any{
412
+ "session": id,
413
+ "events": evs,
414
+ "count": len(evs),
415
+ "after": after,
416
+ "lastSeq": maxSeq,
417
+ // 客户端游标比磁盘还新 ⇒ 增量不可信,去拉全量。
418
+ // ⚠️ 判据是**严格大于**:after == maxSeq 是「已经追平」,那是正常的空增量。
419
+ "snapshotRequired": after > maxSeq,
420
+ })
421
+ }
422
+
423
+ // mapSeqOf 从解析后的 map 里取 seq。
424
+ //
425
+ // ⚠️ json.Unmarshal 进 `map[string]any` 时数字一律变 float64(除非开了 UseNumber),
426
+ // 所以这里只认 float64 —— 写一个 json.Number 分支是**没有调用方能到**的死代码。
427
+ // 非数字 / 负数 / 缺失一律返回 0:seq 从 1 起(见 cli/sessionlog.go 的 appendLine),
428
+ // 所以 0 天然被 `seq > after` 滤掉,不需要额外处理。
429
+ func mapSeqOf(m map[string]any) uint64 {
430
+ f, ok := m["seq"].(float64)
431
+ if !ok || f < 0 {
432
+ return 0
433
+ }
434
+ return uint64(f)
435
+ }
436
+
437
+ // moduleInfo 模块清单的一项:基础版 UI 按它决定加载谁。
438
+ type moduleInfo struct {
439
+ ID string `json:"id"`
440
+ Entry string `json:"entry,omitempty"`
441
+ URL string `json:"url,omitempty"`
442
+ DataTables []string `json:"dataTables,omitempty"`
443
+ // Loadable 前端能否加载(有 entry 且 UIRoot 已配置)。
444
+ Loadable bool `json:"loadable"`
445
+ }
446
+
447
+ // handleModules 模块清单:profile 里的 modules 派生而来。
448
+ //
449
+ // 插件只回答「有哪些模块、入口在哪、它声明了哪些表」——**不含任何领域假设**,
450
+ // 也不解释 dataTables 的含义(那是模块与它自己后端之间的事)。
451
+ func (s *Service) handleModules(w http.ResponseWriter, r *http.Request) {
452
+ hasUI := strings.TrimSpace(s.opts.UIRoot) != ""
453
+ out := make([]moduleInfo, 0, len(s.opts.Profile.Modules))
454
+ for _, m := range s.opts.Profile.Modules {
455
+ mi := moduleInfo{ID: m.ID, Entry: m.Entry, DataTables: m.DataTables}
456
+ if m.Entry != "" {
457
+ /* ⚠️ URL 必须带 `/modules/` 前缀 —— 那是本服务挂载模块静态资源的路径。
458
+ 漏了前缀的表现是「清单里明明有、点开 404」,而且单测(只断言字段)抓不到,
459
+ 第一次真机点模块才发现。 */
460
+ mi.URL = "/modules/" + strings.TrimPrefix(m.Entry, "/")
461
+ }
462
+ mi.Loadable = hasUI && m.Entry != ""
463
+ out = append(out, mi)
464
+ }
465
+ writeJSON(w, http.StatusOK, map[string]any{
466
+ "modules": out,
467
+ "backend": s.opts.Profile.Backend.URL, // 空串 = 未配置,/api/* 会回 501
468
+ })
469
+ }
470
+
471
+ // handleProjectAPI 把 `/api/*` 原样转发给项目自己的后端。
472
+ //
473
+ // 三条设计约束:
474
+ // 1. **不另建存储**:业务数据的真相在项目那边,插件转发而不是复制(否则两个真相源)。
475
+ // 2. **凭据服务端注入**:profile 里的 backend.token 由插件加进请求头,不进浏览器。
476
+ // 3. **支持流式**:项目的后端可能用 SSE 推进度(magic-test 的派发流就是),
477
+ // 所以用 ReverseProxy + 立即 flush,不缓冲整段响应。
478
+ func (s *Service) handleProjectAPI(w http.ResponseWriter, r *http.Request) {
479
+ target := strings.TrimSpace(s.opts.Profile.Backend.URL)
480
+ if target == "" || s.backend == nil {
481
+ writeErr(w, http.StatusNotImplemented,
482
+ "没有配置项目后端:profile 里补 \"backend\": {\"url\": \"http://127.0.0.1:<port>\"}。"+
483
+ "业务数据(需求 / 用例 / 产物 / 设置)的真相在项目侧,插件只转发不另建存储。")
484
+ return
485
+ }
486
+ s.backend.ServeHTTP(w, r)
487
+ }
488
+
489
+ // buildBackendProxy 建 `/api/*` 的反向代理。
490
+ //
491
+ // 三条设计约束:
492
+ // 1. **不另建存储**:业务数据的真相在项目那边,插件转发而不是复制(否则两个真相源);
493
+ // 2. **凭据服务端注入**:backend.token 由插件加进请求头,不进浏览器;
494
+ // 3. **支持流式**:项目后端可能用 SSE 推进度(magic-test 的派发流就是),
495
+ // FlushInterval=-1 让分片立即到达,不缓冲整段响应。
496
+ func buildBackendProxy(cfg BackendConfig, target string) (*httputil.ReverseProxy, error) {
497
+ u, err := url.Parse(target)
498
+ if err != nil || u.Host == "" {
499
+ return nil, fmt.Errorf("profile 里的 backend.url 不是合法地址:%q", target)
500
+ }
501
+ rp := httputil.NewSingleHostReverseProxy(u)
502
+ rp.FlushInterval = -1
503
+ orig := rp.Director
504
+ rp.Director = func(req *http.Request) {
505
+ orig(req)
506
+ req.Host = u.Host
507
+ if tok := strings.TrimSpace(cfg.Token); tok != "" {
508
+ if h := strings.TrimSpace(cfg.TokenHeader); h != "" {
509
+ req.Header.Set(h, tok)
510
+ } else {
511
+ req.Header.Set("Authorization", "Bearer "+tok)
512
+ }
513
+ }
514
+ // 插件自己的令牌不带去后端:那是两套凭据,混着传会让后端误以为
515
+ // 它需要校验我们的令牌。
516
+ req.Header.Del("X-Magic-Token")
517
+ }
518
+ rp.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {
519
+ writeErr(w, http.StatusBadGateway, fmt.Sprintf("转发到项目后端 %s 失败:%v", target, err))
520
+ }
521
+ return rp, nil
522
+ }
523
+
524
+ func (s *Service) handleAsks(w http.ResponseWriter, r *http.Request) {
525
+ type item struct {
526
+ ID string `json:"id"`
527
+ Engine string `json:"engine"`
528
+ Started string `json:"started"`
529
+ }
530
+ s.mu.Lock()
531
+ items := make([]item, 0, len(s.runs))
532
+ for _, run := range s.runs {
533
+ items = append(items, item{ID: run.id, Engine: run.engine, Started: run.started.Format(time.RFC3339)})
534
+ }
535
+ s.mu.Unlock()
536
+ writeJSON(w, http.StatusOK, items)
537
+ }
538
+
539
+ // proxyCore 跑一条只读的 core 命令(--contract / --engines)并把 stdout 原样转发。
540
+ func (s *Service) proxyCore(w http.ResponseWriter, r *http.Request, args ...string) {
541
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
542
+ defer cancel()
543
+ out, err := exec.CommandContext(ctx, s.opts.MagicAgent, args...).Output()
544
+ if err != nil {
545
+ writeErr(w, http.StatusBadGateway, fmt.Sprintf("%s %s 失败:%v", s.opts.MagicAgent, strings.Join(args, " "), err))
546
+ return
547
+ }
548
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
549
+ _, _ = w.Write(out)
550
+ }
551
+
552
+ // askRequest POST /desk/ask 的请求体。
553
+ type askRequest struct {
554
+ Engine string `json:"engine"`
555
+ Prompt string `json:"prompt"`
556
+ Model string `json:"model,omitempty"`
557
+ Workspace string `json:"workspace,omitempty"`
558
+ // Permission 四档权限模型(manual | accept-edits | auto | full),透传给 core 的
559
+ // --permission。空 = 不传,由 core 用它自己的默认值(agent.DefaultPermissionTier)。
560
+ // ⚠️ core 只在**显式传过** --permission 时才校验引擎是否支持(当前 claude / codebuddy /
561
+ // codebuddy-ai);所以这里只在客户端真的要选档位时才加这个参数,不要替 core 猜默认值,
562
+ // 否则会把「显式指定」与「沿用默认」两种语义混成一种。
563
+ Permission string `json:"permission,omitempty"`
564
+ // Session 续接一个已有会话(传 /desk/sessions 里的 session_id 或 run_id)。
565
+ // 空 = 新会话。
566
+ Session string `json:"session,omitempty"`
567
+ NoThinking bool `json:"noThinking,omitempty"`
568
+ // Idle 常驻会话空闲收工时长;默认 0 = 本轮结束就收工(客户端驱动的轮次语义)。
569
+ // >0 才留常驻窗口(此时 answer 命令在轮次结束后仍可用)。
570
+ Idle string `json:"idle,omitempty"`
571
+ }
572
+
573
+ // askRun 一次正在跑的 ask。
574
+ type askRun struct {
575
+ id string
576
+ engine string
577
+ started time.Time
578
+ cmd *exec.Cmd
579
+ stdin io.WriteCloser
580
+ done chan struct{}
581
+ code int
582
+ stderr *cappedBuffer
583
+ }
584
+
585
+ // handleAsk 发起一轮并**把 core 的 NDJSON 事件原样中继**给调用方(SSE)。
586
+ //
587
+ // 中继而不是解释:事件契约的解析权在客户端(桌面 / 移动端各一份实现),
588
+ // 插件只保证「顺序不变、不丢行、结束有明确的收尾事件」。
589
+ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
590
+ if r.Method != http.MethodPost {
591
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
592
+ return
593
+ }
594
+ var req askRequest
595
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
596
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
597
+ return
598
+ }
599
+ if strings.TrimSpace(req.Prompt) == "" {
600
+ writeErr(w, http.StatusBadRequest, "prompt 不能为空")
601
+ return
602
+ }
603
+ engine := strings.TrimSpace(req.Engine)
604
+ if engine == "" {
605
+ writeErr(w, http.StatusBadRequest, "engine 不能为空(清单见 /desk/engines)")
606
+ return
607
+ }
608
+ if !s.opts.Profile.AllowsEngine(engine) {
609
+ writeErr(w, http.StatusForbidden, fmt.Sprintf("profile 白名单不允许引擎 %q", engine))
610
+ return
611
+ }
612
+
613
+ idle := strings.TrimSpace(req.Idle)
614
+ if idle == "" {
615
+ idle = "0"
616
+ }
617
+ args := []string{
618
+ "-e", engine,
619
+ "--stream", "--events", "--control",
620
+ "--idle", idle,
621
+ }
622
+ if req.Model != "" {
623
+ args = append(args, "-m", req.Model)
624
+ }
625
+ if req.Workspace != "" {
626
+ args = append(args, "-w", req.Workspace)
627
+ }
628
+ // 权限档位:只在客户端显式选了一档时才传,让 core 去校验该引擎支不支持。
629
+ if p := strings.TrimSpace(req.Permission); p != "" {
630
+ args = append(args, "--permission", p)
631
+ }
632
+ if req.Session != "" {
633
+ args = append(args, "--session", req.Session)
634
+ }
635
+ if req.NoThinking {
636
+ args = append(args, "--no-thinking")
637
+ }
638
+ args = append(args, "-p", req.Prompt)
639
+
640
+ ctx, cancel := context.WithCancel(r.Context())
641
+ defer cancel()
642
+
643
+ cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
644
+ stdin, err := cmd.StdinPipe()
645
+ if err != nil {
646
+ writeErr(w, http.StatusInternalServerError, "stdin pipe: "+err.Error())
647
+ return
648
+ }
649
+ stdout, err := cmd.StdoutPipe()
650
+ if err != nil {
651
+ writeErr(w, http.StatusInternalServerError, "stdout pipe: "+err.Error())
652
+ return
653
+ }
654
+ run := &askRun{
655
+ id: s.nextID(),
656
+ engine: engine,
657
+ started: time.Now(),
658
+ cmd: cmd,
659
+ stdin: stdin,
660
+ done: make(chan struct{}),
661
+ stderr: newCappedBuffer(8 << 10),
662
+ }
663
+ cmd.Stderr = run.stderr
664
+ if err := cmd.Start(); err != nil {
665
+ writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
666
+ return
667
+ }
668
+ s.mu.Lock()
669
+ s.runs[run.id] = run
670
+ s.mu.Unlock()
671
+ defer func() {
672
+ s.mu.Lock()
673
+ delete(s.runs, run.id)
674
+ s.mu.Unlock()
675
+ }()
676
+
677
+ // SSE 头必须在**第一行事件之前**发出,否则客户端拿到的是 application/json。
678
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
679
+ w.Header().Set("Cache-Control", "no-cache")
680
+ w.Header().Set("X-Accel-Buffering", "no")
681
+ flusher, _ := w.(http.Flusher)
682
+ send := func(event string, payload any) {
683
+ b, _ := json.Marshal(payload)
684
+ if event != "" {
685
+ fmt.Fprintf(w, "event: %s\n", event)
686
+ }
687
+ fmt.Fprintf(w, "data: %s\n\n", b)
688
+ if flusher != nil {
689
+ flusher.Flush()
690
+ }
691
+ }
692
+ send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent})
693
+
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
700
+ }
701
+ // 原样中继:核心事件流自己就是 NDJSON,不再包一层。
702
+ fmt.Fprintf(w, "data: %s\n\n", line)
703
+ if flusher != nil {
704
+ flusher.Flush()
705
+ }
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
715
+ }
716
+ }
717
+ run.code = code
718
+ close(run.done)
719
+ send("end", map[string]any{"id": run.id, "code": code, "stderr": run.stderr.String()})
720
+ }
721
+
722
+ // handleAskSub 处理 `/desk/ask/{id}/control`:把一条控制命令写进 core 的 stdin。
723
+ //
724
+ // 这就是「手机回审批」的服务侧路径 —— 命令格式与 core 的 --control 契约一致,
725
+ // 插件不解释 op,只转发(解析权在 core)。
726
+ func (s *Service) handleAskSub(w http.ResponseWriter, r *http.Request) {
727
+ rest := strings.TrimPrefix(r.URL.Path, "/desk/ask/")
728
+ parts := strings.Split(strings.Trim(rest, "/"), "/")
729
+ if len(parts) != 2 || parts[1] != "control" {
730
+ writeErr(w, http.StatusNotFound, "用法:POST /desk/ask/{id}/control")
731
+ return
732
+ }
733
+ if r.Method != http.MethodPost {
734
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
735
+ return
736
+ }
737
+ s.mu.Lock()
738
+ run := s.runs[parts[0]]
739
+ s.mu.Unlock()
740
+ if run == nil {
741
+ writeErr(w, http.StatusNotFound, "没有这个 ask(可能已结束):"+parts[0])
742
+ return
743
+ }
744
+ var cmd struct {
745
+ Op string `json:"op"`
746
+ Text string `json:"text,omitempty"`
747
+ Reason string `json:"reason,omitempty"`
748
+ }
749
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&cmd); err != nil {
750
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
751
+ return
752
+ }
753
+ if strings.TrimSpace(cmd.Op) == "" {
754
+ writeErr(w, http.StatusBadRequest, "op 不能为空(ping / interrupt / stop / answer)")
755
+ return
756
+ }
757
+ b, _ := json.Marshal(cmd)
758
+ if _, err := run.stdin.Write(append(b, '\n')); err != nil {
759
+ writeErr(w, http.StatusConflict, "写 core stdin 失败(本轮可能已结束):"+err.Error())
760
+ return
761
+ }
762
+ writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "op": cmd.Op, "id": run.id})
763
+ }
764
+
765
+ // handleModulesStatic 业务模块的静态资源:**先**项目的模块目录(profile 的 ui.modulesDir),
766
+ // 再退回插件的 UI 目录(--ui-dir)。
767
+ //
768
+ // 为什么要有单独的挂载点:模块文件属于项目(在项目仓库里跟着一起改),而基础版 UI 属于插件。
769
+ // 混在一个目录里会让「插件升级」和「模块改动」互相踩。两个根都支持是为了让简单项目
770
+ // (模块就一两个文件)也能直接把文件放 UI 目录下,不强迫多配一个字段。
771
+ func (s *Service) handleModulesStatic(w http.ResponseWriter, r *http.Request) {
772
+ rel := strings.TrimPrefix(r.URL.Path, "/modules/")
773
+ roots := make([]string, 0, 2)
774
+ if d := strings.TrimSpace(s.opts.Profile.UI.ModulesDir); d != "" {
775
+ roots = append(roots, d)
776
+ }
777
+ if d := strings.TrimSpace(s.opts.UIRoot); d != "" {
778
+ roots = append(roots, d)
779
+ }
780
+ if len(roots) == 0 {
781
+ writeErr(w, http.StatusNotFound, "没有配置模块目录:profile 的 ui.modulesDir 或 --ui-dir")
782
+ return
783
+ }
784
+ for _, d := range roots {
785
+ root, err := filepath.Abs(d)
786
+ if err != nil {
787
+ continue
788
+ }
789
+ // Clean("/"+rel) 会把 `..` 收进根内,因此拼接后不可能逃出 root;HasPrefix 只作双保险。
790
+ full := filepath.Join(root, filepath.Clean("/"+rel))
791
+ if !strings.HasPrefix(full, root) {
792
+ continue
793
+ }
794
+ /* ⚠️ 这里**不能**用 http.ServeFile:请求路径以 `/index.html` 结尾时它会 301 重定向
795
+ (`/modules/index.html` → `/modules/`),于是那个文件永远读不到 —— 现象是
796
+ 「模块清单里明明有,一点就 404」。ServeContent 不做这个重定向。 */
797
+ f, err := os.Open(full)
798
+ if err != nil {
799
+ continue
800
+ }
801
+ st, err := f.Stat()
802
+ if err != nil || st.IsDir() {
803
+ _ = f.Close()
804
+ continue
805
+ }
806
+ http.ServeContent(w, r, st.Name(), st.ModTime(), f)
807
+ _ = f.Close()
808
+ return
809
+ }
810
+ writeErr(w, http.StatusNotFound, "模块文件不存在:"+rel)
811
+ }
812
+
813
+ // handleStatic 基础版 UI 与业务模块的静态资源。
814
+ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
815
+ if strings.TrimSpace(s.opts.UIRoot) == "" {
816
+ writeErr(w, http.StatusNotFound, "没有配置 UI 目录(--ui-dir),本服务只提供 /desk/* 接口")
817
+ return
818
+ }
819
+ root, err := filepath.Abs(s.opts.UIRoot)
820
+ if err != nil {
821
+ writeErr(w, http.StatusInternalServerError, err.Error())
822
+ return
823
+ }
824
+ p := strings.TrimPrefix(r.URL.Path, "/")
825
+ if p == "" {
826
+ p = s.opts.Profile.UI.Entry
827
+ if p == "" {
828
+ p = "index.html"
829
+ }
830
+ }
831
+ full := filepath.Join(root, filepath.Clean(p))
832
+ if !strings.HasPrefix(full, root) {
833
+ writeErr(w, http.StatusForbidden, "路径越界")
834
+ return
835
+ }
836
+ http.ServeFile(w, r, full)
837
+ }
838
+
839
+ func (s *Service) nextID() string {
840
+ s.mu.Lock()
841
+ defer s.mu.Unlock()
842
+ s.seq++
843
+ return fmt.Sprintf("ask-%d-%d", time.Now().UnixNano()/1e6, s.seq)
844
+ }
845
+
846
+ // cappedBuffer 只留最后 N 字节(stderr 排障用,不无限增长)。
847
+ type cappedBuffer struct {
848
+ mu sync.Mutex
849
+ max int
850
+ buf []byte
851
+ }
852
+
853
+ func newCappedBuffer(max int) *cappedBuffer { return &cappedBuffer{max: max} }
854
+
855
+ func (c *cappedBuffer) Write(p []byte) (int, error) {
856
+ c.mu.Lock()
857
+ defer c.mu.Unlock()
858
+ c.buf = append(c.buf, p...)
859
+ if len(c.buf) > c.max {
860
+ c.buf = c.buf[len(c.buf)-c.max:]
861
+ }
862
+ return len(p), nil
863
+ }
864
+
865
+ func (c *cappedBuffer) String() string {
866
+ c.mu.Lock()
867
+ defer c.mu.Unlock()
868
+ return string(c.buf)
869
+ }
870
+
871
+ func hasStr(list []string, want string) bool {
872
+ for _, s := range list {
873
+ if s == want {
874
+ return true
875
+ }
876
+ }
877
+ return false
878
+ }
879
+
880
+ func writeJSON(w http.ResponseWriter, code int, v any) {
881
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
882
+ w.WriteHeader(code)
883
+ _ = json.NewEncoder(w).Encode(v)
884
+ }
885
+
886
+ func writeErr(w http.ResponseWriter, code int, msg string) {
887
+ writeJSON(w, code, map[string]any{"error": msg})
888
+ }