magic-agent 2.4.3 → 2.6.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 +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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 +8 -2
|
@@ -22,6 +22,7 @@ import (
|
|
|
22
22
|
"os"
|
|
23
23
|
"os/exec"
|
|
24
24
|
"path/filepath"
|
|
25
|
+
"reflect"
|
|
25
26
|
"sort"
|
|
26
27
|
"strconv"
|
|
27
28
|
"strings"
|
|
@@ -38,8 +39,8 @@ import (
|
|
|
38
39
|
* 1) 契约校验:启动时读 core 的 --contract,版本或能力不匹配就**明确报错**,
|
|
39
40
|
* 不带着不匹配的能力跑起来 —— 这类漂移的表现是界面空白,最难定位。
|
|
40
41
|
* 2) 只走 core 的公开面:spawn `magic-agent` 子进程收 NDJSON 事件,**不解析引擎输出**。
|
|
41
|
-
* 3)
|
|
42
|
-
*
|
|
42
|
+
* 3) 默认绑 0.0.0.0:9100 且强制令牌:局域网内端口能被扫到,令牌是唯一门锁;
|
|
43
|
+
* 只想本机访问时用 --local 绑回 127.0.0.1。
|
|
43
44
|
*/
|
|
44
45
|
|
|
45
46
|
// RequiredContractVersion 本插件能解析的 core 契约版本。
|
|
@@ -73,7 +74,7 @@ type Options struct {
|
|
|
73
74
|
// APKPath 可选:要分发给移动端的 APK 路径。空 = 自动在包内 npm/apk/ 下找
|
|
74
75
|
// (见 apk.go)。显式给路径主要给测试与特殊布局用。
|
|
75
76
|
APKPath string
|
|
76
|
-
// Lan
|
|
77
|
+
// Lan 是否绑到了局域网(默认开启,--local 关闭)。移动端「环境」菜单要靠它拿到手机可用的入口,
|
|
77
78
|
// 否则用户只能手抄 IP。绑回环时不报(报了也连不上)。
|
|
78
79
|
Lan bool
|
|
79
80
|
// BrowseUA 内置浏览器中转使用的 User-Agent;空 = 用内置的 Android Chrome。
|
|
@@ -114,9 +115,17 @@ type Service struct {
|
|
|
114
115
|
browseOnce sync.Once
|
|
115
116
|
browseHTTP *http.Client
|
|
116
117
|
|
|
118
|
+
// 浏览器输入中继(/browse-input,见 browseinput.go):页面内 relay 脚本 viewer
|
|
119
|
+
// 与未来的 MCP driver 之间的枢纽,惰性建。
|
|
120
|
+
inputHubOnce sync.Once
|
|
121
|
+
inputHubRef *browseInputHub
|
|
122
|
+
|
|
117
123
|
// 需求自动归纳的调度状态(每工作区一个防抖计时器,见 requirements.go)。
|
|
118
124
|
reqAuto reqAuto
|
|
119
125
|
|
|
126
|
+
// 脚本清单与运行态(「发现 → 脚本」,见 scripts.go)。
|
|
127
|
+
scripts *scriptManager
|
|
128
|
+
|
|
120
129
|
// 随包发布的移动端 APK(空 = 这个包里没带)。见 apk.go。
|
|
121
130
|
apkPath string
|
|
122
131
|
|
|
@@ -126,6 +135,15 @@ type Service struct {
|
|
|
126
135
|
pubURL string
|
|
127
136
|
pubExp time.Time
|
|
128
137
|
pubBusy bool
|
|
138
|
+
|
|
139
|
+
/* 知识库(OpenViking)的最新版本缓存 + 安装互斥(见 knowledge.go)。
|
|
140
|
+
成功缓存 6h、失败缓存 15min —— 失败不该每次开页面都重试一次外网。
|
|
141
|
+
ovBusy 挡住两个客户端同时点安装(会抢同一个 venv)。 */
|
|
142
|
+
ovMu sync.Mutex
|
|
143
|
+
ovLatest string
|
|
144
|
+
ovLatestOK bool
|
|
145
|
+
ovLatestAt time.Time
|
|
146
|
+
ovBusy bool
|
|
129
147
|
}
|
|
130
148
|
|
|
131
149
|
// Contract core `--contract` 里本插件关心的部分。
|
|
@@ -193,7 +211,7 @@ func New(opts Options) (*Service, error) {
|
|
|
193
211
|
|
|
194
212
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
195
213
|
defer cancel()
|
|
196
|
-
out, err :=
|
|
214
|
+
out, err := newCoreCmd(ctx, opts.MagicAgent, "--contract").Output()
|
|
197
215
|
if err != nil {
|
|
198
216
|
return nil, fmt.Errorf("读 core 契约失败(%s --contract):%w", opts.MagicAgent, err)
|
|
199
217
|
}
|
|
@@ -213,6 +231,8 @@ func New(opts Options) (*Service, error) {
|
|
|
213
231
|
/* 作业队列:服务端权威的排队层(多客户端串行 / 幂等 / 重启恢复)。
|
|
214
232
|
必须在 New 里就建好:/desk/ask 第一次进来就要能入队。 */
|
|
215
233
|
s.jobs = newJobQueue(s)
|
|
234
|
+
// 脚本管理器同理:清单要跨重启保留,运行中的脚本要靠 pid 探活。
|
|
235
|
+
s.scripts = newScriptManager(s)
|
|
216
236
|
return s, nil
|
|
217
237
|
}
|
|
218
238
|
|
|
@@ -227,11 +247,16 @@ func (s *Service) Handler() http.Handler {
|
|
|
227
247
|
mux := http.NewServeMux()
|
|
228
248
|
mux.HandleFunc("/desk/health", s.guard(s.handleHealth))
|
|
229
249
|
mux.HandleFunc("/desk/contract", s.guard(s.handleContract))
|
|
250
|
+
/* 重启后台实例(2026-10-24,移动端「我的 → 重启服务」)。见 restart.go:
|
|
251
|
+
本进程发完响应后由一个脱离终端的助手停旧起新,令牌 / 端口沿用。 */
|
|
252
|
+
mux.HandleFunc("/desk/restart", s.guard(s.handleRestart))
|
|
230
253
|
// 移动端 APK:版本信息 + 安装包本身(版本号跟 npm 包走,见 apk.go)。
|
|
231
254
|
mux.HandleFunc("/desk/app", s.guard(s.handleAppInfo))
|
|
232
255
|
// 系统浏览器/下载器带不了自定义头,这个接口靠 guard 支持的 ?token= 过鉴权。
|
|
233
256
|
mux.HandleFunc("/desk/app.apk", s.guard(s.handleAppAPK))
|
|
234
257
|
mux.HandleFunc("/desk/engines", s.guard(s.handleEngines))
|
|
258
|
+
// 每日积分领取(2026-10-24):core --checkin <engine>,用该引擎自己的票据。
|
|
259
|
+
mux.HandleFunc("/desk/checkin", s.guard(s.handleCheckin))
|
|
235
260
|
mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
|
|
236
261
|
mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
|
|
237
262
|
mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
|
|
@@ -258,11 +283,25 @@ func (s *Service) Handler() http.Handler {
|
|
|
258
283
|
// 需求聚合(2026-10-22,见 requirements.go):读清单 / 手动整理一轮。
|
|
259
284
|
// 自动归纳不在这里 —— 它是每轮 /desk/ask 收尾后的防抖动作。
|
|
260
285
|
mux.HandleFunc("/desk/requirements", s.guard(s.handleRequirements))
|
|
286
|
+
mux.HandleFunc("/desk/requirements/graph", s.guard(s.handleRequirementsGraph))
|
|
261
287
|
mux.HandleFunc("/desk/requirements/consolidate", s.guard(s.handleRequirementsConsolidate))
|
|
288
|
+
// 页面完成情况截图(2026-10-25):body = PNG 原始字节,参数走查询串。
|
|
289
|
+
mux.HandleFunc("/desk/requirements/shots", s.guard(s.handleRequirementsShots))
|
|
290
|
+
// 服务端 agent-browser 截图(2026-10-25 MCP 改基 agent-browser):
|
|
291
|
+
// body = {"url":…},服务端真实 Chromium 截全页图落 uploads。
|
|
292
|
+
mux.HandleFunc("/desk/requirements/shot-remote", s.guard(s.handleRequirementShotRemote))
|
|
262
293
|
// 插件目录(magic-shorts / magic-video 两个工程的 CLI):介绍、安装状态、安装/升级。
|
|
263
294
|
// 与引擎无关 —— core 不认识它们,所以放在这一层而不是 core 的 --engines 里。
|
|
264
295
|
mux.HandleFunc("/desk/plugins", s.guard(s.handlePlugins))
|
|
265
296
|
mux.HandleFunc("/desk/plugins/", s.guard(s.handlePluginAction))
|
|
297
|
+
// 知识库目录(OpenViking:面向 Agent 的上下文数据库):本机是否已安装、当前版本、
|
|
298
|
+
// 与 PyPI 最新版本的差距,以及一键安装 / 升级。与插件同源:不是引擎,放在这一层。
|
|
299
|
+
mux.HandleFunc("/desk/knowledge", s.guard(s.handleKnowledge))
|
|
300
|
+
mux.HandleFunc("/desk/knowledge/", s.guard(s.handleKnowledgeAction))
|
|
301
|
+
// 脚本清单与启停(「发现 → 脚本」):用户登记脚本,服务端负责启停与日志。
|
|
302
|
+
// 命令**只来自登记过的脚本定义**(不过 shell),客户端不能传任意命令行。
|
|
303
|
+
mux.HandleFunc("/desk/scripts", s.guard(s.handleScripts))
|
|
304
|
+
mux.HandleFunc("/desk/scripts/", s.guard(s.handleScriptSub))
|
|
266
305
|
mux.HandleFunc("/desk/dirs", s.guard(s.handleDirs))
|
|
267
306
|
// /desk/session/{id}/messages 是**前缀匹配**路由 —— 标准 ServeMux 把它挂上后,
|
|
268
307
|
// 该前缀下任何 path 都先进这里(其它挂载顺序靠后也拦不住)。
|
|
@@ -287,6 +326,9 @@ func (s *Service) Handler() http.Handler {
|
|
|
287
326
|
// 内置浏览器的**服务端中转**:/b/<scheme>/<host><path>。走桌面端网络抓取并改写,
|
|
288
327
|
// 见 browse.go。注意它自己鉴权(cookie/查询令牌),不再套 s.guard。
|
|
289
328
|
mux.HandleFunc("/b/", s.handleBrowse)
|
|
329
|
+
// 浏览器输入中继:/browse-input?role=viewer|driver。鉴权同 /b/(查询令牌/cookie),
|
|
330
|
+
// 不套 s.guard —— 页面内注入的 relay 脚本只带 cookie。见 browseinput.go。
|
|
331
|
+
mux.HandleFunc(browseWSPath, s.handleBrowseInput)
|
|
290
332
|
// SOCKS5 over WebSocket:让 SOCKS5 能穿过 HTTP 隧道(Cloudflare 等)到公网,见 wsproxy.go。
|
|
291
333
|
mux.HandleFunc(proxyWSPath, s.handleProxyWS)
|
|
292
334
|
mux.HandleFunc("/", s.handleStatic)
|
|
@@ -426,6 +468,11 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
|
426
468
|
// 服务端作业队列(2026-10-23):有它才谈得上「多客户端串行 + 幂等」。
|
|
427
469
|
// 老插件没有该字段 → 客户端退回本地排队(见 client-ui-mobile 的 fallback)。
|
|
428
470
|
"jobs": true,
|
|
471
|
+
// 重启标志(2026-10-25,只增字段):本实例已受理「重启服务」、定时器还没到点。
|
|
472
|
+
// 探活语义:true = 旧实例认账、即将下线(见 restart.go 的标志 + 定时器链路);
|
|
473
|
+
// 再探到该字段为假的 200 才是新一代起来。老插件没有该字段 → 客户端照旧
|
|
474
|
+
// 「先看到连不上、再看到 200」的判据。
|
|
475
|
+
"restarting": restartRequested(),
|
|
429
476
|
// 服务实例的稳定 id(2026-10-23,只增字段):同一台桌面的本地 / 局域网 / 公网
|
|
430
477
|
// 入口可能有多条 URL,客户端据此把它们归一成同一个环境(队列与设置共用)。
|
|
431
478
|
// 老插件没有该字段 → 客户端退回按连接各自算环境。
|
|
@@ -647,7 +694,7 @@ var extraSessionClients = []string{"magic-test", "magic-agent"}
|
|
|
647
694
|
func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|
648
695
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
649
696
|
defer cancel()
|
|
650
|
-
out, err :=
|
|
697
|
+
out, err := newCoreCmd(ctx, s.opts.MagicAgent, "--sessions").Output()
|
|
651
698
|
if err != nil {
|
|
652
699
|
writeErr(w, http.StatusBadGateway,
|
|
653
700
|
fmt.Sprintf("%s --sessions 失败:%v", s.opts.MagicAgent, err))
|
|
@@ -1079,7 +1126,7 @@ func (s *Service) handleSessionTitleSummarize(w http.ResponseWriter, r *http.Req
|
|
|
1079
1126
|
if body.Force {
|
|
1080
1127
|
args = append(args, "--title-force")
|
|
1081
1128
|
}
|
|
1082
|
-
out, err :=
|
|
1129
|
+
out, err := newCoreCmd(ctx, s.opts.MagicAgent, args...).Output()
|
|
1083
1130
|
if err != nil {
|
|
1084
1131
|
// core 的失败原因写在 stderr,原样带回:只回「归纳失败」调用方没法排障。
|
|
1085
1132
|
msg := strings.TrimSpace(string(out))
|
|
@@ -1650,7 +1697,7 @@ var errCoreTimeout = errors.New("core 命令超时未返回")
|
|
|
1650
1697
|
func (s *Service) runCore(ctx context.Context, args ...string) ([]byte, error) {
|
|
1651
1698
|
ctx, cancel := context.WithTimeout(ctx, proxyCoreTimeout)
|
|
1652
1699
|
defer cancel()
|
|
1653
|
-
cmd :=
|
|
1700
|
+
cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
|
|
1654
1701
|
/* WaitDelay 是有界返回的最后一道保险:exec 给 Stdout/Stderr 建的是内部管道,
|
|
1655
1702
|
只要 core 的某个后代进程(引擎 CLI 的 node worker)还握着写端,Wait 就永远不返回 ——
|
|
1656
1703
|
症状正是「弹层永远停在正在探测引擎与模型…」。设了 WaitDelay 后,ctx 到期再等这么久
|
|
@@ -1736,6 +1783,56 @@ func saveEnginesCache(out []byte) {
|
|
|
1736
1783
|
_ = os.Rename(tmp, p)
|
|
1737
1784
|
}
|
|
1738
1785
|
|
|
1786
|
+
// patchEnginesCacheAfterCheckin 领取(或发现「今天已签」)后,把兜底缓存里该引擎的
|
|
1787
|
+
// 签到状态就地改掉:下一次打开选择器立刻看到「今日已领」,不用等后台重探。
|
|
1788
|
+
// 只改缓存、不重探 —— 重探要起所有引擎 CLI,弹层会卡几十秒。
|
|
1789
|
+
func patchEnginesCacheAfterCheckin(out []byte) {
|
|
1790
|
+
var res struct {
|
|
1791
|
+
Engine string `json:"engine"`
|
|
1792
|
+
CheckedToday bool `json:"checked_today"`
|
|
1793
|
+
CreditLeft string `json:"credit_left"`
|
|
1794
|
+
}
|
|
1795
|
+
if json.Unmarshal(out, &res) != nil || !res.CheckedToday || res.Engine == "" {
|
|
1796
|
+
return
|
|
1797
|
+
}
|
|
1798
|
+
p, err := enginesCachePath()
|
|
1799
|
+
if err != nil {
|
|
1800
|
+
return
|
|
1801
|
+
}
|
|
1802
|
+
raw, err := os.ReadFile(p)
|
|
1803
|
+
if err != nil {
|
|
1804
|
+
return
|
|
1805
|
+
}
|
|
1806
|
+
var rows []map[string]any
|
|
1807
|
+
if json.Unmarshal(raw, &rows) != nil {
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
changed := false
|
|
1811
|
+
for _, r := range rows {
|
|
1812
|
+
if name, _ := r["engine"].(string); name != res.Engine {
|
|
1813
|
+
continue
|
|
1814
|
+
}
|
|
1815
|
+
r["checkin_today"] = true
|
|
1816
|
+
r["checkin_credit"] = ""
|
|
1817
|
+
if res.CreditLeft != "" {
|
|
1818
|
+
r["credit_left"] = res.CreditLeft
|
|
1819
|
+
}
|
|
1820
|
+
changed = true
|
|
1821
|
+
}
|
|
1822
|
+
if !changed {
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
b, err := json.Marshal(rows)
|
|
1826
|
+
if err != nil {
|
|
1827
|
+
return
|
|
1828
|
+
}
|
|
1829
|
+
tmp := p + ".tmp"
|
|
1830
|
+
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
_ = os.Rename(tmp, p)
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1739
1836
|
// loadEnginesCache 读上次成功的清单;过期 / 损坏都当没有。
|
|
1740
1837
|
func loadEnginesCache() ([]byte, time.Time, bool) {
|
|
1741
1838
|
p, err := enginesCachePath()
|
|
@@ -1795,6 +1892,9 @@ func (s *Service) probeEnginesAsync() {
|
|
|
1795
1892
|
// 这样弹层永远是秒开:用户这次看到的是上次的结果,下一次打开就是新的。
|
|
1796
1893
|
func (s *Service) proxyEngines(w http.ResponseWriter, r *http.Request) {
|
|
1797
1894
|
if cached, at, ok := loadEnginesCache(); ok {
|
|
1895
|
+
/* 模型清单用缓存,额度 / 签到现查:额度会随用量与 key 变化,跟着缓存一起
|
|
1896
|
+
冻 24 小时就是用户看到的「额度不刷新」(2026-10-25 报障)。 */
|
|
1897
|
+
cached = s.mergeLiveQuota(r.Context(), cached)
|
|
1798
1898
|
w.Header().Set("X-Magic-Engines-Stale", at.Format(time.RFC3339))
|
|
1799
1899
|
writeCoreJSON(w, cached)
|
|
1800
1900
|
s.probeEnginesAsync()
|
|
@@ -1813,12 +1913,133 @@ func (s *Service) proxyEngines(w http.ResponseWriter, r *http.Request) {
|
|
|
1813
1913
|
writeErr(w, code, fmt.Sprintf("%s --engines 失败:%v", s.opts.MagicAgent, err))
|
|
1814
1914
|
}
|
|
1815
1915
|
|
|
1916
|
+
// quotaFields core --quotas 里允许覆盖到引擎清单上的字段(与 --engines 同名同义)。
|
|
1917
|
+
var quotaFields = []string{
|
|
1918
|
+
"credit_left", "credit_suffix", "model_quota", "checkin_today", "checkin_credit", "logged_in", "account",
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// mergeLiveQuota 用 core --quotas 的最新结果覆盖引擎清单里的额度 / 签到字段。
|
|
1922
|
+
//
|
|
1923
|
+
// 为什么不整体重探:模型清单要逐个启动引擎 CLI(秒级起步),而额度只读本机票据 +
|
|
1924
|
+
// 一两条只读请求。分开之后选择器依旧秒开,额度却是这次现查的。
|
|
1925
|
+
// 查询失败(老 core 没有 --quotas / 网络抖动)就原样返回缓存 —— 不能因为额度
|
|
1926
|
+
// 查不到让整份引擎清单变成错误。
|
|
1927
|
+
func (s *Service) mergeLiveQuota(ctx context.Context, engines []byte) []byte {
|
|
1928
|
+
// core 内部还有 6s 上限;这里再兜一层,避免异常时钟 / 挂起的子进程拖住响应。
|
|
1929
|
+
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
1930
|
+
defer cancel()
|
|
1931
|
+
quotas, err := s.runCore(ctx, "--quotas")
|
|
1932
|
+
if err != nil {
|
|
1933
|
+
return engines
|
|
1934
|
+
}
|
|
1935
|
+
return mergeQuotaJSON(engines, quotas)
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// mergeQuotaJSON 把 --quotas 的行按 engine 合并进 --engines 的数组(纯函数,便于单测)。
|
|
1939
|
+
// 只覆盖「这次查到了的」字段:空串 / 空对象不覆盖,避免一次网络失败把已有数字抹掉。
|
|
1940
|
+
func mergeQuotaJSON(engines, quotas []byte) []byte {
|
|
1941
|
+
var rows []map[string]any
|
|
1942
|
+
if json.Unmarshal(engines, &rows) != nil {
|
|
1943
|
+
return engines
|
|
1944
|
+
}
|
|
1945
|
+
var qs []map[string]any
|
|
1946
|
+
if json.Unmarshal(quotas, &qs) != nil {
|
|
1947
|
+
return engines
|
|
1948
|
+
}
|
|
1949
|
+
byEngine := make(map[string]map[string]any, len(qs))
|
|
1950
|
+
for _, q := range qs {
|
|
1951
|
+
if name, _ := q["engine"].(string); name != "" {
|
|
1952
|
+
byEngine[name] = q
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
changed := false
|
|
1956
|
+
for _, r := range rows {
|
|
1957
|
+
name, _ := r["engine"].(string)
|
|
1958
|
+
q := byEngine[name]
|
|
1959
|
+
if q == nil {
|
|
1960
|
+
continue
|
|
1961
|
+
}
|
|
1962
|
+
for _, k := range quotaFields {
|
|
1963
|
+
v, ok := q[k]
|
|
1964
|
+
if !ok || emptyQuotaValue(v) || reflect.DeepEqual(r[k], v) {
|
|
1965
|
+
continue
|
|
1966
|
+
}
|
|
1967
|
+
r[k] = v
|
|
1968
|
+
changed = true
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
if !changed {
|
|
1972
|
+
return engines
|
|
1973
|
+
}
|
|
1974
|
+
out, err := json.Marshal(rows)
|
|
1975
|
+
if err != nil {
|
|
1976
|
+
return engines
|
|
1977
|
+
}
|
|
1978
|
+
return out
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// emptyQuotaValue --quotas 里「没查到」的值形态(omitempty 后本不该出现,
|
|
1982
|
+
// 但显式空串 / 空对象也要当成没有,别覆盖缓存里的旧值)。
|
|
1983
|
+
func emptyQuotaValue(v any) bool {
|
|
1984
|
+
switch t := v.(type) {
|
|
1985
|
+
case nil:
|
|
1986
|
+
return true
|
|
1987
|
+
case string:
|
|
1988
|
+
return strings.TrimSpace(t) == ""
|
|
1989
|
+
case map[string]any:
|
|
1990
|
+
return len(t) == 0
|
|
1991
|
+
case []any:
|
|
1992
|
+
return len(t) == 0
|
|
1993
|
+
}
|
|
1994
|
+
return false
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// handleCheckin POST /desk/checkin:领取某引擎账号的每日积分(core --checkin <engine>)。
|
|
1998
|
+
//
|
|
1999
|
+
// 只做转发:读本机票据、调账单接口都在 core;这里负责参数校验与有界返回。
|
|
2000
|
+
// 业务失败(如「今天已经领过了」)也是 200 —— 响应体是 core 的
|
|
2001
|
+
// {engine, ok:false, message},客户端直接显示原因,不当成网络错误。
|
|
2002
|
+
func (s *Service) handleCheckin(w http.ResponseWriter, r *http.Request) {
|
|
2003
|
+
if r.Method != http.MethodPost {
|
|
2004
|
+
writeErr(w, http.StatusMethodNotAllowed, "/desk/checkin 只支持 POST")
|
|
2005
|
+
return
|
|
2006
|
+
}
|
|
2007
|
+
var req struct {
|
|
2008
|
+
Engine string `json:"engine"`
|
|
2009
|
+
}
|
|
2010
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
2011
|
+
writeErr(w, http.StatusBadRequest, `请求体不是合法 JSON:需要 {"engine":"codebuddy"}`)
|
|
2012
|
+
return
|
|
2013
|
+
}
|
|
2014
|
+
engine := strings.TrimSpace(req.Engine)
|
|
2015
|
+
if engine == "" {
|
|
2016
|
+
writeErr(w, http.StatusBadRequest, "缺少 engine")
|
|
2017
|
+
return
|
|
2018
|
+
}
|
|
2019
|
+
out, err := s.runCore(r.Context(), "--checkin", engine)
|
|
2020
|
+
if err != nil {
|
|
2021
|
+
code := http.StatusBadGateway
|
|
2022
|
+
if errors.Is(err, errCoreTimeout) {
|
|
2023
|
+
code = http.StatusGatewayTimeout
|
|
2024
|
+
}
|
|
2025
|
+
writeErr(w, code, fmt.Sprintf("--checkin %s 失败:%v", engine, err))
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
// 领到 / 发现「今天已签」后同步兜底缓存,避免重开弹层又显示「领取」。
|
|
2029
|
+
patchEnginesCacheAfterCheckin(out)
|
|
2030
|
+
writeCoreJSON(w, out)
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1816
2033
|
// askRequest POST /desk/ask 的请求体。
|
|
1817
2034
|
type askRequest struct {
|
|
1818
|
-
Engine
|
|
1819
|
-
Prompt
|
|
1820
|
-
Model
|
|
1821
|
-
|
|
2035
|
+
Engine string `json:"engine"`
|
|
2036
|
+
Prompt string `json:"prompt"`
|
|
2037
|
+
Model string `json:"model,omitempty"`
|
|
2038
|
+
// FallbackEngine / FallbackModel 主引擎的备用(2026-10-24,只增字段)。
|
|
2039
|
+
// 主引擎失败且**零产出**时 core 自动降级一次;空 = 不降级(老客户端行为不变)。
|
|
2040
|
+
FallbackEngine string `json:"fallbackEngine,omitempty"`
|
|
2041
|
+
FallbackModel string `json:"fallbackModel,omitempty"`
|
|
2042
|
+
Workspace string `json:"workspace,omitempty"`
|
|
1822
2043
|
// Permission 四档权限模型(manual | accept-edits | auto | full),透传给 core 的
|
|
1823
2044
|
// --permission。空 = 不传,由 core 用它自己的默认值(agent.DefaultPermissionTier)。
|
|
1824
2045
|
// ⚠️ core 只在**显式传过** --permission 时才校验引擎是否支持(当前 claude / codebuddy /
|
|
@@ -1893,6 +2114,10 @@ type teamRequest struct {
|
|
|
1893
2114
|
type teamRoute struct {
|
|
1894
2115
|
Engine string `json:"engine,omitempty"`
|
|
1895
2116
|
Model string `json:"model,omitempty"`
|
|
2117
|
+
// FallbackEngine / FallbackModel 这一路的备用(2026-10-24,只增字段)。
|
|
2118
|
+
// 主引擎失败且零产出时自动降级一次;空 = 不降级。
|
|
2119
|
+
FallbackEngine string `json:"fallbackEngine,omitempty"`
|
|
2120
|
+
FallbackModel string `json:"fallbackModel,omitempty"`
|
|
1896
2121
|
}
|
|
1897
2122
|
|
|
1898
2123
|
// askRun 一次 ask(跑完的还会短时保留,供断线续传补拉终态)。
|
|
@@ -1919,6 +2144,12 @@ type askRun struct {
|
|
|
1919
2144
|
登记表里这条会话只有 run_id,/desk/asks 要把两者都给客户端才能认领。受 mu 保护。 */
|
|
1920
2145
|
run string
|
|
1921
2146
|
|
|
2147
|
+
/* fbEngine / fbModel 本轮**由备用引擎接上**时记下是谁(扫 type=fallback 事件,
|
|
2148
|
+
2026-10-26)。作业记录靠它回答「主备到底生效没有」—— 以前那份记录里只有
|
|
2149
|
+
配置、没有结果,用户报障时无从判断。受 mu 保护;没降级时为空。 */
|
|
2150
|
+
fbEngine string
|
|
2151
|
+
fbModel string
|
|
2152
|
+
|
|
1922
2153
|
/* 事件回放缓冲(2026-10-19 断线续传):core 的每一行原样留一份,客户端拿
|
|
1923
2154
|
`GET /desk/ask/{id}/events?after=<seq>` 按 seq 增量补拉。
|
|
1924
2155
|
为什么不直接复用会话日志(`/desk/session/{id}/events`):
|
|
@@ -2015,6 +2246,19 @@ func (r *askRun) appendEvent(line string) {
|
|
|
2015
2246
|
if seq > r.seq {
|
|
2016
2247
|
r.seq = seq
|
|
2017
2248
|
}
|
|
2249
|
+
/* 主备降级帧(2026-10-26):只留 to 端,作业收尾时写进作业记录。
|
|
2250
|
+
只在长度够时才解析 —— 绝大多数行都不是 fallback。 */
|
|
2251
|
+
if len(line) > 24 && strings.Contains(line, `"type":"fallback"`) {
|
|
2252
|
+
var fe struct {
|
|
2253
|
+
Fallback *struct {
|
|
2254
|
+
ToEngine string `json:"to_engine"`
|
|
2255
|
+
ToModel string `json:"to_model"`
|
|
2256
|
+
} `json:"fallback"`
|
|
2257
|
+
}
|
|
2258
|
+
if json.Unmarshal([]byte(line), &fe) == nil && fe.Fallback != nil {
|
|
2259
|
+
r.fbEngine, r.fbModel = fe.Fallback.ToEngine, fe.Fallback.ToModel
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2018
2262
|
r.events = append(r.events, askEvent{seq: seq, data: []byte(line)})
|
|
2019
2263
|
size := 0
|
|
2020
2264
|
for _, e := range r.events {
|
|
@@ -2053,6 +2297,13 @@ func (r *askRun) finish(code int) {
|
|
|
2053
2297
|
r.mu.Unlock()
|
|
2054
2298
|
}
|
|
2055
2299
|
|
|
2300
|
+
// fallbackState 本轮降级到了哪个引擎 / 模型(没降级返回空)。
|
|
2301
|
+
func (r *askRun) fallbackState() (engine, model string) {
|
|
2302
|
+
r.mu.Lock()
|
|
2303
|
+
defer r.mu.Unlock()
|
|
2304
|
+
return r.fbEngine, r.fbModel
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2056
2307
|
// snapshot 取 seq > after 的事件(after=0 时额外放行网关合成的用户提问,seq=0)。
|
|
2057
2308
|
// 与 handleAskEvents 的返回口径一致,供 SSE 中继与作业事件共用。
|
|
2058
2309
|
func (r *askRun) snapshot(after uint64) (out []askEvent, lastSeq uint64, done bool, code int, stderr string) {
|
|
@@ -2172,6 +2423,17 @@ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
|
|
|
2172
2423
|
if req.Model != "" {
|
|
2173
2424
|
args = append(args, "-m", req.Model)
|
|
2174
2425
|
}
|
|
2426
|
+
/* 主备(2026-10-24):备用引擎同样要过白名单,否则会绕过「这台桌面允许用哪些引擎」。
|
|
2427
|
+
主引擎失败且零产出时由 core 自动降级(见 agent/fallback.go)。 */
|
|
2428
|
+
if fb := strings.TrimSpace(req.FallbackEngine); fb != "" {
|
|
2429
|
+
if !s.opts.Profile.AllowsEngine(fb) {
|
|
2430
|
+
return "", nil, http.StatusForbidden, fmt.Errorf("profile 白名单不允许备用引擎 %q", fb)
|
|
2431
|
+
}
|
|
2432
|
+
args = append(args, "--fallback-engine", fb)
|
|
2433
|
+
if m := strings.TrimSpace(req.FallbackModel); m != "" {
|
|
2434
|
+
args = append(args, "--fallback-model", m)
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2175
2437
|
if req.Workspace != "" {
|
|
2176
2438
|
args = append(args, "-w", req.Workspace)
|
|
2177
2439
|
}
|
|
@@ -2250,16 +2512,20 @@ stdout 全部进 askRun 的回放缓冲,谁来读(SSE / /desk/ask/{id}/event
|
|
|
2250
2512
|
*/
|
|
2251
2513
|
func (s *Service) startAsk(req askRequest, engine string, args []string, source string) (*askRun, error) {
|
|
2252
2514
|
ctx := context.WithoutCancel(context.Background())
|
|
2253
|
-
cmd :=
|
|
2515
|
+
cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
|
|
2254
2516
|
/* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
|
|
2255
2517
|
· EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
|
|
2256
2518
|
· EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
|
|
2257
|
-
|
|
2258
|
-
时先抢锁再写 running,让直接跑 CLI
|
|
2519
|
+
工作空间锁(选入式,见 internal/session/workspace_lock.go)**只对串行作业注入**:
|
|
2520
|
+
core 在 Begin 时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行;
|
|
2521
|
+
并行作业注入了锁就会在同空间的 core Begin 处重新排队,把「并行」架空 ——
|
|
2522
|
+
模式互斥的裁决只在队列这一层做(jobs.go dispatch)。 */
|
|
2259
2523
|
cmd.Env = append(os.Environ(),
|
|
2260
2524
|
session.EnvSource+"="+source,
|
|
2261
|
-
session.EnvClient+"="+s.opts.Profile.AppID()
|
|
2262
|
-
|
|
2525
|
+
session.EnvClient+"="+s.opts.Profile.AppID())
|
|
2526
|
+
if req.Serial == nil || *req.Serial {
|
|
2527
|
+
cmd.Env = append(cmd.Env, session.EnvWorkspaceLock+"=1")
|
|
2528
|
+
}
|
|
2263
2529
|
stdin, err := cmd.StdinPipe()
|
|
2264
2530
|
if err != nil {
|
|
2265
2531
|
return nil, fmt.Errorf("stdin pipe: %w", err)
|
|
@@ -2422,13 +2688,20 @@ func (s *Service) handleAskSub(w http.ResponseWriter, r *http.Request) {
|
|
|
2422
2688
|
Op string `json:"op"`
|
|
2423
2689
|
Text string `json:"text,omitempty"`
|
|
2424
2690
|
Reason string `json:"reason,omitempty"`
|
|
2691
|
+
/* ask_answer(原地回审批,2026-10-25):这三项必须原样透传给 core 的 --control。
|
|
2692
|
+
顶层 decode 只认 op/text/reason 的话,它们会在 re-marshal 时被丢掉,
|
|
2693
|
+
症状是客户端明明点了「允许」,core 却回 control_error「需要 request_id」。 */
|
|
2694
|
+
RequestID string `json:"request_id,omitempty"`
|
|
2695
|
+
Allowed *bool `json:"allowed,omitempty"`
|
|
2696
|
+
Message string `json:"message,omitempty"`
|
|
2697
|
+
ToolName string `json:"tool_name,omitempty"`
|
|
2425
2698
|
}
|
|
2426
2699
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&cmd); err != nil {
|
|
2427
2700
|
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
|
|
2428
2701
|
return
|
|
2429
2702
|
}
|
|
2430
2703
|
if strings.TrimSpace(cmd.Op) == "" {
|
|
2431
|
-
writeErr(w, http.StatusBadRequest, "op 不能为空(ping / interrupt / stop / answer)")
|
|
2704
|
+
writeErr(w, http.StatusBadRequest, "op 不能为空(ping / interrupt / stop / answer / ask_answer)")
|
|
2432
2705
|
return
|
|
2433
2706
|
}
|
|
2434
2707
|
b, _ := json.Marshal(cmd)
|
|
@@ -2577,6 +2850,19 @@ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
|
|
|
2577
2850
|
}
|
|
2578
2851
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
2579
2852
|
if p == "" {
|
|
2853
|
+
// 手机浏览器打开根地址 → 直接给移动端界面(/m/)。
|
|
2854
|
+
// "/" 是 PC 宿主页(见 client-ui/index.html 的文件头),手机上打开只会得到
|
|
2855
|
+
// 被窄屏挤压的桌面布局;移动端有专门的 client-ui-mobile。
|
|
2856
|
+
// 只跳「/m/ 真的能给出页面」的情况(hasMobileUI),否则照旧给 PC 页 —— 否则
|
|
2857
|
+
// 手机用户会落到一个 404 上。
|
|
2858
|
+
if isMobileUA(r.UserAgent()) && s.hasMobileUI() {
|
|
2859
|
+
target := "/m/"
|
|
2860
|
+
if q := r.URL.RawQuery; q != "" {
|
|
2861
|
+
target += "?" + q
|
|
2862
|
+
}
|
|
2863
|
+
http.Redirect(w, r, target, http.StatusFound)
|
|
2864
|
+
return
|
|
2865
|
+
}
|
|
2580
2866
|
p = s.opts.Profile.UI.Entry
|
|
2581
2867
|
if p == "" {
|
|
2582
2868
|
p = "index.html"
|
|
@@ -2590,6 +2876,18 @@ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
|
|
|
2590
2876
|
http.ServeFile(w, r, full)
|
|
2591
2877
|
}
|
|
2592
2878
|
|
|
2879
|
+
// isMobileUA 粗判手机浏览器(够用即可,不追求 UA 库的完备度)。
|
|
2880
|
+
// iPadOS 13+ 默认发桌面 UA(Macintosh),这里刻意不认 —— 它本来就更适合桌面布局。
|
|
2881
|
+
func isMobileUA(ua string) bool {
|
|
2882
|
+
ua = strings.ToLower(ua)
|
|
2883
|
+
for _, k := range []string{"iphone", "ipod", "android", "mobile", "windows phone"} {
|
|
2884
|
+
if strings.Contains(ua, k) {
|
|
2885
|
+
return true
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
return false
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2593
2891
|
// mobileRoot 解析移动端界面目录:显式 --mobile-dir 优先,否则取与 --ui-dir 同级的 client-ui-mobile。
|
|
2594
2892
|
func (s *Service) mobileRoot() string {
|
|
2595
2893
|
if d := strings.TrimSpace(s.opts.MobileRoot); d != "" {
|
|
@@ -2852,10 +3150,16 @@ func isMediaFile(head []byte, path string) bool {
|
|
|
2852
3150
|
if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") {
|
|
2853
3151
|
return true
|
|
2854
3152
|
}
|
|
3153
|
+
// HTML 产物(2026-10-24):对话内核的产物卡用 <iframe src=/desk/media?path=…> 就地预览
|
|
3154
|
+
// .html/.htm —— 放行 text/html(内容嗅探为主、扩展名兜底,与图 / 视频同一口径)。
|
|
3155
|
+
if strings.HasPrefix(ct, "text/html") {
|
|
3156
|
+
return true
|
|
3157
|
+
}
|
|
2855
3158
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
2856
3159
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
|
2857
3160
|
".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi",
|
|
2858
|
-
".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"
|
|
3161
|
+
".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac",
|
|
3162
|
+
".html", ".htm", ".xhtml":
|
|
2859
3163
|
return true
|
|
2860
3164
|
}
|
|
2861
3165
|
return false
|
|
@@ -718,6 +718,57 @@ func TestModulesStatic(t *testing.T) {
|
|
|
718
718
|
}
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
+
// TestRootServesMobileUIToPhone 手机打开 "/" 必须落到移动端界面,且令牌随查询串带过去;
|
|
722
|
+
// 桌面 UA、以及「移动端界面不存在」两种情况仍给 PC 页 —— 后者保证不会把手机送进 404。
|
|
723
|
+
func TestRootServesMobileUIToPhone(t *testing.T) {
|
|
724
|
+
const iphoneUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
|
725
|
+
const desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
|
726
|
+
|
|
727
|
+
uiDir, mobileDir := t.TempDir(), t.TempDir()
|
|
728
|
+
for dir, body := range map[string]string{uiDir: "PC", mobileDir: "MOBILE"} {
|
|
729
|
+
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(body), 0o644); err != nil {
|
|
730
|
+
t.Fatal(err)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// 直接构造 Service、只测 handleStatic:这条逻辑不碰 core,测试也就不需要假 core 子进程
|
|
734
|
+
// (那是个 sh 脚本,Windows 上跑不起来,见 fakeCore)。
|
|
735
|
+
newSvc := func(mobileRoot string) *Service {
|
|
736
|
+
return &Service{opts: Options{Profile: DefaultProfile(), UIRoot: uiDir, MobileRoot: mobileRoot}}
|
|
737
|
+
}
|
|
738
|
+
get := func(t *testing.T, svc *Service, path, ua string) *httptest.ResponseRecorder {
|
|
739
|
+
t.Helper()
|
|
740
|
+
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
741
|
+
req.Header.Set("User-Agent", ua)
|
|
742
|
+
rec := httptest.NewRecorder()
|
|
743
|
+
svc.handleStatic(rec, req)
|
|
744
|
+
return rec
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
t.Run("手机访问根路径跳移动端", func(t *testing.T) {
|
|
748
|
+
rec := get(t, newSvc(mobileDir), "/?token=tok", iphoneUA)
|
|
749
|
+
if rec.Code != http.StatusFound {
|
|
750
|
+
t.Fatalf("应 302,实际 %d", rec.Code)
|
|
751
|
+
}
|
|
752
|
+
if loc := rec.Header().Get("Location"); loc != "/m/?token=tok" {
|
|
753
|
+
t.Errorf("跳转目标不对:%s", loc)
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
t.Run("桌面访问根路径仍是PC页", func(t *testing.T) {
|
|
758
|
+
rec := get(t, newSvc(mobileDir), "/?token=tok", desktopUA)
|
|
759
|
+
if rec.Code != http.StatusOK || rec.Body.String() != "PC" {
|
|
760
|
+
t.Errorf("桌面应拿到 PC 页,实际 %d / %q", rec.Code, rec.Body.String())
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
t.Run("没有移动端界面时不跳", func(t *testing.T) {
|
|
765
|
+
rec := get(t, newSvc(t.TempDir()), "/?token=tok", iphoneUA) // 空目录 ⇒ hasMobileUI=false
|
|
766
|
+
if rec.Code != http.StatusOK || rec.Body.String() != "PC" {
|
|
767
|
+
t.Errorf("应退回 PC 页,实际 %d / %q", rec.Code, rec.Body.String())
|
|
768
|
+
}
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
|
|
721
772
|
func TestUploadReadBackAndTraversalGuard(t *testing.T) {
|
|
722
773
|
svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: DefaultProfile(), Token: "tok"})
|
|
723
774
|
if err != nil {
|