magic-agent 1.0.3 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -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/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -3,169 +3,2483 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
6
|
-
<title
|
|
6
|
+
<title>magic-agent · 远程控制</title>
|
|
7
7
|
<!--
|
|
8
8
|
移动端宿主:把**同一个**公共对话组件(shared/conversation.js)挂到页面上。
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
9
|
+
|
|
10
|
+
── 形态:照搬 ZCode 移动端(packages/android)的远程连接方案 ──
|
|
11
|
+
ZCode 的壳只有两块屏,这里逐块对应:
|
|
12
|
+
1) RemoteControlLauncher(RemoteControlApp.kt:393-515)
|
|
13
|
+
→ 本文件的「启动页」:标题 + 说明 + 连接地址输入框 + 打开/扫码 + 最近连接列表
|
|
14
|
+
2) RemoteControlWebViewScreen + RemoteControlTopBar(:517-644)
|
|
15
|
+
→ 本文件的「会话页」:顶栏(返回 + 标题/地址 + 动作)+ 对话区
|
|
16
|
+
两块屏的切换由 `remoteUrl == null ? Launcher : Screen` 决定;这里同构:
|
|
17
|
+
没有连接目标就显示启动页,连上(或 App 注入配对信息)就显示会话页。
|
|
18
|
+
|
|
19
|
+
── 与 ZCode 的差异(刻意的)──
|
|
20
|
+
ZCode 桌面 → zcode.z.ai 中继 → 手机,跨网靠云端 relay(WebSocket + 二进制 RPC +
|
|
21
|
+
HMAC 握手,见 web/src/webRemoteControlTransport.ts、client/src/webRemoteControlRelayProtocol.ts)。
|
|
22
|
+
magic-agent 自己**不跑云**:它的等价物是局域网直连 —— 桌面 `--lan` 起服务后
|
|
23
|
+
`/pair` 出「地址 + 令牌」的二维码,手机连的就是 `/desk/*`(见 CLIENT-CONTRACT.md 2.5)。
|
|
24
|
+
所以这里复刻的是**方案与交互**(扫码/粘贴 → 连接 → 会话 → 断开),传输换成 magic-agent。
|
|
25
|
+
|
|
26
|
+
── 视觉基准(逐值抄自 ZCode 安卓端 Compose 源码)──
|
|
27
|
+
ui/theme/Color.kt / Theme.kt → Material3 色令牌
|
|
28
|
+
ui/RemoteControlApp.kt:600-644 → RemoteControlTopBar(48dp · 标题+副标题在左 · 动作在右)
|
|
29
|
+
ui/RemoteControlApp.kt:407-513 → Launcher(20dp 内边距 · 标题/说明/OutlinedTextField/Button/OutlinedButton/最近列表)
|
|
29
30
|
-->
|
|
30
31
|
<link rel="stylesheet" href="shared/conversation.css">
|
|
32
|
+
<!-- 对话内核那套 DOM(.qa-*)的**共用皮肤**:三端同一份(见 shared/conv/conv.css 的文件头)。
|
|
33
|
+
里面的取值全走令牌,本页只负责给令牌(下面的 :root)—— 皮肤本身不定义颜色。 -->
|
|
34
|
+
<link rel="stylesheet" href="shared/conv/conv.css">
|
|
31
35
|
<style>
|
|
32
|
-
/* ===== 令牌:
|
|
33
|
-
⚠️ 默认 = 暗色:跟
|
|
34
|
-
用户在系统里选了亮色才切亮色(没设主题时不会莫名跑出白底)。 */
|
|
36
|
+
/* ===== 令牌:ZCode 安卓端那一套,映射到共享组件的颜色契约 =====
|
|
37
|
+
⚠️ 默认 = 暗色:跟 ZCode 的 DarkColorScheme 逐字段对齐。 */
|
|
35
38
|
:root{
|
|
36
39
|
color-scheme:dark;
|
|
37
|
-
--canvas:#
|
|
38
|
-
--ink:#
|
|
39
|
-
--border:rgba(255,255,255,.
|
|
40
|
-
--primary-action:#
|
|
41
|
-
--app-shadow:rgba(0,0,0,.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
--
|
|
45
|
-
--
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
--
|
|
49
|
-
--
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
--
|
|
53
|
-
--
|
|
54
|
-
|
|
55
|
-
/*
|
|
56
|
-
--
|
|
57
|
-
--
|
|
40
|
+
--canvas:#111827; /* background = NeutralDarkBackground */
|
|
41
|
+
--ink:#F9FAFB; --ink-soft:#E5E7EB; --muted:#CBD5E1; --faint:#94A3B8;
|
|
42
|
+
--border:rgba(255,255,255,.10); --subtle:#374151; --raised:#1F2937;
|
|
43
|
+
--primary-action:#93C5FD; --on-primary-action:#111827; /* primary = BrandBlueDark / onPrimary */
|
|
44
|
+
--app-shadow:rgba(0,0,0,.45);
|
|
45
|
+
|
|
46
|
+
/* 用户气泡 = surface/foreground:ZCode 的用户消息是**中性表面**不是品牌色气泡 */
|
|
47
|
+
--bubble:#1F2937; --bubble-text:#F9FAFB;
|
|
48
|
+
--code:#0F172A; --status-neutral:#374151; --status-neutral-text:#CBD5E1;
|
|
49
|
+
--activity:rgba(147,197,253,.12);
|
|
50
|
+
|
|
51
|
+
--md-body:#F9FAFB; --md-muted:#CBD5E1; --md-border:#374151; --md-code-bg:#1E293B;
|
|
52
|
+
--link:#93C5FD; --prompt:#93C5FD; --shimmer:#FFFFFF;
|
|
53
|
+
--panel:#1F2937; --panel-border:#374151;
|
|
54
|
+
|
|
55
|
+
--accent:#93C5FD; --warning:#FBBF24; --success:#22C55E;
|
|
56
|
+
--error-surface:#3B1113; --error-border:#7F1D1D; --error-text:#FCA5A5; --error-icon:#F87171;
|
|
57
|
+
--running:#22C55E; --transitioning:#60A5FA; --active:#FBBF24;
|
|
58
|
+
/* 状态色底(半透明叠加色,明暗主题通用) */
|
|
59
|
+
--tint-run:rgba(59,130,246,.18); --tint-wait:rgba(245,158,11,.20); --tint-done:rgba(34,197,94,.18);
|
|
60
|
+
--tint-queued:rgba(139,92,246,.18); --queued:#8B5CF6;
|
|
61
|
+
|
|
62
|
+
/* ── 掌天瓶绿液(可选令牌,shared/conversation.css 用 `var(--q-bottle*, 兜底)` 取)──
|
|
63
|
+
兜底那套绿是共享样式自带的,之所以这里还显式给一份:移动端的绿要跟 --running 同族的
|
|
64
|
+
鲜绿(#22C55E),而兜底那份偏薄荷(#34D27B),在深色底上会显得发灰。 */
|
|
65
|
+
--q-bottle-hi:#7DF3A8; --q-bottle:#22C55E; --q-bottle-lo:#0F7B3C;
|
|
66
|
+
--q-bottle-line:#94A3B8; --q-bottle-cork:#94A3B8;
|
|
67
|
+
--q-bottle-glow:rgba(34,197,94,.30); --q-bottle-shell:rgba(255,255,255,.03);
|
|
68
|
+
|
|
69
|
+
--r-card:16px; --r:12px; --r-full:999px;
|
|
70
|
+
--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans CJK SC","Segoe UI",Roboto,system-ui,sans-serif;
|
|
58
71
|
--mono:ui-monospace,SFMono-Regular,Menlo,"SF Mono",monospace;
|
|
72
|
+
|
|
73
|
+
/* ── 对话皮肤的两个**移动端基线值**(与主题无关,所以放在默认那一块里)──
|
|
74
|
+
共用皮肤(shared/conv/conv.css)用 `var(--qa-bubble-r, …)` / `var(--qa-body-lh, …)` 取它们:
|
|
75
|
+
皮肤三端一份,基线各端给自己的 —— 这两个值就是 tests/check-ui-spec.mjs 里钉的那两条。 */
|
|
76
|
+
--qa-bubble-r:12px 4px 12px 12px; /* ConversationUserInputBubble.tsx:27 rounded-xl + rounded-tr-xs */
|
|
77
|
+
--qa-body-lh:28px; /* ai-elements/message.tsx MessageResponse leading-[1.75] */
|
|
59
78
|
}
|
|
60
79
|
@media (prefers-color-scheme:light){
|
|
61
80
|
:root{
|
|
62
81
|
color-scheme:light;
|
|
63
|
-
--canvas:#
|
|
64
|
-
--ink:#
|
|
65
|
-
--border:#
|
|
66
|
-
--primary-action:#
|
|
67
|
-
--app-shadow:rgba(
|
|
68
|
-
--bubble:#
|
|
69
|
-
--code
|
|
70
|
-
--activity:rgba(
|
|
71
|
-
--md-body:#
|
|
72
|
-
--link:#
|
|
73
|
-
--panel:#
|
|
74
|
-
--accent:#
|
|
75
|
-
--error-surface:#
|
|
82
|
+
--canvas:#F8FAFC;
|
|
83
|
+
--ink:#111827; --ink-soft:#1F2937; --muted:#64748B; --faint:#94A3B8;
|
|
84
|
+
--border:#E2E8F0; --subtle:#F1F5F9; --raised:#FFFFFF;
|
|
85
|
+
--primary-action:#2563EB; --on-primary-action:#FFFFFF;
|
|
86
|
+
--app-shadow:rgba(15,23,42,.12);
|
|
87
|
+
--bubble:#FFFFFF; --bubble-text:#111827;
|
|
88
|
+
--code:#F1F5F9; --status-neutral:#E2E8F0; --status-neutral-text:#475569;
|
|
89
|
+
--activity:rgba(37,99,235,.08);
|
|
90
|
+
--md-body:#111827; --md-muted:#64748B; --md-border:#E2E8F0; --md-code-bg:#F1F5F9;
|
|
91
|
+
--link:#2563EB; --prompt:#2563EB; --shimmer:#0F172A;
|
|
92
|
+
--panel:#FFFFFF; --panel-border:#E2E8F0;
|
|
93
|
+
--accent:#2563EB; --warning:#D97706; --success:#16A34A;
|
|
94
|
+
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
95
|
+
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
76
96
|
}
|
|
77
97
|
}
|
|
98
|
+
/* 手动主题:在「我的」页显式选了才生效(属性选择器优先级高于上面的 :root / media) */
|
|
99
|
+
html[data-theme="light"]{color-scheme:light;
|
|
100
|
+
--canvas:#F8FAFC;
|
|
101
|
+
--ink:#111827; --ink-soft:#1F2937; --muted:#64748B; --faint:#94A3B8;
|
|
102
|
+
--border:#E2E8F0; --subtle:#F1F5F9; --raised:#FFFFFF;
|
|
103
|
+
--primary-action:#2563EB; --on-primary-action:#FFFFFF;
|
|
104
|
+
--app-shadow:rgba(15,23,42,.12);
|
|
105
|
+
--bubble:#FFFFFF; --bubble-text:#111827;
|
|
106
|
+
--code:#F1F5F9; --status-neutral:#E2E8F0; --status-neutral-text:#475569;
|
|
107
|
+
--activity:rgba(37,99,235,.08);
|
|
108
|
+
--md-body:#111827; --md-muted:#64748B; --md-border:#E2E8F0; --md-code-bg:#F1F5F9;
|
|
109
|
+
--link:#2563EB; --prompt:#2563EB; --shimmer:#0F172A;
|
|
110
|
+
--panel:#FFFFFF; --panel-border:#E2E8F0;
|
|
111
|
+
--accent:#2563EB; --warning:#D97706; --success:#16A34A;
|
|
112
|
+
--error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
|
|
113
|
+
--running:#16A34A; --transitioning:#2563EB; --active:#D97706;
|
|
114
|
+
}
|
|
115
|
+
html[data-theme="dark"]{color-scheme:dark;
|
|
116
|
+
--canvas:#111827;
|
|
117
|
+
--ink:#F9FAFB; --ink-soft:#E5E7EB; --muted:#CBD5E1; --faint:#94A3B8;
|
|
118
|
+
--border:rgba(255,255,255,.10); --subtle:#374151; --raised:#1F2937;
|
|
119
|
+
--primary-action:#93C5FD; --on-primary-action:#111827;
|
|
120
|
+
--app-shadow:rgba(0,0,0,.45);
|
|
121
|
+
--bubble:#1F2937; --bubble-text:#F9FAFB;
|
|
122
|
+
--code:#0F172A; --status-neutral:#374151; --status-neutral-text:#CBD5E1;
|
|
123
|
+
--activity:rgba(147,197,253,.12);
|
|
124
|
+
--md-body:#F9FAFB; --md-muted:#CBD5E1; --md-border:#374151; --md-code-bg:#1E293B;
|
|
125
|
+
--link:#93C5FD; --prompt:#93C5FD; --shimmer:#FFFFFF;
|
|
126
|
+
--panel:#1F2937; --panel-border:#374151;
|
|
127
|
+
--accent:#93C5FD; --warning:#FBBF24; --success:#22C55E;
|
|
128
|
+
--error-surface:#3B1113; --error-border:#7F1D1D; --error-text:#FCA5A5; --error-icon:#F87171;
|
|
129
|
+
--running:#22C55E; --transitioning:#60A5FA; --active:#FBBF24;
|
|
130
|
+
}
|
|
78
131
|
|
|
79
132
|
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
[hidden]{display:none!important}
|
|
134
|
+
/* 启动闪屏那行手写体字标用的字体:assets/caveat.woff2(与 PC 宿主同一份字形)。
|
|
135
|
+
⚠️ 必须显式声明,光有文件不会生效 —— 没声明时 `font-family:'AA Caveat'` 会静默回落 cursive。 */
|
|
136
|
+
@font-face{font-family:'AA Caveat';src:url('assets/caveat.woff2') format('woff2');
|
|
137
|
+
font-display:swap;font-weight:400 700}
|
|
138
|
+
/* ⚠️ 锁死视口高度而不是 min-height:否则滚动条落到 document 上,对话区不滚。 */
|
|
83
139
|
html,body{margin:0;height:100%;overflow:hidden}
|
|
84
|
-
body{background:var(--canvas);color:var(--ink);font:
|
|
140
|
+
body{background:var(--canvas);color:var(--ink);font:16px/1.5 var(--font);
|
|
85
141
|
overscroll-behavior:none;display:flex;flex-direction:column}
|
|
86
142
|
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
|
|
87
143
|
svg{display:block}
|
|
144
|
+
textarea{font:inherit}
|
|
145
|
+
|
|
146
|
+
/* ══════════ 启动页 RemoteControlLauncher(RemoteControlApp.kt:393-515)══════════
|
|
147
|
+
Column · fillMaxSize · statusBars+navigationBars padding · verticalScroll · padding 20dp
|
|
148
|
+
verticalArrangement = Center */
|
|
149
|
+
.launcher{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;justify-content:center;
|
|
150
|
+
padding:calc(env(safe-area-inset-top) + 20px) 20px calc(20px + env(safe-area-inset-bottom))}
|
|
151
|
+
/* headlineSmall ≈ 24sp */
|
|
152
|
+
.launcher__title{font-size:24px;font-weight:400;line-height:32px;margin:0;color:var(--ink)}
|
|
153
|
+
/* 说明 bodyMedium(onSurfaceVariant) */
|
|
154
|
+
.launcher__desc{margin:8px 0 0;font-size:14px;line-height:20px;color:var(--muted)}
|
|
155
|
+
/* OutlinedTextField:1dp 描边 · 4dp 圆角(Material3 默认) */
|
|
156
|
+
.field{margin-top:20px;border:1px solid var(--border);border-radius:4px;padding:8px 12px;
|
|
157
|
+
background:transparent}
|
|
158
|
+
.field:focus-within{border-color:var(--primary-action)}
|
|
159
|
+
.field.is-error{border-color:var(--error-text)}
|
|
160
|
+
.field label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin-bottom:4px}
|
|
161
|
+
.field textarea{display:block;width:100%;border:0;outline:0;resize:none;background:none;
|
|
162
|
+
color:var(--ink);font:15px/20px var(--mono);min-height:40px}
|
|
163
|
+
.field__err{color:var(--error-text);font-size:12px;line-height:16px;margin-top:6px}
|
|
164
|
+
/* Button(filled)/ OutlinedButton:40dp 高 · 全圆角 */
|
|
165
|
+
.btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;height:40px;
|
|
166
|
+
border-radius:20px;font-size:14px;font-weight:600}
|
|
167
|
+
.btn--filled{margin-top:12px;background:var(--primary-action);color:var(--on-primary-action)}
|
|
168
|
+
.btn--outlined{margin-top:8px;border:1px solid var(--border);color:var(--primary-action)}
|
|
169
|
+
.btn:active{opacity:.9}
|
|
170
|
+
/* 最近连接列表:标题 outline + 每条 OutlinedButton(标题 bodyMedium + 副标题 labelSmall) */
|
|
171
|
+
.recent{margin-top:20px}
|
|
172
|
+
.recent__head{display:flex;align-items:center}
|
|
173
|
+
.recent__title{flex:1;font-size:14px;font-weight:600;color:var(--ink)}
|
|
174
|
+
.recent__clear{color:var(--primary-action);font-size:13px;font-weight:600;padding:8px 12px}
|
|
175
|
+
.recent__item{display:flex;flex-direction:column;gap:2px;width:100%;margin-top:8px;text-align:left;
|
|
176
|
+
border:1px solid var(--border);border-radius:20px;padding:10px 16px}
|
|
177
|
+
.recent__t{font-size:14px;line-height:20px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
178
|
+
.recent__s{font-size:12px;line-height:16px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
179
|
+
|
|
180
|
+
/* ══════════ 会话页 RemoteControlWebViewScreen + TopBar(:517-644)══════════ */
|
|
181
|
+
.session{flex:1;min-height:0;display:flex;flex-direction:column}
|
|
182
|
+
/* 顶栏 RemoteControlTopBar(RemoteControlApp.kt:607-614):
|
|
183
|
+
statusBarsPadding · height(48dp) · padding(horizontal=12dp)。
|
|
184
|
+
左 Column(weight=1){titleSmall 14/20/500, labelSmall 11/16/500};右 TextButton(labelLarge 14/20/500)。 */
|
|
185
|
+
.topbar{display:flex;align-items:center;gap:4px;flex:0 0 auto;box-sizing:border-box;
|
|
186
|
+
padding:env(safe-area-inset-top) 12px 0;height:calc(48px + env(safe-area-inset-top))}
|
|
187
|
+
.topbar__col{display:flex;flex-direction:column;justify-content:center;flex:1;min-width:0;gap:1px}
|
|
188
|
+
.topbar__title{font-size:14px;font-weight:500;line-height:20px;color:var(--ink);
|
|
189
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
190
|
+
.topbar__sub{font-size:11px;font-weight:500;line-height:16px;color:var(--muted);
|
|
191
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
192
|
+
.topbar__sub:empty{display:none}
|
|
193
|
+
.topbar__action{display:inline-flex;align-items:center;height:40px;padding:0 12px;
|
|
194
|
+
border-radius:var(--r-full);color:var(--primary-action);font-size:14px;font-weight:500;flex:0 0 auto}
|
|
195
|
+
.topbar__action:active{background:var(--activity)}
|
|
196
|
+
.topbar__icon{width:40px;height:40px;border-radius:var(--r-full);display:flex;align-items:center;
|
|
197
|
+
justify-content:center;color:var(--ink);flex:0 0 auto}
|
|
198
|
+
.topbar__icon:active{background:var(--activity)}
|
|
199
|
+
|
|
200
|
+
/* ══════════ 历史会话列表 ══════════ */
|
|
201
|
+
.history{flex:1;min-height:0;display:flex;flex-direction:column;position:relative}
|
|
202
|
+
.history__top{display:flex;align-items:center;gap:8px;flex:0 0 auto;
|
|
203
|
+
padding:calc(env(safe-area-inset-top) + 10px) 16px 8px}
|
|
204
|
+
.history__title{flex:1;min-width:0;font-size:22px;font-weight:700;color:var(--ink);
|
|
205
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
206
|
+
.history__disconnect{color:var(--primary-action);font-size:14px;font-weight:500;padding:8px 10px}
|
|
207
|
+
.history__list{flex:1;min-height:0;overflow-y:auto;padding:4px 10px calc(96px + env(safe-area-inset-bottom))}
|
|
208
|
+
.history__group{padding:12px 8px 6px;font-size:12px;font-weight:700;color:var(--muted)}
|
|
209
|
+
.history__hint{padding:22px 12px;color:var(--muted);font-size:14px;text-align:center}
|
|
210
|
+
.srow{display:flex;align-items:center;gap:12px;width:100%;padding:12px 8px;border-radius:14px;text-align:left}
|
|
211
|
+
.srow:active{background:var(--subtle)}
|
|
212
|
+
.srow--pending{cursor:default}
|
|
213
|
+
.srow__ico{flex:0 0 auto;width:44px;height:44px;border-radius:50%;background:var(--subtle);
|
|
214
|
+
display:flex;align-items:center;justify-content:center;color:var(--ink);position:relative}
|
|
215
|
+
/* 会话状态:用颜色区分(无角标、无描边) */
|
|
216
|
+
.srow__ico--run{background:var(--tint-run);color:var(--primary-action)}
|
|
217
|
+
.srow__ico--wait{background:var(--tint-wait);color:var(--warning)}
|
|
218
|
+
.srow__ico--done{background:var(--tint-done);color:var(--success)}
|
|
219
|
+
.srow__ico--idle{background:var(--subtle);color:var(--muted)}
|
|
220
|
+
/* 等待中(串行模式排队的新任务):紫色,区别于其它状态 */
|
|
221
|
+
.srow__ico--queued{background:var(--tint-queued);color:var(--queued)}
|
|
222
|
+
/* 进行中:图标外围一圈圆形扫光(conic 渐隐光带,mask 挖空成环) */
|
|
223
|
+
.srow__ico--run::after{content:"";position:absolute;inset:-3px;border-radius:50%;pointer-events:none;
|
|
224
|
+
background:conic-gradient(from 0turn, transparent, var(--primary-action));
|
|
225
|
+
-webkit-mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
|
|
226
|
+
mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
|
|
227
|
+
animation:srow-spin 3.2s linear infinite}
|
|
228
|
+
@keyframes srow-spin{to{transform:rotate(360deg)}}
|
|
229
|
+
@media (prefers-reduced-motion: reduce){.srow__ico--run::after{animation:none}}
|
|
230
|
+
.srow__col{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
231
|
+
.srow__t{font-size:15px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
232
|
+
.srow__s{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
233
|
+
.srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
|
|
234
|
+
/* 会话列表:置顶 / 任务 / 空间 三段 */
|
|
235
|
+
.history__actions{padding:2px 16px 8px}
|
|
236
|
+
.newtask{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;height:44px;border:1px solid var(--border);border-radius:var(--r-full);background:var(--raised);color:var(--ink);font-size:15px;font-weight:500}
|
|
237
|
+
.newtask:active{background:var(--subtle)}
|
|
238
|
+
.sechead{display:flex;align-items:center;gap:6px;padding:14px 8px 4px;color:var(--muted);font-size:12px;font-weight:700}
|
|
239
|
+
.sechead__t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
240
|
+
.sechead__chev{display:inline-flex;transition:transform .15s;color:var(--faint)}
|
|
241
|
+
.sechead__chev.is-open{transform:rotate(90deg)}
|
|
242
|
+
.sechead__x{color:var(--muted);font-size:12px;padding:0 4px}
|
|
243
|
+
.more{padding:10px 8px;color:var(--primary-action);font-size:13px;font-weight:500;text-align:left}
|
|
244
|
+
.srow__pin{flex:0 0 auto;color:var(--faint);display:inline-flex}
|
|
245
|
+
.srow__pin.is-on{color:var(--primary-action)}
|
|
246
|
+
.wsrow__head{display:flex;align-items:center;gap:4px;padding:2px 0}
|
|
247
|
+
.wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
|
|
248
|
+
.wsrow__open:active{background:var(--subtle)}
|
|
249
|
+
.wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
250
|
+
/* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
|
|
251
|
+
.wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
|
|
252
|
+
color:var(--queued);background:var(--tint-queued);white-space:nowrap}
|
|
253
|
+
.wsrow__chev{color:var(--muted);display:inline-flex;transition:transform .15s}
|
|
254
|
+
.wsrow__chev.is-open{transform:rotate(90deg)}
|
|
255
|
+
.wsrow__add{flex:0 0 auto;width:36px;height:36px;border-radius:var(--r-full);display:flex;align-items:center;justify-content:center;color:var(--muted)}
|
|
256
|
+
.wsrow__add:active{background:var(--subtle)}
|
|
257
|
+
/* 长按会话行的浮动菜单 */
|
|
258
|
+
.rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
|
|
259
|
+
border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
|
|
260
|
+
.rowmenu__scrim{position:fixed;inset:0;z-index:1200}
|
|
261
|
+
.rowmenu__item{display:flex;align-items:center;gap:10px;width:100%;padding:11px 12px;border-radius:10px;color:var(--ink);font-size:15px;text-align:left}
|
|
262
|
+
.rowmenu__item:active{background:var(--subtle)}
|
|
263
|
+
.rowmenu__item--danger{color:var(--error-text)}
|
|
264
|
+
.rowmenu__l{flex:1;min-width:0}
|
|
265
|
+
/* toast(没有通知能力时的兜底提示) */
|
|
266
|
+
.toast{position:fixed;left:50%;bottom:calc(92px + env(safe-area-inset-bottom));transform:translateX(-50%);
|
|
267
|
+
max-width:82vw;padding:10px 14px;border-radius:12px;background:var(--raised);color:var(--ink);
|
|
268
|
+
border:1px solid var(--border);font-size:13px;text-align:center;z-index:1300;
|
|
269
|
+
box-shadow:0 12px 30px -12px var(--app-shadow)}
|
|
270
|
+
button.setrow{font-family:inherit}
|
|
271
|
+
/* 默认引擎/模型选择器:左列「自动」与正在浏览的引擎不能都画成选中态(两个浅蓝块会让人
|
|
272
|
+
分不清哪个是已保存的默认)。is-on = 已保存;is-cur = 右列此刻在浏览谁。 */
|
|
273
|
+
.pick__citem.is-cur{background:var(--subtle)}
|
|
274
|
+
|
|
275
|
+
/* ══════════ 发现:插件目录 ══════════
|
|
276
|
+
两项 magic-* 工程的 CLI(magic-shorts / magic-video)。数据来自桌面端 `/desk/plugins`:
|
|
277
|
+
简介、安装状态、安装/升级命令都由**服务端**宣布,本页只显示与触发,不自己推断状态
|
|
278
|
+
(装没装、版本多少以服务端探测为准)。 */
|
|
279
|
+
.plugin{display:flex;align-items:center;gap:12px;width:100%;padding:12px 8px;border-radius:14px;text-align:left}
|
|
280
|
+
.plugin:active{background:var(--subtle)}
|
|
281
|
+
.plugin__ico{flex:0 0 auto;width:44px;height:44px;border-radius:12px;background:var(--subtle);
|
|
282
|
+
display:flex;align-items:center;justify-content:center;color:var(--ink)}
|
|
283
|
+
.plugin__ico.is-on{background:var(--tint-done);color:var(--success)}
|
|
284
|
+
.plugin__col{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
|
|
285
|
+
.plugin__t{font-size:15px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
286
|
+
.plugin__s{font-size:12px;line-height:16px;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;
|
|
287
|
+
-webkit-box-orient:vertical;overflow:hidden}
|
|
288
|
+
.pbadge{flex:0 0 auto;padding:2px 8px;border-radius:var(--r-full);font-size:11px;line-height:16px;white-space:nowrap}
|
|
289
|
+
.pbadge--on{color:var(--success);background:var(--tint-done)}
|
|
290
|
+
.pbadge--off{color:var(--muted);background:var(--subtle)}
|
|
291
|
+
/* 详情页 */
|
|
292
|
+
.pd{padding:0 16px calc(28px + env(safe-area-inset-bottom));font-size:14px;line-height:1.7;color:var(--ink)}
|
|
293
|
+
.pd__head{display:flex;align-items:center;gap:10px;padding:6px 0 2px}
|
|
294
|
+
.pd__name{font-size:20px;font-weight:700}
|
|
295
|
+
.pd__tag{margin:6px 0 0;color:var(--muted);font-size:13px;line-height:1.6}
|
|
296
|
+
.pd__sec{margin-top:18px}
|
|
297
|
+
.pd__h{font-size:12px;font-weight:700;letter-spacing:.04em;color:var(--muted);margin-bottom:6px}
|
|
298
|
+
.pd__p{margin:0;font-size:13.5px;line-height:1.7;color:var(--ink-soft)}
|
|
299
|
+
.pd__code{margin:0;padding:10px 12px;border-radius:10px;background:var(--code);color:var(--ink-soft);
|
|
300
|
+
font:12.5px/1.6 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
301
|
+
.pd__actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
|
302
|
+
.pd__actions .btn{flex:1;min-width:120px;margin-top:0}
|
|
303
|
+
.pd__actions .btn[disabled]{opacity:.5}
|
|
304
|
+
.pd__log{margin-top:12px;padding:10px 12px;border-radius:10px;background:var(--code);color:var(--muted);
|
|
305
|
+
font:11.5px/1.55 var(--mono);max-height:170px;overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
306
|
+
.pd__note{margin-top:8px;font-size:12px;line-height:1.6;color:var(--faint)}
|
|
307
|
+
|
|
308
|
+
/* ══════════ 我的(设置)══════════ */
|
|
309
|
+
.mine{flex:1;min-height:0;display:flex;flex-direction:column}
|
|
310
|
+
.setcard{background:var(--raised);border:1px solid var(--border);border-radius:16px;margin:10px 6px;padding:0 12px}
|
|
311
|
+
.setrow{display:flex;align-items:center;gap:12px;width:100%;padding:14px 2px;text-align:left}
|
|
312
|
+
.setrow+.setrow{border-top:1px solid var(--border)}
|
|
313
|
+
.setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
|
|
314
|
+
.setrow__v{flex:0 0 auto;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
|
|
315
|
+
.setrow--danger{color:var(--error-text)}
|
|
316
|
+
.seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
|
|
317
|
+
.seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
|
|
318
|
+
.seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
|
|
319
|
+
.tgl{flex:0 0 auto;width:48px;height:28px;border-radius:var(--r-full);background:var(--subtle);position:relative;transition:background .16s}
|
|
320
|
+
.tgl.is-on{background:var(--success)}
|
|
321
|
+
.tgl::after{content:"";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:var(--r-full);background:#fff;transition:left .16s}
|
|
322
|
+
.tgl.is-on::after{left:23px}
|
|
88
323
|
|
|
89
|
-
/*
|
|
90
|
-
.
|
|
91
|
-
padding:
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
324
|
+
/* ══════════ 底部 Tab ══════════ */
|
|
325
|
+
.tabbar{flex:0 0 auto;display:flex;border-top:1px solid var(--border);background:var(--raised);
|
|
326
|
+
padding-bottom:env(safe-area-inset-bottom)}
|
|
327
|
+
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
|
|
328
|
+
height:56px;color:var(--muted);font-size:11px}
|
|
329
|
+
.tab.is-on{color:var(--primary-action)}
|
|
330
|
+
.tab svg{display:block}
|
|
331
|
+
/* 有底部 tab 时,composer 不再自己叠加安全区(tab 已承担) */
|
|
332
|
+
#conv .composer{padding-bottom:14px}
|
|
97
333
|
|
|
98
334
|
/* 组件挂载点:占满剩余高度 */
|
|
99
335
|
#conv{flex:1;min-height:0;display:flex}
|
|
100
336
|
|
|
101
337
|
/* 挂载失败时说明原因,而不是留一片空白 */
|
|
102
|
-
.boot-err{margin:16px;padding:14px;border-radius:
|
|
338
|
+
.boot-err{margin:16px;padding:14px;border-radius:12px;background:var(--error-surface);
|
|
103
339
|
border:1.2px solid var(--error-border);color:var(--error-text);font-size:13.5px;line-height:1.55}
|
|
104
340
|
|
|
341
|
+
/* 扫码浮层(ZCode 的 RemoteControlQrScanner,这里用浏览器 BarcodeDetector 兜底) */
|
|
342
|
+
.scan{position:fixed;inset:0;z-index:400;background:#000;display:flex;flex-direction:column}
|
|
343
|
+
.scan video{flex:1;min-height:0;width:100%;object-fit:cover;background:#000}
|
|
344
|
+
.scan__bar{display:flex;align-items:center;gap:8px;height:56px;padding:0 12px;
|
|
345
|
+
padding-top:env(safe-area-inset-top);
|
|
346
|
+
background:color-mix(in srgb,var(--raised) 92%,transparent);color:var(--ink)}
|
|
347
|
+
.scan__t{flex:1;min-width:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
348
|
+
.scan__close{color:var(--primary-action);font-size:14px;font-weight:600;padding:8px 12px}
|
|
349
|
+
|
|
350
|
+
/* 内置浏览器:全屏浮层盖住 tabbar;z-index 380 < .pick-scrim(1000),
|
|
351
|
+
浏览器内的菜单/快捷站点才能复用共享底部抽屉组件。 */
|
|
352
|
+
.browser{position:fixed;inset:0;z-index:380;background:var(--canvas);display:flex;flex-direction:column}
|
|
353
|
+
.browser__top{flex:0 0 auto;display:flex;align-items:center;gap:10px;
|
|
354
|
+
padding:calc(8px + env(safe-area-inset-top)) 12px 8px;
|
|
355
|
+
background:var(--raised);border-bottom:1px solid var(--border)}
|
|
356
|
+
.browser__bar{flex:1;min-width:0;display:flex;align-items:center;gap:8px;height:40px;
|
|
357
|
+
padding:0 8px 0 12px;border-radius:12px;background:var(--subtle)}
|
|
358
|
+
.browser__shield{flex:0 0 auto;display:inline-flex;color:var(--success)}
|
|
359
|
+
.browser__shield.is-plain{color:var(--faint)}
|
|
360
|
+
.browser__addrwrap{flex:1;min-width:0;display:flex;margin:0}
|
|
361
|
+
.browser__addr{flex:1;min-width:0;font-size:14px;color:var(--ink);
|
|
362
|
+
border:none;outline:none;background:transparent;padding:0;
|
|
363
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
364
|
+
.browser__addr::placeholder{color:var(--faint)}
|
|
365
|
+
.browser__ib{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;
|
|
366
|
+
width:30px;height:30px;border-radius:9px;border:none;background:transparent;color:var(--muted)}
|
|
367
|
+
.browser__ib:active{background:var(--border)}
|
|
368
|
+
.browser__ib.is-on{color:var(--primary-action)}
|
|
369
|
+
.browser__app{flex:0 0 auto;width:34px;height:34px;border-radius:10px;border:none;
|
|
370
|
+
background:var(--primary-action);color:var(--on-primary-action);font-size:15px;line-height:1}
|
|
371
|
+
.browser__frame{flex:1;min-height:0;width:100%;border:0;background:#fff}
|
|
372
|
+
.browser__nav{flex:0 0 auto;display:flex;align-items:center;
|
|
373
|
+
padding:6px 6px calc(6px + env(safe-area-inset-bottom));
|
|
374
|
+
background:var(--raised);border-top:1px solid var(--border)}
|
|
375
|
+
.browser__nb{flex:1;display:flex;align-items:center;justify-content:center;height:42px;
|
|
376
|
+
border:none;background:transparent;color:var(--ink)}
|
|
377
|
+
.browser__nb:active{opacity:.55}
|
|
378
|
+
.browser__cnt{flex:1;display:flex;align-items:center;justify-content:center}
|
|
379
|
+
.browser__cnt i{font-style:normal;min-width:26px;height:26px;display:inline-flex;
|
|
380
|
+
align-items:center;justify-content:center;border:2px solid var(--ink);
|
|
381
|
+
border-radius:8px;font-size:13px;font-weight:700;color:var(--ink)}
|
|
382
|
+
|
|
383
|
+
/* ── 本壳自己的那几件(对话区内部)──
|
|
384
|
+
⚠️ 气泡(旧 `.ubub`)、正文(旧 `.md`)、思考块(旧 `.rsn`)、工具卡(旧 `.tool` / `.cmdbar`)
|
|
385
|
+
的样式**不在这里** —— 那些 DOM 现在由对话内核画,皮肤在共用的一份
|
|
386
|
+
`shared/conv/conv.css`(三端同一份)。这里只留本壳仍然亲手建出来的节点。 */
|
|
387
|
+
.notice{border-radius:12px}
|
|
388
|
+
.conv__empty{font-size:14px;line-height:1.75}
|
|
389
|
+
|
|
105
390
|
/* 底部安全区:iPhone 的 home indicator 会压住输入框 */
|
|
106
391
|
.composer{padding-bottom:calc(16px + env(safe-area-inset-bottom))}
|
|
107
392
|
|
|
393
|
+
/* 产物阅读浮层:产物卡「查看」打开文本 / Markdown。
|
|
394
|
+
z-index 低于 .pick-scrim(1000) 之外的抽屉(1200+),高于内置浏览器(380)。 */
|
|
395
|
+
.reader{position:fixed;inset:0;z-index:1100;background:var(--canvas);display:flex;flex-direction:column}
|
|
396
|
+
.reader__top{flex:0 0 auto;display:flex;align-items:center;gap:10px;
|
|
397
|
+
padding:calc(8px + env(safe-area-inset-top)) 12px 8px;
|
|
398
|
+
background:var(--raised);border-bottom:1px solid var(--border)}
|
|
399
|
+
.reader__icon{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;
|
|
400
|
+
width:36px;height:36px;border:none;border-radius:10px;background:transparent;color:var(--ink)}
|
|
401
|
+
.reader__icon:active{background:var(--border)}
|
|
402
|
+
.reader__col{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
403
|
+
.reader__t{font-size:15px;font-weight:600;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
404
|
+
.reader__s{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
405
|
+
.reader__body{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch;
|
|
406
|
+
padding:14px 16px calc(24px + env(safe-area-inset-bottom));font-size:15px;line-height:1.7;color:var(--ink)}
|
|
407
|
+
.reader__body pre{margin:0;font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;
|
|
408
|
+
font-family:var(--mono);color:var(--ink)}
|
|
409
|
+
.reader__hint{color:var(--muted);font-size:13.5px;line-height:1.7}
|
|
410
|
+
/* 阅读页比对话流再大一号(手机上看文档,12.5px 偏小) */
|
|
411
|
+
.reader__body .md-body{font-size:14px;line-height:1.75}
|
|
412
|
+
|
|
413
|
+
/* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════
|
|
414
|
+
盖住整页,瓶居中,绿雾在后。灌满后整屏淡出(`.is-out`),由下面的 JS 在最迟 1.6s 摘掉。
|
|
415
|
+
只用 transform / opacity:出现时主线程正在跑首屏初始化,动 layout 会把自己卡住。
|
|
416
|
+
`pointer-events:none` 写在 `.splash` 上:闪屏是动画不是弹窗,不能拦掉点击。 */
|
|
417
|
+
.splash{position:fixed;inset:0;z-index:2000;display:flex;flex-direction:column;
|
|
418
|
+
align-items:center;justify-content:center;gap:18px;pointer-events:none;
|
|
419
|
+
background:var(--canvas);opacity:1;transition:opacity .38s ease}
|
|
420
|
+
/* 淡出后彻底撤掉(display:none 不够:要让它不再参与合成层) */
|
|
421
|
+
.splash[hidden]{display:none}
|
|
422
|
+
.splash.is-out{opacity:0}
|
|
423
|
+
|
|
424
|
+
/* 绿雾:比 loading 里那团更大更亮 —— 启动这一屏只有这一件东西,绿要当主角 */
|
|
425
|
+
.splash__glow{position:absolute;left:50%;top:50%;width:320px;height:320px;
|
|
426
|
+
margin:-160px 0 0 -160px;border-radius:50%;pointer-events:none;
|
|
427
|
+
background:radial-gradient(circle,var(--q-bottle-glow, rgba(52,210,123,.30)) 0%,transparent 70%);
|
|
428
|
+
animation:splashGlow 2.4s ease-in-out infinite}
|
|
429
|
+
|
|
430
|
+
.splash__bottle{position:relative;width:132px;height:165px;flex:none;
|
|
431
|
+
color:var(--q-bottle-line, var(--muted))}
|
|
432
|
+
.splash__bottle .qbottle{display:block;width:132px;height:165px}
|
|
433
|
+
|
|
434
|
+
/* 名字:等绿液灌到一半才浮出来(它在给「灌满了」这件事做收尾) */
|
|
435
|
+
.splash__name{position:relative;font-family:'AA Caveat',cursive;font-size:19px;letter-spacing:.12em;
|
|
436
|
+
color:var(--muted);opacity:0;animation:splashName .5s ease .55s forwards}
|
|
437
|
+
|
|
438
|
+
@keyframes splashGlow{0%,100%{opacity:.5;transform:scale(.92)}50%{opacity:.95;transform:scale(1.06)}}
|
|
439
|
+
@keyframes splashName{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
440
|
+
|
|
441
|
+
/* 用户开了「减弱动态效果」:不留空瓶子干等,直接给满瓶、不淡出不闪 —— 但**还是要出现**
|
|
442
|
+
(它是 App 的身份动作)。灌液动画与呼吸都关掉。 */
|
|
443
|
+
@media (prefers-reduced-motion:reduce){
|
|
444
|
+
.splash__glow{animation:none;opacity:.7}
|
|
445
|
+
.splash__name{animation:none;opacity:1}
|
|
446
|
+
.splash .qbottle--rise .qbottle__liquid{animation:none;transform:none}
|
|
447
|
+
}
|
|
448
|
+
|
|
108
449
|
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
|
109
450
|
</style>
|
|
110
451
|
</head>
|
|
111
452
|
<body>
|
|
112
453
|
|
|
454
|
+
<!-- ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════
|
|
455
|
+
这一屏盖在整页之上,从「空瓶」灌到「满瓶」,然后淡出。
|
|
456
|
+
· 瓶身**不在这里画**:由 shared/conversation.js 的 bottleSVG 生成(与读历史的
|
|
457
|
+
loading 同一份图形)—— 抄一份就会两处漂移,历史上三份对话实现就是这么坏掉的。
|
|
458
|
+
所以本节点先留空,由下面 boot splash 那段 JS 填充。
|
|
459
|
+
· `pointer-events:none`:闪屏只是**动画**,不许它拦掉用户点连接按钮 / 列表行
|
|
460
|
+
(真机上它只活 ~1.2s,但慢启动时用户已经在戳屏幕了)。
|
|
461
|
+
· 只用 transform / opacity:这一屏出现时主线程正在跑首屏初始化,动 layout 的属性会
|
|
462
|
+
让它自己卡住,看起来像「App 卡在启动画面」。 -->
|
|
463
|
+
<div class="splash" id="bottleSplash" aria-hidden="true">
|
|
464
|
+
<div class="splash__glow"></div>
|
|
465
|
+
<div class="splash__bottle" id="splashBottle"></div>
|
|
466
|
+
<div class="splash__name">magic-agent</div>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
113
469
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
114
470
|
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 8 12l7 7"/></symbol>
|
|
115
471
|
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5.5v13M5.5 12h13"/></symbol>
|
|
472
|
+
<symbol id="i-scan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V6a2 2 0 0 1 2-2h3M15 4h3a2 2 0 0 1 2 2v3M20 15v3a2 2 0 0 1-2 2h-3M9 20H6a2 2 0 0 1-2-2v-3M4 12h16"/></symbol>
|
|
473
|
+
<symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></symbol>
|
|
474
|
+
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
|
|
475
|
+
<symbol id="i-tune" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h8M18 7h2M4 17h2M12 17h8"/><circle cx="15" cy="7" r="2.2"/><circle cx="9" cy="17" r="2.2"/></symbol>
|
|
476
|
+
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8.5" r="3.6"/><path d="M5 20a7 7 0 0 1 14 0"/></symbol>
|
|
477
|
+
<symbol id="i-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a7 7 0 0 1-7 7H9l-4 3v-4.6A7 7 0 0 1 5 12a7 7 0 0 1 7-7h1a7 7 0 0 1 7 7z"/></symbol>
|
|
478
|
+
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><path d="M12 8v4.3l3 1.8"/></symbol>
|
|
479
|
+
<symbol id="i-compass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/></symbol>
|
|
480
|
+
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M10 3.5v3M14 3.5v3M10 17.5v3M14 17.5v3M3.5 10h3M3.5 14h3M17.5 10h3M17.5 14h3"/></symbol>
|
|
481
|
+
<symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4.5h10a1 1 0 0 1 1 1V20l-6-3.6L6 20V5.5a1 1 0 0 1 1-1z"/></symbol>
|
|
482
|
+
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7a2 2 0 0 1 2-2h3.2l1.6 2H18a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/></symbol>
|
|
483
|
+
<symbol id="i-chev-r" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></symbol>
|
|
484
|
+
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L19 9a2 2 0 0 0 0-3l-1-1a2 2 0 0 0-3 0L4 16z"/><path d="M14 6l4 4"/></symbol>
|
|
485
|
+
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/></symbol>
|
|
486
|
+
<symbol id="i-laptop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="6" width="14" height="9" rx="1.6"/><path d="M3 18.5h18"/></symbol>
|
|
487
|
+
<symbol id="i-phone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3.5" width="10" height="17" rx="2.2"/><path d="M10.8 17.6h2.4"/></symbol>
|
|
488
|
+
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.2 4.2L19 7"/></symbol>
|
|
489
|
+
<symbol id="i-bang" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 6.5v7M12 17.2h.01"/></symbol>
|
|
490
|
+
<symbol id="i-dot" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5.5"/></symbol>
|
|
491
|
+
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.6 2.3 3.9 5 3.9 8s-1.3 5.7-3.9 8c-2.6-2.3-3.9-5-3.9-8s1.3-5.7 3.9-8z"/></symbol>
|
|
492
|
+
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="4.5" y="4.5" width="6" height="6" rx="1.5"/><rect x="13.5" y="4.5" width="6" height="6" rx="1.5"/><rect x="4.5" y="13.5" width="6" height="6" rx="1.5"/><rect x="13.5" y="13.5" width="6" height="6" rx="1.5"/></symbol>
|
|
493
|
+
<symbol id="i-plug" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3.5V8M15 3.5V8"/><path d="M7 8h10v3.2a5 5 0 0 1-3.6 4.8L12 16.5l-1.4-.5A5 5 0 0 1 7 11.2z"/><path d="M12 16.5v4"/></symbol>
|
|
494
|
+
<symbol id="i-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M12 3.5l2.1 5.4 5.4 2.1-5.4 2.1-2.1 5.4-2.1-5.4-5.4-2.1 5.4-2.1z"/></symbol>
|
|
495
|
+
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l7 2.6v5.2c0 4.6-3 8-7 9.2-4-1.2-7-4.6-7-9.2V6.1z"/><path d="M9 11.8l2.1 2.1 4-4"/></symbol>
|
|
496
|
+
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M12 4.5l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z"/></symbol>
|
|
497
|
+
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 3.5v3.7h-3.7"/></symbol>
|
|
498
|
+
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="6.5" cy="12" r="2.4"/><circle cx="17" cy="6" r="2.4"/><circle cx="17" cy="18" r="2.4"/><path d="M8.7 10.9l6.1-3.6M8.7 13.1l6.1 3.6"/></symbol>
|
|
499
|
+
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 11.5L12 4.5l7.5 7"/><path d="M6.5 10v9.5h11V10"/></symbol>
|
|
116
500
|
</svg>
|
|
117
501
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
<
|
|
502
|
+
<!-- ══════════ 启动页 ══════════ -->
|
|
503
|
+
<div class="launcher" id="launcher" hidden>
|
|
504
|
+
<h1 class="launcher__title">magic-agent · 远程控制</h1>
|
|
505
|
+
<p class="launcher__desc">打开桌面配对页里的连接地址,远程继续你的会话。</p>
|
|
506
|
+
|
|
507
|
+
<div class="field" id="field">
|
|
508
|
+
<label for="addr">桌面地址</label>
|
|
509
|
+
<textarea id="addr" rows="2" placeholder="http://192.168.1.5:8791/?token=…"
|
|
510
|
+
autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
|
511
|
+
</div>
|
|
512
|
+
<div class="field__err" id="err" hidden></div>
|
|
513
|
+
|
|
514
|
+
<button class="btn btn--filled" id="btnConnect">连接桌面</button>
|
|
515
|
+
<button class="btn btn--outlined" id="btnScan">
|
|
516
|
+
<svg width="18" height="18"><use href="#i-scan"/></svg><span>扫码连接</span>
|
|
517
|
+
</button>
|
|
518
|
+
|
|
519
|
+
<div class="recent" id="recent" hidden>
|
|
520
|
+
<div class="recent__head">
|
|
521
|
+
<span class="recent__title">最近连接</span>
|
|
522
|
+
<button class="recent__clear" id="btnClear">清空</button>
|
|
523
|
+
</div>
|
|
524
|
+
<div id="recentList"></div>
|
|
525
|
+
</div>
|
|
122
526
|
</div>
|
|
123
|
-
<div id="conv"></div>
|
|
124
527
|
|
|
528
|
+
<!-- ══════════ 历史会话列表 ══════════ -->
|
|
529
|
+
<div class="history" id="history" hidden>
|
|
530
|
+
<div class="history__top">
|
|
531
|
+
<span class="history__title" id="historyTitle">任务</span>
|
|
532
|
+
</div>
|
|
533
|
+
<div class="history__actions">
|
|
534
|
+
<button class="newtask" id="btnNewTask"><svg width="18" height="18"><use href="#i-plus"/></svg><span>新建任务</span></button>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="history__list" id="historyList"></div>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<!-- ══════════ 我的(设置)══════════ -->
|
|
540
|
+
<div class="mine" id="mine" hidden>
|
|
541
|
+
<div class="history__top">
|
|
542
|
+
<button class="topbar__icon" id="btnMineBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
543
|
+
<span class="history__title" style="font-size:18px;font-weight:700">我的</span>
|
|
544
|
+
<span style="width:40px;flex:0 0 auto"></span>
|
|
545
|
+
</div>
|
|
546
|
+
<div class="history__list">
|
|
547
|
+
<div class="setcard">
|
|
548
|
+
<div class="setrow"><span class="setrow__l">设备</span><span class="setrow__v" id="mineDevice">—</span></div>
|
|
549
|
+
<div class="setrow"><span class="setrow__l">主题</span>
|
|
550
|
+
<span class="seg" id="segTheme">
|
|
551
|
+
<button class="seg__b" data-v="system">系统</button>
|
|
552
|
+
<button class="seg__b" data-v="light">浅色</button>
|
|
553
|
+
<button class="seg__b" data-v="dark">深色</button>
|
|
554
|
+
</span>
|
|
555
|
+
</div>
|
|
556
|
+
<div class="setrow"><span class="setrow__l">字体大小</span>
|
|
557
|
+
<span class="seg" id="segFont">
|
|
558
|
+
<button class="seg__b" data-v="0.92">小</button>
|
|
559
|
+
<button class="seg__b" data-v="1">标准</button>
|
|
560
|
+
<button class="seg__b" data-v="1.14">大</button>
|
|
561
|
+
</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="setrow"><span class="setrow__l">任务模式</span>
|
|
564
|
+
<span class="seg" id="segMode">
|
|
565
|
+
<button class="seg__b" data-v="parallel">并行</button>
|
|
566
|
+
<button class="seg__b" data-v="serial">串行</button>
|
|
567
|
+
</span>
|
|
568
|
+
</div>
|
|
569
|
+
<!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
|
|
570
|
+
两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
|
|
571
|
+
<button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
|
|
572
|
+
<button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
|
|
573
|
+
<div class="setrow"><span class="setrow__l">触感反馈</span><button class="tgl" id="tglHaptic" aria-label="触感反馈"></button></div>
|
|
574
|
+
<div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
|
|
575
|
+
<div class="setrow"><span class="setrow__l">语言</span><span class="setrow__v">跟随系统</span></div>
|
|
576
|
+
<button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
|
|
577
|
+
</div>
|
|
578
|
+
<div class="setcard">
|
|
579
|
+
<button class="setrow" id="btnUpgrade"><span class="setrow__l">检查更新</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="setcard">
|
|
582
|
+
<button class="setrow setrow--danger" id="btnLogout">断开连接</button>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
<!-- ══════════ 定时任务 ══════════ -->
|
|
588
|
+
<div class="history" id="sched" hidden>
|
|
589
|
+
<div class="history__top"><span class="history__title">定时任务</span></div>
|
|
590
|
+
<div class="history__list">
|
|
591
|
+
<div class="history__hint">magic-agent 当前没有“定时任务清单”接口。<br>需要的话我可以按 core 的自动化能力接上。</div>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
|
|
596
|
+
<div class="history" id="discover" hidden>
|
|
597
|
+
<div class="history__top"><span class="history__title">发现</span></div>
|
|
598
|
+
<div class="history__list" id="discoverList">
|
|
599
|
+
<div class="history__group">入口</div>
|
|
600
|
+
<button class="srow" id="dscBrowser" type="button">
|
|
601
|
+
<span class="srow__ico"><svg width="22" height="22"><use href="#i-globe"/></svg></span>
|
|
602
|
+
<span class="srow__col"><span class="srow__t">浏览器</span><span class="srow__s">内置浏览器,可直接上网</span></span>
|
|
603
|
+
<span class="srow__time" style="color:var(--faint)"><svg width="16" height="16"><use href="#i-chev-r"/></svg></span>
|
|
604
|
+
</button>
|
|
605
|
+
<button class="srow" id="dscApps" type="button">
|
|
606
|
+
<span class="srow__ico"><svg width="22" height="22"><use href="#i-grid"/></svg></span>
|
|
607
|
+
<span class="srow__col"><span class="srow__t">应用</span><span class="srow__s">桌面端发布的应用</span></span>
|
|
608
|
+
<span class="srow__time" style="color:var(--faint)"><svg width="16" height="16"><use href="#i-chev-r"/></svg></span>
|
|
609
|
+
</button>
|
|
610
|
+
<button class="srow" id="dscPlugins" type="button">
|
|
611
|
+
<span class="srow__ico"><svg width="22" height="22"><use href="#i-plug"/></svg></span>
|
|
612
|
+
<span class="srow__col"><span class="srow__t">插件</span><span class="srow__s">magic-shorts · magic-video 等工程 CLI</span></span>
|
|
613
|
+
<span class="srow__time" style="color:var(--faint)"><svg width="16" height="16"><use href="#i-chev-r"/></svg></span>
|
|
614
|
+
</button>
|
|
615
|
+
<button class="srow" id="dscSkill" type="button">
|
|
616
|
+
<span class="srow__ico"><svg width="22" height="22"><use href="#i-spark"/></svg></span>
|
|
617
|
+
<span class="srow__col"><span class="srow__t">SKILL</span><span class="srow__s">技能包,扩展 AI 能力</span></span>
|
|
618
|
+
<span class="srow__time" style="color:var(--faint)"><svg width="16" height="16"><use href="#i-chev-r"/></svg></span>
|
|
619
|
+
</button>
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
|
|
623
|
+
<!-- ══════════ 发现 → 插件(工程 CLI 目录)══════════
|
|
624
|
+
列表与详情都是**服务端宣布什么就显示什么**(GET /desk/plugins)。 -->
|
|
625
|
+
<div class="history" id="plugins" hidden>
|
|
626
|
+
<div class="history__top">
|
|
627
|
+
<button class="topbar__icon" id="btnPluginsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
628
|
+
<span class="history__title">插件</span>
|
|
629
|
+
</div>
|
|
630
|
+
<div class="history__list" id="pluginList"></div>
|
|
631
|
+
</div>
|
|
632
|
+
|
|
633
|
+
<div class="history" id="pluginDetail" hidden>
|
|
634
|
+
<div class="history__top">
|
|
635
|
+
<button class="topbar__icon" id="btnPluginDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
|
|
636
|
+
<span class="history__title" id="pluginDetailTitle">插件</span>
|
|
637
|
+
</div>
|
|
638
|
+
<div class="history__list" id="pluginDetailBody"></div>
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
<!-- ══════════ 会话页 ══════════ -->
|
|
642
|
+
<div class="session" id="session" hidden>
|
|
643
|
+
<header class="topbar">
|
|
644
|
+
<button class="topbar__icon" id="btnList" aria-label="会话列表"><svg width="20" height="20"><use href="#i-list"/></svg></button>
|
|
645
|
+
<div class="topbar__col">
|
|
646
|
+
<span class="topbar__title" id="title">会话</span>
|
|
647
|
+
<span class="topbar__sub" id="subtitle"></span>
|
|
648
|
+
</div>
|
|
649
|
+
<button class="topbar__icon" id="btnSettings" aria-label="运行设置"><svg width="20" height="20"><use href="#i-tune"/></svg></button>
|
|
650
|
+
</header>
|
|
651
|
+
<div id="conv"></div>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<!-- ══════════ 扫码浮层 ══════════ -->
|
|
655
|
+
<div class="scan" id="scan" hidden>
|
|
656
|
+
<div class="scan__bar">
|
|
657
|
+
<span class="scan__t">将桌面配对页的二维码对准取景框</span>
|
|
658
|
+
<button class="scan__close" id="btnScanClose">关闭</button>
|
|
659
|
+
</div>
|
|
660
|
+
<video id="scanVideo" playsinline muted></video>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
<!-- ══════════ 内置浏览器(iframe 内嵌;z-index 低于共享抽屉 .pick)══════════ -->
|
|
664
|
+
<div class="browser" id="browser" hidden>
|
|
665
|
+
<header class="browser__top">
|
|
666
|
+
<div class="browser__bar">
|
|
667
|
+
<span class="browser__shield" id="brShield"><svg width="16" height="16"><use href="#i-shield"/></svg></span>
|
|
668
|
+
<form class="browser__addrwrap" id="brForm" novalidate>
|
|
669
|
+
<input class="browser__addr" id="brAddr" type="url" inputmode="url" enterkeyhint="go" autocomplete="off" spellcheck="false" placeholder="输入网址">
|
|
670
|
+
</form>
|
|
671
|
+
<button class="browser__ib" id="brStar" type="button" aria-label="收藏"><svg width="18" height="18"><use href="#i-star"/></svg></button>
|
|
672
|
+
<button class="browser__ib" id="brRefresh" type="button" aria-label="刷新"><svg width="18" height="18"><use href="#i-refresh"/></svg></button>
|
|
673
|
+
<button class="browser__ib" id="brShare" type="button" aria-label="分享"><svg width="18" height="18"><use href="#i-share"/></svg></button>
|
|
674
|
+
</div>
|
|
675
|
+
<button class="browser__app" id="brClose" type="button" aria-label="关闭浏览器">✕</button>
|
|
676
|
+
</header>
|
|
677
|
+
<iframe class="browser__frame" id="brFrame" src="about:blank" referrerpolicy="no-referrer" allow="fullscreen"></iframe>
|
|
678
|
+
<nav class="browser__nav">
|
|
679
|
+
<button class="browser__nb" id="brBack" type="button" aria-label="返回"><svg width="22" height="22"><use href="#i-back"/></svg></button>
|
|
680
|
+
<button class="browser__nb" id="brSites" type="button" aria-label="快捷站点"><svg width="22" height="22"><use href="#i-grid"/></svg></button>
|
|
681
|
+
<button class="browser__nb" id="brGo" type="button" aria-label="打开网址"><svg width="22" height="22"><use href="#i-list"/></svg></button>
|
|
682
|
+
<span class="browser__cnt" title="单标签"><i>1</i></span>
|
|
683
|
+
<button class="browser__nb" id="brHome" type="button" aria-label="主页"><svg width="22" height="22"><use href="#i-home"/></svg></button>
|
|
684
|
+
</nav>
|
|
685
|
+
</div>
|
|
686
|
+
|
|
687
|
+
<!-- ══════════ 产物阅读浮层(文本 / Markdown 产物卡「查看」)══════════ -->
|
|
688
|
+
<div class="reader" id="reader" hidden>
|
|
689
|
+
<header class="reader__top">
|
|
690
|
+
<button class="reader__icon" id="rdClose" type="button" aria-label="关闭">
|
|
691
|
+
<svg width="20" height="20"><use href="#i-back"/></svg>
|
|
692
|
+
</button>
|
|
693
|
+
<div class="reader__col">
|
|
694
|
+
<span class="reader__t" id="rdTitle">文档</span>
|
|
695
|
+
<span class="reader__s" id="rdSub"></span>
|
|
696
|
+
</div>
|
|
697
|
+
</header>
|
|
698
|
+
<div class="reader__body" id="rdBody"></div>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<!-- ══════════ 底部 Tab ══════════ -->
|
|
702
|
+
<nav class="tabbar" id="tabbar" hidden>
|
|
703
|
+
<button class="tab" type="button" data-tab="tasks"><svg width="22" height="22"><use href="#i-chat"/></svg><span>任务</span></button>
|
|
704
|
+
<button class="tab" type="button" data-tab="sched"><svg width="22" height="22"><use href="#i-clock"/></svg><span>定时任务</span></button>
|
|
705
|
+
<button class="tab" type="button" data-tab="discover"><svg width="22" height="22"><use href="#i-compass"/></svg><span>发现</span></button>
|
|
706
|
+
<button class="tab" type="button" data-tab="mine"><svg width="22" height="22"><use href="#i-user"/></svg><span>我的</span></button>
|
|
707
|
+
</nav>
|
|
708
|
+
<!-- 第三方库归口 shared/vendor(与内核共用一份):markdown 渲染栈要用 marked + DOMPurify;
|
|
709
|
+
highlight.js 由内核按需加载,地址在下面给(内核自己不知道页面的相对根;
|
|
710
|
+
按 shared/ 取件 → 远程 /m/shared/vendor/* 与 APK 本地 www/shared/vendor/* 都对得上) -->
|
|
711
|
+
<script src="shared/vendor/marked.min.js"></script>
|
|
712
|
+
<script src="shared/vendor/purify.min.js"></script>
|
|
713
|
+
<script>window.QA_HLJS_URL = "shared/vendor/highlight.min.js";</script>
|
|
714
|
+
<!-- 对话内核(唯一实现,见 shared/conv/README.md):组件要用它的判定口径,必须先引。
|
|
715
|
+
路径按 shared/ 取件 —— 远程(/m/shared/* → --ui-dir/shared/*)与 APK 本地 www 都对得上。 -->
|
|
716
|
+
<script src="shared/conv/01-dom.js"></script>
|
|
717
|
+
<script src="shared/conv/02-media-kinds.js"></script>
|
|
718
|
+
<script src="shared/conv/03-chat-message.js"></script>
|
|
719
|
+
<script src="shared/conv/06-ask-answer.js"></script>
|
|
720
|
+
<script src="shared/conv/07-stream-delegation.js"></script>
|
|
721
|
+
<script src="shared/conv/08-ask-event-map.js"></script>
|
|
722
|
+
<script src="shared/conv/09-stream-apply.js"></script>
|
|
723
|
+
<script src="shared/conv/10-markdown-render.js"></script>
|
|
724
|
+
<script src="shared/conv/18-askbox-markup.js"></script>
|
|
725
|
+
<script src="shared/conv/19-attachments.js"></script>
|
|
726
|
+
<script src="shared/conv/27-conv-ledger.js"></script>
|
|
727
|
+
<script src="shared/conv/29-msg-variants.js"></script>
|
|
728
|
+
<!-- 宿主符号适配层:内核只用不定义的那 12 个(任务表 / 需求板 / 工作区 / 模型 / 发送 / 提示)——
|
|
729
|
+
本壳真有的走 window.MagicHost 转发(块② 才挂),没有的给**诚实桩**,不假装成功 -->
|
|
730
|
+
<script src="shared/host-glue.js"></script>
|
|
125
731
|
<script src="shared/conversation.js"></script>
|
|
126
732
|
<script src="shared/transport-http.js"></script>
|
|
127
733
|
<script>
|
|
128
734
|
(function () {
|
|
129
735
|
'use strict';
|
|
130
736
|
|
|
131
|
-
/*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
var TOKEN = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
|
|
136
|
-
var BASE = (window.MAGIC_BASE || '').replace(/\/+$/, '');
|
|
137
|
-
|
|
737
|
+
/* ── 元素 ── */
|
|
738
|
+
var launcherEl = document.getElementById('launcher');
|
|
739
|
+
var sessionEl = document.getElementById('session');
|
|
740
|
+
var convEl = document.getElementById('conv');
|
|
138
741
|
var titleEl = document.getElementById('title');
|
|
742
|
+
var subtitleEl = document.getElementById('subtitle');
|
|
743
|
+
var fieldEl = document.getElementById('field');
|
|
744
|
+
var addrEl = document.getElementById('addr');
|
|
745
|
+
var errEl = document.getElementById('err');
|
|
746
|
+
var recentEl = document.getElementById('recent');
|
|
747
|
+
var recentListEl = document.getElementById('recentList');
|
|
748
|
+
var scanEl = document.getElementById('scan');
|
|
749
|
+
var scanVideo = document.getElementById('scanVideo');
|
|
750
|
+
var historyEl = document.getElementById('history');
|
|
751
|
+
var historyList = document.getElementById('historyList');
|
|
752
|
+
var historyTitle = document.getElementById('historyTitle');
|
|
753
|
+
var mineEl = document.getElementById('mine');
|
|
754
|
+
var mineDevice = document.getElementById('mineDevice');
|
|
755
|
+
var segTheme = document.getElementById('segTheme');
|
|
756
|
+
var segFont = document.getElementById('segFont');
|
|
757
|
+
var segMode = document.getElementById('segMode');
|
|
758
|
+
var tglHaptic = document.getElementById('tglHaptic');
|
|
759
|
+
var tabbarEl = document.getElementById('tabbar');
|
|
760
|
+
var schedEl = document.getElementById('sched');
|
|
761
|
+
var discoverEl = document.getElementById('discover');
|
|
762
|
+
var pluginsEl = document.getElementById('plugins');
|
|
763
|
+
var pluginListEl = document.getElementById('pluginList');
|
|
764
|
+
var pluginDetailEl = document.getElementById('pluginDetail');
|
|
765
|
+
var pluginDetailTitle = document.getElementById('pluginDetailTitle');
|
|
766
|
+
var pluginDetailBody = document.getElementById('pluginDetailBody');
|
|
767
|
+
var brEl = document.getElementById('browser');
|
|
768
|
+
var brFrame = document.getElementById('brFrame');
|
|
769
|
+
var brAddr = document.getElementById('brAddr');
|
|
770
|
+
var brForm = document.getElementById('brForm');
|
|
771
|
+
var brShield = document.getElementById('brShield');
|
|
772
|
+
var brStar = document.getElementById('brStar');
|
|
773
|
+
var readerEl = document.getElementById('reader');
|
|
774
|
+
var rdTitle = document.getElementById('rdTitle');
|
|
775
|
+
var rdSub = document.getElementById('rdSub');
|
|
776
|
+
var rdBody = document.getElementById('rdBody');
|
|
777
|
+
var histSubtitle = '';
|
|
778
|
+
/* null 安全的接线:某个 id 不在时只跳过,绝不因为一个元素缺失就把整页初始化打断(那会白屏)。 */
|
|
779
|
+
function on(id, fn) { var e = document.getElementById(id); if (e) { e.onclick = fn; } }
|
|
780
|
+
|
|
781
|
+
/* ── 当前连接:连上后由它驱动 transport ── */
|
|
782
|
+
var BASE = '', TOKEN = '';
|
|
783
|
+
var conv = null;
|
|
784
|
+
var WS = ''; // 当前工作空间('' = 默认,不传 -w)
|
|
785
|
+
/* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
|
|
786
|
+
「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
|
|
787
|
+
try { WS = localStorage.getItem('magic.ws') || ''; } catch (e) {}
|
|
788
|
+
function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} }
|
|
789
|
+
/* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
|
|
790
|
+
提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
|
|
791
|
+
var RUN_PREF = loadRunPref();
|
|
792
|
+
var ENGINES = []; // 最近一次从 /desk/engines?models=1 拿到的清单
|
|
139
793
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
794
|
+
/* ══════════ 最近连接(对应 ZCode RemoteControlRecentLinksStore:最多 5 条)══════════ */
|
|
795
|
+
var LS_KEY = 'magic.remote.recent.v1', MAX_RECENT = 5;
|
|
796
|
+
function loadRecent() {
|
|
797
|
+
try {
|
|
798
|
+
var raw = localStorage.getItem(LS_KEY);
|
|
799
|
+
var list = raw ? JSON.parse(raw) : [];
|
|
800
|
+
return Array.isArray(list) ? list : [];
|
|
801
|
+
} catch (e) { return []; }
|
|
145
802
|
}
|
|
803
|
+
function saveRecent(list) { try { localStorage.setItem(LS_KEY, JSON.stringify(list)); } catch (e) {} }
|
|
804
|
+
function recordRecent(t) {
|
|
805
|
+
var list = loadRecent().filter(function (x) { return x.url !== t.url; });
|
|
806
|
+
list.unshift({ url: t.url, token: t.token, name: t.name, at: Date.now() });
|
|
807
|
+
saveRecent(list.slice(0, MAX_RECENT));
|
|
808
|
+
}
|
|
809
|
+
function clearRecent() { saveRecent([]); }
|
|
146
810
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
811
|
+
function fmtTime(ms) {
|
|
812
|
+
try { return new Date(ms).toLocaleString(); } catch (e) { return ''; }
|
|
813
|
+
}
|
|
814
|
+
function renderRecent() {
|
|
815
|
+
var list = loadRecent();
|
|
816
|
+
recentListEl.innerHTML = '';
|
|
817
|
+
if (!list.length) { recentEl.hidden = true; return; }
|
|
818
|
+
recentEl.hidden = false;
|
|
819
|
+
list.forEach(function (item) {
|
|
820
|
+
var host = '';
|
|
821
|
+
try { host = new URL(item.url).host; } catch (e) { host = item.url; }
|
|
822
|
+
var b = document.createElement('button');
|
|
823
|
+
b.type = 'button';
|
|
824
|
+
b.className = 'recent__item';
|
|
825
|
+
var t = document.createElement('span');
|
|
826
|
+
t.className = 'recent__t';
|
|
827
|
+
t.textContent = item.name || host;
|
|
828
|
+
var s = document.createElement('span');
|
|
829
|
+
s.className = 'recent__s';
|
|
830
|
+
s.textContent = host + ' · ' + fmtTime(item.at);
|
|
831
|
+
b.appendChild(t); b.appendChild(s);
|
|
832
|
+
b.onclick = function () { connect({ url: item.url, token: item.token, name: item.name, host: host }); };
|
|
833
|
+
recentListEl.appendChild(b);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
151
836
|
|
|
152
|
-
|
|
153
|
-
|
|
837
|
+
/* ══════════ 解析 + 校验连接地址 ══════════
|
|
838
|
+
与 ZCode RemoteControlUrlPolicy 同职责:只放行 http(s),解析出 base 与令牌。
|
|
839
|
+
ZCode 生产包写死 zcode.z.ai;magic-agent 是用户自托管(LAN IP 各不相同),
|
|
840
|
+
所以这里按「能解析出 http(s) 地址」放行,令牌可有可无(本机调试常无令牌)。 */
|
|
841
|
+
function parseTarget(raw) {
|
|
842
|
+
var s = String(raw == null ? '' : raw).trim();
|
|
843
|
+
if (!s) return null;
|
|
844
|
+
if (!/^https?:\/\//i.test(s)) s = 'http://' + s;
|
|
845
|
+
var u;
|
|
846
|
+
try { u = new URL(s); } catch (e) { return null; }
|
|
847
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
848
|
+
if (!u.host) return null;
|
|
849
|
+
return {
|
|
850
|
+
url: u.origin,
|
|
851
|
+
token: u.searchParams.get('token') || '',
|
|
852
|
+
name: u.searchParams.get('name') || '',
|
|
853
|
+
host: u.host
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function showError(msg) {
|
|
858
|
+
errEl.textContent = msg || '';
|
|
859
|
+
errEl.hidden = !msg;
|
|
860
|
+
fieldEl.classList.toggle('is-error', !!msg);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
|
|
864
|
+
function hideAll() { launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; schedEl.hidden = true; discoverEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; sessionEl.hidden = true; }
|
|
865
|
+
function setActive(name) {
|
|
866
|
+
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
867
|
+
b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function showLauncher() { hideAll(); launcherEl.hidden = false; tabbarEl.hidden = true; renderRecent(); }
|
|
871
|
+
function showHistory(address) {
|
|
872
|
+
hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
|
|
873
|
+
if (address != null) histSubtitle = address;
|
|
874
|
+
historyTitle.textContent = histSubtitle || '任务';
|
|
875
|
+
loadSessions();
|
|
876
|
+
}
|
|
877
|
+
function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
|
|
878
|
+
function showSched() { hideAll(); schedEl.hidden = false; tabbarEl.hidden = false; setActive('sched'); }
|
|
879
|
+
function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
|
|
880
|
+
function showMine() {
|
|
881
|
+
hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
|
|
882
|
+
mineDevice.textContent = histSubtitle || '本机';
|
|
883
|
+
var ns = document.getElementById('notifyState');
|
|
884
|
+
if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
|
|
885
|
+
markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
|
|
886
|
+
tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
|
|
887
|
+
paintRunPref();
|
|
888
|
+
refreshVpn();
|
|
889
|
+
}
|
|
890
|
+
function markSeg(seg, v) {
|
|
891
|
+
Array.prototype.forEach.call(seg.children, function (b) {
|
|
892
|
+
b.classList.toggle('is-on', b.getAttribute('data-v') === v);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/* ══════════ 发现:应用 / SKILL(暂无后端目录,占位说明)══════════ */
|
|
897
|
+
function openComingSheet(title, desc) {
|
|
898
|
+
openHostSheet(title, function (body) {
|
|
899
|
+
var hint = hEl('div', 'history__hint', desc);
|
|
900
|
+
hint.style.textAlign = 'left';
|
|
901
|
+
body.appendChild(hint);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/* ══════════ 发现 → 插件(magic-shorts / magic-video 的 CLI)══════════
|
|
906
|
+
数据全部来自桌面端 `GET /desk/plugins`:装没装、版本多少、安装命令是什么,都由服务端探测后宣布。
|
|
907
|
+
本页不自己判断状态 —— 那样两端会各说一套(「手机上显示已安装、装的时候说没装」最难查)。
|
|
908
|
+
|
|
909
|
+
安装/升级走 `POST /desk/plugins/{id}/{action}` 的 SSE:命令是**服务端写死的**,
|
|
910
|
+
手机只传 id 与动作;输出逐行回来,所以看得见在动,失败也能把 stderr 摊出来。 */
|
|
911
|
+
var PLUGINS = []; // 最近一次 GET /desk/plugins 的结果
|
|
912
|
+
var PLUGIN_BUSY = {}; // id → 正在跑的动作(防重复点)
|
|
913
|
+
var PLUGIN_LOG = {}; // id → 上次安装/升级的日志文本(收尾重画后仍看得到)
|
|
914
|
+
function pluginAPI() { return BASE; }
|
|
915
|
+
function pluginHeaders() { return TOKEN ? { 'X-Magic-Token': TOKEN } : {}; }
|
|
916
|
+
|
|
917
|
+
function loadPlugins() {
|
|
918
|
+
/* 已经有缓存就立刻画一遍(从详情返回时不闪「读取中」),再后台刷新真实状态。 */
|
|
919
|
+
if (PLUGINS.length) renderPlugins();
|
|
920
|
+
else pluginListEl.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
921
|
+
fetch(pluginAPI() + '/desk/plugins', { headers: pluginHeaders() })
|
|
922
|
+
.then(function (r) {
|
|
923
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
924
|
+
return r.json();
|
|
925
|
+
})
|
|
926
|
+
.then(function (j) {
|
|
927
|
+
PLUGINS = (j && j.plugins) || [];
|
|
928
|
+
renderPlugins();
|
|
929
|
+
})
|
|
930
|
+
.catch(function (e) {
|
|
931
|
+
pluginListEl.innerHTML = '';
|
|
932
|
+
pluginListEl.appendChild(hEl('div', 'history__hint',
|
|
933
|
+
'读取插件目录失败:' + (e && e.message ? e.message : e) +
|
|
934
|
+
'。需要桌面端为较新版本(/desk/plugins)。'));
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function renderPlugins() {
|
|
939
|
+
pluginListEl.innerHTML = '';
|
|
940
|
+
if (!PLUGINS.length) {
|
|
941
|
+
pluginListEl.appendChild(hEl('div', 'history__hint', '桌面端没有返回任何插件。'));
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
pluginListEl.appendChild(hEl('div', 'history__group', '工程 CLI'));
|
|
945
|
+
PLUGINS.forEach(function (p) {
|
|
946
|
+
var row = hEl('button', 'plugin'); row.type = 'button';
|
|
947
|
+
var ico = hEl('span', 'plugin__ico' + (p.installed ? ' is-on' : ''));
|
|
948
|
+
ico.innerHTML = '<svg width="22" height="22"><use href="#i-plug"/></svg>';
|
|
949
|
+
var col = hEl('span', 'plugin__col');
|
|
950
|
+
col.appendChild(hEl('span', 'plugin__t', p.name));
|
|
951
|
+
col.appendChild(hEl('span', 'plugin__s', p.tagline || ''));
|
|
952
|
+
var badge = hEl('span', 'pbadge ' + (p.installed ? 'pbadge--on' : 'pbadge--off'),
|
|
953
|
+
p.installed ? ('已安装' + (p.version ? ' v' + p.version : '')) : '未安装');
|
|
954
|
+
row.appendChild(ico); row.appendChild(col); row.appendChild(badge);
|
|
955
|
+
row.onclick = function () { showPluginDetail(p.id); };
|
|
956
|
+
pluginListEl.appendChild(row);
|
|
957
|
+
});
|
|
958
|
+
pluginListEl.appendChild(hEl('div', 'pd__note',
|
|
959
|
+
'安装与升级在**桌面端**执行(手机只发起),装完即可在会话里让 Agent 调用该命令。'));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function pluginById(id) {
|
|
963
|
+
for (var i = 0; i < PLUGINS.length; i++) if (PLUGINS[i].id === id) return PLUGINS[i];
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function showPlugins() {
|
|
968
|
+
hideAll(); pluginsEl.hidden = false; tabbarEl.hidden = true;
|
|
969
|
+
loadPlugins();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function showPluginDetail(id) {
|
|
973
|
+
var p = pluginById(id);
|
|
974
|
+
if (!p) { showPlugins(); return; }
|
|
975
|
+
hideAll(); pluginDetailEl.hidden = false; tabbarEl.hidden = true;
|
|
976
|
+
pluginDetailTitle.textContent = p.name;
|
|
977
|
+
renderPluginDetail(p);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function renderPluginDetail(p) {
|
|
981
|
+
var box = pluginDetailBody;
|
|
982
|
+
box.innerHTML = '';
|
|
983
|
+
var wrap = hEl('div', 'pd');
|
|
984
|
+
|
|
985
|
+
var head = hEl('div', 'pd__head');
|
|
986
|
+
head.appendChild(hEl('span', 'pd__name', p.name));
|
|
987
|
+
head.appendChild(hEl('span', 'pbadge ' + (p.installed ? 'pbadge--on' : 'pbadge--off'),
|
|
988
|
+
p.installed ? ('已安装' + (p.version ? ' v' + p.version : '')) : '未安装'));
|
|
989
|
+
wrap.appendChild(head);
|
|
990
|
+
wrap.appendChild(hEl('p', 'pd__tag', p.tagline || ''));
|
|
991
|
+
|
|
992
|
+
/* 状态说明(服务端给的那句「为什么是这个状态」) */
|
|
993
|
+
if (p.note) wrap.appendChild(hEl('div', 'pd__note', p.note));
|
|
994
|
+
if (p.bin) wrap.appendChild(hEl('div', 'pd__note', '命令:' + p.bin));
|
|
995
|
+
if (p.sourcePath) wrap.appendChild(hEl('div', 'pd__note', '源码:' + p.sourcePath));
|
|
996
|
+
|
|
997
|
+
wrap.appendChild(pdSection('介绍', hEl('p', 'pd__p', p.description || '')));
|
|
998
|
+
if (p.requirements) wrap.appendChild(pdSection('环境要求', hEl('p', 'pd__p', p.requirements)));
|
|
999
|
+
|
|
1000
|
+
var cmd = p.installed ? p.upgrade : p.install;
|
|
1001
|
+
var cmdLabel = p.installed ? '升级' : '安装';
|
|
1002
|
+
var cmdSec = hEl('div', 'pd__sec');
|
|
1003
|
+
cmdSec.appendChild(hEl('div', 'pd__h', cmdLabel + '命令(在桌面端执行)'));
|
|
1004
|
+
var pre = hEl('pre', 'pd__code', cmd || '(服务端没有给出安装命令)');
|
|
1005
|
+
cmdSec.appendChild(pre);
|
|
1006
|
+
|
|
1007
|
+
var actions = hEl('div', 'pd__actions');
|
|
1008
|
+
var goBtn = hEl('button', 'btn btn--filled', PLUGIN_BUSY[p.id] ? '正在' + PLUGIN_BUSY[p.id] + '…' : cmdLabel);
|
|
1009
|
+
goBtn.type = 'button';
|
|
1010
|
+
goBtn.disabled = !!PLUGIN_BUSY[p.id] || !cmd;
|
|
1011
|
+
goBtn.onclick = function () { runPluginAction(p.id, p.installed ? 'upgrade' : 'install'); };
|
|
1012
|
+
var copyBtn = hEl('button', 'btn btn--outlined', '复制命令');
|
|
1013
|
+
copyBtn.type = 'button';
|
|
1014
|
+
copyBtn.onclick = function () { copyText(cmd || '', '命令已复制'); };
|
|
1015
|
+
actions.appendChild(goBtn); actions.appendChild(copyBtn);
|
|
1016
|
+
cmdSec.appendChild(actions);
|
|
1017
|
+
|
|
1018
|
+
var log = hEl('pre', 'pd__log');
|
|
1019
|
+
log.textContent = PLUGIN_LOG[p.id] || '';
|
|
1020
|
+
log.hidden = !log.textContent;
|
|
1021
|
+
cmdSec.appendChild(log);
|
|
1022
|
+
wrap.appendChild(cmdSec);
|
|
1023
|
+
p.__log = log;
|
|
1024
|
+
|
|
1025
|
+
/* 让它干活:预填一段话到输入框(不直接发 —— 主题/受众得用户自己填) */
|
|
1026
|
+
var useSec = hEl('div', 'pd__sec');
|
|
1027
|
+
useSec.appendChild(hEl('div', 'pd__h', '用起来'));
|
|
1028
|
+
var useBtn = hEl('button', 'btn btn--filled', '新建会话并让它干活');
|
|
1029
|
+
useBtn.type = 'button';
|
|
1030
|
+
useBtn.style.marginTop = '0';
|
|
1031
|
+
useBtn.onclick = function () { usePlugin(p); };
|
|
1032
|
+
useSec.appendChild(useBtn);
|
|
1033
|
+
if (p.examples && p.examples.length) {
|
|
1034
|
+
var ex = hEl('pre', 'pd__code', p.examples.join('\n'));
|
|
1035
|
+
ex.style.marginTop = '10px';
|
|
1036
|
+
useSec.appendChild(ex);
|
|
1037
|
+
}
|
|
1038
|
+
useSec.appendChild(hEl('div', 'pd__note',
|
|
1039
|
+
'会话里由 Agent 在桌面端执行这些命令;长任务可以在任务列表里看进度。'));
|
|
1040
|
+
wrap.appendChild(useSec);
|
|
1041
|
+
|
|
1042
|
+
if (p.homepage) {
|
|
1043
|
+
var home = hEl('button', 'setrow');
|
|
1044
|
+
home.appendChild(hEl('span', 'setrow__l', '项目主页'));
|
|
1045
|
+
home.appendChild(hEl('span', 'setrow__v', p.homepage));
|
|
1046
|
+
home.onclick = function () { closeHostSheet(); openBrowser(p.homepage); };
|
|
1047
|
+
var card = hEl('div', 'setcard'); card.appendChild(home);
|
|
1048
|
+
wrap.appendChild(card);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
box.appendChild(wrap);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function pdSection(title, node) {
|
|
1055
|
+
var sec = hEl('div', 'pd__sec');
|
|
1056
|
+
sec.appendChild(hEl('div', 'pd__h', title));
|
|
1057
|
+
sec.appendChild(node);
|
|
1058
|
+
return sec;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function copyText(text, okMsg) {
|
|
1062
|
+
if (!text) return;
|
|
1063
|
+
try {
|
|
1064
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1065
|
+
navigator.clipboard.writeText(text).then(function () { toast(okMsg || '已复制'); },
|
|
1066
|
+
function () { toast('复制失败'); });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
} catch (e) {}
|
|
1070
|
+
toast('当前环境不支持复制');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/* 安装 / 升级:POST 出去,按 SSE 逐行画日志(命令由服务端定,本页只传 id 与动作)。 */
|
|
1074
|
+
function runPluginAction(id, action) {
|
|
1075
|
+
if (PLUGIN_BUSY[id]) { toast('已有任务在跑,等它结束'); return; }
|
|
1076
|
+
var p = pluginById(id);
|
|
1077
|
+
var log = p && p.__log;
|
|
1078
|
+
PLUGIN_BUSY[id] = action;
|
|
1079
|
+
PLUGIN_LOG[id] = '▶ 开始' + (action === 'install' ? '安装' : '升级') + '…\n';
|
|
1080
|
+
if (log) { log.hidden = false; log.textContent = PLUGIN_LOG[id]; }
|
|
1081
|
+
renderPluginDetailButtonState(p, action);
|
|
1082
|
+
|
|
1083
|
+
fetch(pluginAPI() + '/desk/plugins/' + encodeURIComponent(id) + '/' + action,
|
|
1084
|
+
{ method: 'POST', headers: pluginHeaders() })
|
|
1085
|
+
.then(function (resp) {
|
|
1086
|
+
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
|
|
1087
|
+
var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
|
|
1088
|
+
return (function pump() {
|
|
1089
|
+
return reader.read().then(function (chunk) {
|
|
1090
|
+
if (chunk.done) return null;
|
|
1091
|
+
buf += dec.decode(chunk.value, { stream: true });
|
|
1092
|
+
var blocks = buf.split('\n\n');
|
|
1093
|
+
buf = blocks.pop();
|
|
1094
|
+
blocks.forEach(function (b) {
|
|
1095
|
+
var name = '', data = '';
|
|
1096
|
+
b.split('\n').forEach(function (ln) {
|
|
1097
|
+
if (ln.indexOf('event: ') === 0) name = ln.slice(7).trim();
|
|
1098
|
+
else if (ln.indexOf('data: ') === 0) data += ln.slice(6);
|
|
1099
|
+
});
|
|
1100
|
+
if (!data) return;
|
|
1101
|
+
var ev; try { ev = JSON.parse(data); } catch (e) { return; }
|
|
1102
|
+
var line = '';
|
|
1103
|
+
if (name === 'step') line = '\n$ ' + ev.command + '\n';
|
|
1104
|
+
else if (name === 'line') line = ev.line + '\n';
|
|
1105
|
+
else if (name === 'end') {
|
|
1106
|
+
line = ev.ok ? '\n✔ 完成\n' : ('\n✘ 失败:' + (ev.error || ('退出码 ' + ev.code)) + '\n');
|
|
1107
|
+
if (ev.plugin) {
|
|
1108
|
+
PLUGINS = PLUGINS.map(function (x) { return x.id === ev.plugin.id ? ev.plugin : x; });
|
|
1109
|
+
renderPlugins();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (!line) return;
|
|
1113
|
+
PLUGIN_LOG[id] = (PLUGIN_LOG[id] || '') + line;
|
|
1114
|
+
if (log) { log.textContent = PLUGIN_LOG[id]; log.scrollTop = log.scrollHeight; }
|
|
1115
|
+
});
|
|
1116
|
+
return pump();
|
|
1117
|
+
});
|
|
1118
|
+
})();
|
|
1119
|
+
})
|
|
1120
|
+
.then(function () { delete PLUGIN_BUSY[id]; toast(action === 'install' ? '安装流程结束' : '升级流程结束'); })
|
|
1121
|
+
.catch(function (e) {
|
|
1122
|
+
delete PLUGIN_BUSY[id];
|
|
1123
|
+
PLUGIN_LOG[id] = (PLUGIN_LOG[id] || '') + '\n✘ ' + (e && e.message ? e.message : e) + '\n';
|
|
1124
|
+
if (log) { log.textContent = PLUGIN_LOG[id]; log.scrollTop = log.scrollHeight; }
|
|
1125
|
+
toast('执行失败:' + (e && e.message ? e.message : e));
|
|
1126
|
+
})
|
|
1127
|
+
.then(function () {
|
|
1128
|
+
/* 收尾后按服务端最新状态重画这一页(按钮文案要从「安装」变「升级」) */
|
|
1129
|
+
var fresh = pluginById(id);
|
|
1130
|
+
if (fresh && !pluginDetailEl.hidden) renderPluginDetail(fresh);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function renderPluginDetailButtonState(p, action) {
|
|
1135
|
+
if (!p) return;
|
|
1136
|
+
var btns = pluginDetailBody.querySelectorAll('.pd__actions .btn');
|
|
1137
|
+
if (btns[0]) {
|
|
1138
|
+
btns[0].disabled = true;
|
|
1139
|
+
btns[0].textContent = '正在' + (action === 'install' ? '安装' : '升级') + '…';
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/* 用它干活:新开会话,把「要做什么」预填进输入框(用户补主题/受众后自己发)。 */
|
|
1144
|
+
function usePlugin(p) {
|
|
1145
|
+
newConversation();
|
|
1146
|
+
var text = p.usePrompt || ('用 ' + p.cli + ' 完成一个任务。\n要求:');
|
|
1147
|
+
var ta = convEl.querySelector('textarea');
|
|
1148
|
+
if (!ta) { toast('输入框还没准备好,请重试'); return; }
|
|
1149
|
+
ta.value = text;
|
|
1150
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1151
|
+
ta.focus();
|
|
1152
|
+
/* 光标落到第一个待填的空位后面,省得用户自己去翻 */
|
|
1153
|
+
try {
|
|
1154
|
+
var i = text.indexOf(':\n');
|
|
1155
|
+
if (i >= 0) ta.setSelectionRange(i + 2, i + 2);
|
|
1156
|
+
} catch (e) {}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/* ══════════ 内置浏览器 ══════════
|
|
1160
|
+
用 iframe 内嵌第三方站点(对方没设 X-Frame-Options 才显示得出来)。
|
|
1161
|
+
导航栈只记我们主动打开的层级 —— 跨源页面内部跳转读不到,
|
|
1162
|
+
「返回」回退栈里的上一站,栈空即退出浏览器。 */
|
|
1163
|
+
var HOME_URL = 'https://m.baidu.com/';
|
|
1164
|
+
var BR_SITES = [
|
|
1165
|
+
{ name: '百度一下', url: 'https://m.baidu.com/' },
|
|
1166
|
+
{ name: '维基百科', url: 'https://zh.m.wikipedia.org/' },
|
|
1167
|
+
{ name: '微博', url: 'https://m.weibo.cn/' },
|
|
1168
|
+
{ name: '网易新闻', url: 'https://m.163.com/' },
|
|
1169
|
+
{ name: '什么值得买', url: 'https://www.smzdm.com/' },
|
|
1170
|
+
{ name: '携程旅行', url: 'https://m.ctrip.com/' },
|
|
1171
|
+
{ name: '哔哩哔哩', url: 'https://www.bilibili.com/' },
|
|
1172
|
+
{ name: '百度学术', url: 'https://xueshu.baidu.com/' }
|
|
1173
|
+
];
|
|
1174
|
+
var brStack = [];
|
|
1175
|
+
var BR_STAR_KEY = 'magic.browser.stars.v1';
|
|
1176
|
+
function loadStars() {
|
|
1177
|
+
try { var a = JSON.parse(localStorage.getItem(BR_STAR_KEY) || '[]'); return Array.isArray(a) ? a : []; }
|
|
1178
|
+
catch (e) { return []; }
|
|
1179
|
+
}
|
|
1180
|
+
function saveStars(a) { try { localStorage.setItem(BR_STAR_KEY, JSON.stringify(a)); } catch (e) {} }
|
|
1181
|
+
function siteName(u) {
|
|
1182
|
+
for (var i = 0; i < BR_SITES.length; i++) { if (BR_SITES[i].url === u) return BR_SITES[i].name; }
|
|
1183
|
+
try { return new URL(u).host; } catch (e) { return u; }
|
|
1184
|
+
}
|
|
1185
|
+
function brPaint() {
|
|
1186
|
+
var u = brStack[brStack.length - 1] || HOME_URL;
|
|
1187
|
+
if (document.activeElement === brAddr) return;
|
|
1188
|
+
brAddr.value = siteName(u);
|
|
1189
|
+
brShield.classList.toggle('is-plain', !/^https:/i.test(u));
|
|
1190
|
+
brStar.classList.toggle('is-on', loadStars().indexOf(u) >= 0);
|
|
1191
|
+
}
|
|
1192
|
+
function brNav(url) {
|
|
1193
|
+
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
|
1194
|
+
brStack.push(url);
|
|
1195
|
+
brLoad(url);
|
|
1196
|
+
brPaint();
|
|
1197
|
+
}
|
|
1198
|
+
/* 中转地址:iframe 经桌面端 /b/ 取页面,走**桌面端的网络**,而不是手机直连。
|
|
1199
|
+
BASE/TOKEN 都空(本地兜底页 / 未配对)时退回直连,行为与以前一致。
|
|
1200
|
+
「我的 → 网络代理」开着时,全机流量已经过隧道;此时再让浏览器单独走 /b/ 中转没有意义,
|
|
1201
|
+
反而会让页内资源分两个出口(Google 那种风控就是被这个坑到的),所以直接用原始地址。 */
|
|
1202
|
+
function vpnActive() { return VPN_ON === true; }
|
|
1203
|
+
function brProxyURL(u) {
|
|
1204
|
+
if (vpnActive()) return u; // 网络代理已开:整机都走隧道,这里不必再中转
|
|
1205
|
+
try {
|
|
1206
|
+
var b = BASE || (/^https?:$/i.test(location.protocol) ? location.origin : '');
|
|
1207
|
+
if (!b || !TOKEN) return u;
|
|
1208
|
+
var a = new URL(u, location.origin);
|
|
1209
|
+
if (a.protocol !== 'http:' && a.protocol !== 'https:') return u;
|
|
1210
|
+
var q = 'magicToken=' + encodeURIComponent(TOKEN);
|
|
1211
|
+
return b + '/b/' + a.protocol.replace(':', '') + '/' + a.host + (a.pathname || '/') +
|
|
1212
|
+
(a.search ? a.search + '&' + q : '?' + q);
|
|
1213
|
+
} catch (e) { return u; }
|
|
1214
|
+
}
|
|
1215
|
+
function brLoad(u) { brFrame.src = brProxyURL(u); }
|
|
1216
|
+
function openBrowser(url) {
|
|
1217
|
+
brStack = [url || HOME_URL];
|
|
1218
|
+
brLoad(brStack[0]);
|
|
1219
|
+
brPaint();
|
|
1220
|
+
brEl.hidden = false;
|
|
1221
|
+
}
|
|
1222
|
+
function closeBrowser() { brEl.hidden = true; brStack = []; brFrame.src = 'about:blank'; }
|
|
1223
|
+
function brBack() {
|
|
1224
|
+
if (brStack.length > 1) { brStack.pop(); brLoad(brStack[brStack.length - 1]); brPaint(); }
|
|
1225
|
+
else closeBrowser();
|
|
1226
|
+
}
|
|
1227
|
+
function brHome() { brStack = [HOME_URL]; brLoad(HOME_URL); brPaint(); }
|
|
1228
|
+
function brReload() { if (brStack.length) { brLoad(brStack[brStack.length - 1]); brPaint(); } }
|
|
1229
|
+
function brShare() {
|
|
1230
|
+
var u = brStack[brStack.length - 1] || HOME_URL;
|
|
1231
|
+
if (navigator.share) { navigator.share({ title: siteName(u), url: u }).catch(function () {}); return; }
|
|
1232
|
+
try {
|
|
1233
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1234
|
+
navigator.clipboard.writeText(u).then(function () { toast('链接已复制'); }, function () { toast('复制失败'); });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
} catch (e) {}
|
|
1238
|
+
toast('当前环境不支持分享');
|
|
1239
|
+
}
|
|
1240
|
+
function brToggleStar() {
|
|
1241
|
+
var u = brStack[brStack.length - 1] || HOME_URL;
|
|
1242
|
+
var stars = loadStars();
|
|
1243
|
+
var i = stars.indexOf(u);
|
|
1244
|
+
if (i >= 0) { stars.splice(i, 1); toast('已取消收藏'); }
|
|
1245
|
+
else { stars.unshift(u); toast('已收藏'); }
|
|
1246
|
+
saveStars(stars);
|
|
1247
|
+
brPaint();
|
|
1248
|
+
}
|
|
1249
|
+
function openBrSites() {
|
|
1250
|
+
openHostSheet('快捷站点', function (body) {
|
|
1251
|
+
BR_SITES.forEach(function (s) {
|
|
1252
|
+
hRow(body, s.name, s.url.replace(/^https?:\/\//, '').replace(/\/+$/, ''), function () {
|
|
1253
|
+
closeHostSheet();
|
|
1254
|
+
brNav(s.url);
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
body.appendChild(hEl('div', 'history__hint', '以上站点允许内嵌;其它站点可能拒绝被内嵌打开。'));
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
function openBrGo() {
|
|
1261
|
+
openHostSheet('打开网址', function (body) {
|
|
1262
|
+
var wrap = hEl('div');
|
|
1263
|
+
wrap.style.cssText = 'display:flex;gap:8px;padding:6px 0 2px';
|
|
1264
|
+
var inp = hEl('input');
|
|
1265
|
+
inp.type = 'url'; inp.placeholder = 'example.com 或 https://…';
|
|
1266
|
+
inp.setAttribute('autocomplete', 'off'); inp.setAttribute('spellcheck', 'false');
|
|
1267
|
+
inp.style.cssText = 'flex:1;min-width:0;height:38px;padding:0 12px;border-radius:10px;border:1px solid var(--border);background:var(--subtle);color:var(--ink);font-size:14px';
|
|
1268
|
+
var go = hEl('button', 'btn btn--filled', '打开');
|
|
1269
|
+
go.type = 'button';
|
|
1270
|
+
go.style.cssText = 'flex:0 0 auto;width:auto;height:38px;padding:0 16px;border-radius:10px;margin-top:0';
|
|
1271
|
+
function submit() {
|
|
1272
|
+
var v = String(inp.value || '').trim();
|
|
1273
|
+
if (!v) return;
|
|
1274
|
+
closeHostSheet();
|
|
1275
|
+
brTyped(v);
|
|
1276
|
+
}
|
|
1277
|
+
go.onclick = submit;
|
|
1278
|
+
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
|
|
1279
|
+
wrap.appendChild(inp); wrap.appendChild(go);
|
|
1280
|
+
body.appendChild(wrap);
|
|
1281
|
+
body.appendChild(hEl('div', 'history__hint', '仅支持 http(s) 链接;页面由桌面端中转抓取(走桌面端网络)。'));
|
|
1282
|
+
setTimeout(function () { try { inp.focus(); } catch (e) {} }, 80);
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
function brTyped(raw) {
|
|
1286
|
+
var v = String(raw || '').trim();
|
|
1287
|
+
if (!v) { brPaint(); return; }
|
|
1288
|
+
brNav(v);
|
|
1289
|
+
toast('已通过桌面端中转打开;若空白,可能是该站点限制或加载失败');
|
|
1290
|
+
}
|
|
1291
|
+
brAddr.addEventListener('focus', function () {
|
|
1292
|
+
brAddr.value = brStack[brStack.length - 1] || HOME_URL;
|
|
1293
|
+
setTimeout(function () { try { brAddr.select(); } catch (e) {} }, 60);
|
|
1294
|
+
});
|
|
1295
|
+
brForm.addEventListener('submit', function (e) {
|
|
1296
|
+
e.preventDefault();
|
|
1297
|
+
var v = brAddr.value;
|
|
1298
|
+
brAddr.blur();
|
|
1299
|
+
brTyped(v);
|
|
1300
|
+
});
|
|
1301
|
+
brAddr.addEventListener('blur', function () { brPaint(); });
|
|
1302
|
+
function takeSeg(seg, key, apply) {
|
|
1303
|
+
seg.addEventListener('click', function (e) {
|
|
1304
|
+
var b = e.target && e.target.closest ? e.target.closest('.seg__b') : null;
|
|
1305
|
+
if (!b) return;
|
|
1306
|
+
var v = b.getAttribute('data-v');
|
|
1307
|
+
savePref(key, v); apply(v); markSeg(seg, v);
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/* ══════════ 偏好:主题 / 字体 / 触感(本地持久化)══════════ */
|
|
1312
|
+
function loadPrefs() {
|
|
1313
|
+
try {
|
|
1314
|
+
return {
|
|
1315
|
+
theme: localStorage.getItem('magic.theme') || 'system',
|
|
1316
|
+
font: localStorage.getItem('magic.font') || '1',
|
|
1317
|
+
haptic: localStorage.getItem('magic.haptic') || 'on'
|
|
1318
|
+
};
|
|
1319
|
+
} catch (e) { return { theme: 'system', font: '1', haptic: 'on' }; }
|
|
1320
|
+
}
|
|
1321
|
+
function savePref(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
|
1322
|
+
function applyThemePref(v) {
|
|
1323
|
+
if (v === 'light' || v === 'dark') document.documentElement.setAttribute('data-theme', v);
|
|
1324
|
+
else document.documentElement.removeAttribute('data-theme');
|
|
1325
|
+
}
|
|
1326
|
+
/* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
|
|
1327
|
+
function applyFontPref(v) { try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {} }
|
|
1328
|
+
function applyHapticPref(v) { window.MAGIC_HAPTICS = (v !== 'off'); }
|
|
1329
|
+
/* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
|
|
1330
|
+
var TASK_MODE = 'parallel';
|
|
1331
|
+
try { TASK_MODE = localStorage.getItem('magic.taskmode') || 'parallel'; } catch (e) {}
|
|
1332
|
+
var PREFS = loadPrefs();
|
|
1333
|
+
applyThemePref(PREFS.theme); applyFontPref(PREFS.font); applyHapticPref(PREFS.haptic);
|
|
1334
|
+
paintRunPref();
|
|
1335
|
+
|
|
1336
|
+
/* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
|
|
1337
|
+
sid 非空 = 打开该会话(组件会走 readSession 回放历史);s = 该会话对象(标题取 prompt_head);空 = 新会话。 */
|
|
1338
|
+
function mountConversation(sid, s) {
|
|
1339
|
+
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
1340
|
+
convEl.innerHTML = '';
|
|
154
1341
|
try {
|
|
155
|
-
|
|
156
|
-
打包进 App 时这里会换成「走自己后端」的实现;当前先用 HTTP 参考件。 */
|
|
157
|
-
var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN });
|
|
1342
|
+
var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' });
|
|
158
1343
|
conv = window.MagicConversation.create({
|
|
159
|
-
el:
|
|
160
|
-
|
|
161
|
-
|
|
1344
|
+
el: convEl, transport: transport, titleEl: titleEl, workspace: WS,
|
|
1345
|
+
engine: RUN_PREF.engine, model: RUN_PREF.model,
|
|
1346
|
+
showPermission: false, showAttach: true, beforeAsk: gateAsk,
|
|
1347
|
+
onPick: rememberRunPick,
|
|
1348
|
+
onMenu: function (key) { if (key === 'scheduled') showSched(); }
|
|
162
1349
|
});
|
|
163
|
-
|
|
164
|
-
/* 供对接方/调试取用:window.magicConv.setSession(sid) 换会话 */
|
|
1350
|
+
if (sid) conv.setSession(sid, s);
|
|
165
1351
|
window.magicConv = conv;
|
|
166
1352
|
} catch (e) {
|
|
167
|
-
|
|
1353
|
+
var d = document.createElement('div');
|
|
1354
|
+
d.className = 'boot-err';
|
|
1355
|
+
d.textContent = '对话组件挂载失败:' + (e && e.message ? e.message : e);
|
|
1356
|
+
convEl.appendChild(d);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
function openSession(sid, s) {
|
|
1360
|
+
if (s && s.workspace) { WS = s.workspace; saveWs(); }
|
|
1361
|
+
showSession(s && s.workspace ? basename(s.workspace) : histSubtitle);
|
|
1362
|
+
mountConversation(sid, s);
|
|
1363
|
+
}
|
|
1364
|
+
function newConversation() { showSession(); mountConversation(null); }
|
|
1365
|
+
|
|
1366
|
+
function connect(target) {
|
|
1367
|
+
BASE = target.url || '';
|
|
1368
|
+
TOKEN = target.token || '';
|
|
1369
|
+
recordRecent(target);
|
|
1370
|
+
showError('');
|
|
1371
|
+
showHistory(target.host || '');
|
|
1372
|
+
startPoll();
|
|
1373
|
+
}
|
|
1374
|
+
function disconnect() {
|
|
1375
|
+
stopPoll();
|
|
1376
|
+
if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
|
|
1377
|
+
convEl.innerHTML = '';
|
|
1378
|
+
BASE = ''; TOKEN = '';
|
|
1379
|
+
showLauncher();
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/* ══════════ 历史会话列表(GET /desk/sessions,字段同 core 的 --sessions)══════════ */
|
|
1383
|
+
function fmtRel(s) {
|
|
1384
|
+
if (!s) return '';
|
|
1385
|
+
var d = new Date(String(s).replace(' ', 'T'));
|
|
1386
|
+
if (isNaN(d.getTime())) return String(s);
|
|
1387
|
+
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
|
1388
|
+
var hm = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
1389
|
+
var now = new Date();
|
|
1390
|
+
if (d.toDateString() === now.toDateString()) return hm;
|
|
1391
|
+
if (d.toDateString() === new Date(now.getTime() - 86400000).toDateString()) return '昨天 ' + hm;
|
|
1392
|
+
return (d.getMonth() + 1) + '-' + d.getDate();
|
|
1393
|
+
}
|
|
1394
|
+
/* ── 列表页可见时静默轮询清单(2026-10-16)──
|
|
1395
|
+
为什么需要:`loadSessions()` 只在**进入列表页**时跑一次,于是「PC / 另一台手机刚起的任务」
|
|
1396
|
+
在这份列表上不会自己冒出来(得先退出这张页再进来)。而用户要的正是「多个客户端马上就能
|
|
1397
|
+
看到正在跑的回话」。这里每 5s 拉一次 `/desk/sessions`;**只有在清单真的变了时才重绘** ——
|
|
1398
|
+
没变就不动 DOM(整表重绘会闪、还会丢滚动位置,见 renderSessions 的注释)。
|
|
1399
|
+
进度那一列不靠重绘:RUN_TICK 每 3s 就地刷「第 N 步」与时长(见 tickRunning)。 */
|
|
1400
|
+
var LIST_POLL = null;
|
|
1401
|
+
function sessionSig(list) {
|
|
1402
|
+
return (list || []).map(function (s) {
|
|
1403
|
+
return sidOf(s) + ':' + (s.state || '') + ':' + (s.updated_at || '');
|
|
1404
|
+
}).join('|');
|
|
1405
|
+
}
|
|
1406
|
+
function startListPoll() {
|
|
1407
|
+
if (LIST_POLL) return;
|
|
1408
|
+
LIST_POLL = setInterval(function () {
|
|
1409
|
+
if (historyEl.hidden) return; // 只在这张页可见时拉
|
|
1410
|
+
if (!TOKEN) return; // 还没配对,拉也拉不到
|
|
1411
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
1412
|
+
fetch(BASE + '/desk/sessions', { headers: hdr })
|
|
1413
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1414
|
+
.then(function (list) {
|
|
1415
|
+
if (!Array.isArray(list)) return;
|
|
1416
|
+
if (sessionSig(list) === sessionSig(LAST_SESSIONS)) return; // 没变 → 不动 DOM
|
|
1417
|
+
loadSessions(true); // 变了才走完整刷新(含标题归纳 / 健康),且不许闪
|
|
1418
|
+
}).catch(function () {});
|
|
1419
|
+
}, 5000);
|
|
1420
|
+
}
|
|
1421
|
+
function loadSessions(quiet) {
|
|
1422
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
1423
|
+
/* quiet(由 startListPoll 的轮询触发):不塞「读取中…」占位、失败也不覆盖现有列表 ——
|
|
1424
|
+
轮询是**后台行为**,不能因为一次网络抖动就把用户正看着的列表换成一行错误,
|
|
1425
|
+
也不该让他每次看到整页闪一下「读取中…」(这正是 deleteSessionRow 绕开它的原因)。 */
|
|
1426
|
+
if (!quiet) historyList.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
1427
|
+
var pList = fetch(BASE + '/desk/sessions', { headers: hdr }).then(function (r) {
|
|
1428
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1429
|
+
return r.json();
|
|
1430
|
+
});
|
|
1431
|
+
var pTitles = fetch(BASE + '/desk/titles', { headers: hdr })
|
|
1432
|
+
.then(function (r) { return r.ok ? r.json() : {}; }).catch(function () { return {}; });
|
|
1433
|
+
var pHealth = fetch(BASE + '/desk/health', { headers: hdr })
|
|
1434
|
+
.then(function (r) { return r.ok ? r.json() : {}; }).catch(function () { return {}; });
|
|
1435
|
+
var pDirs = fetch(BASE + '/desk/dirs', { headers: hdr })
|
|
1436
|
+
.then(function (r) { return r.ok ? r.json() : {}; }).catch(function () { return {}; });
|
|
1437
|
+
Promise.all([pList, pTitles, pHealth, pDirs]).then(function (a) {
|
|
1438
|
+
TITLES = a[1] || {};
|
|
1439
|
+
DW = (a[2] && a[2].defaultWorkspace) || '';
|
|
1440
|
+
SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
|
|
1441
|
+
ROOT_DIRS = (((a[3] && a[3].dirs) || [])).filter(function (d) { return String(d.name).indexOf('magic-') === 0; });
|
|
1442
|
+
renderSessions(Array.isArray(a[0]) ? a[0] : []);
|
|
1443
|
+
/* 给**还没归纳过**的会话补一次(core 幂等,已有的直接回 cached 不重复调 LLM)。
|
|
1444
|
+
判定认 core 的 `title_summarized`,**不能**认「title 为空」——
|
|
1445
|
+
core 的 title 恒非空(没归纳就回落 prompt_head),拿空判会永远不触发
|
|
1446
|
+
(界面上只是标题一直显示首问原文,看不出是 bug)。
|
|
1447
|
+
只在**列表真正画出来之后**触发:这样就地改标题时那一行已存在。
|
|
1448
|
+
逐条限流发起、不等待 —— 归纳慢也不能拖住列表显示。 */
|
|
1449
|
+
var pending = LAST_SESSIONS.filter(function (s) { return !s.title_summarized; });
|
|
1450
|
+
pending.slice(0, TITLE_SUMMARIZE_CAP).forEach(function (s) { setTimeout(function () { ensureTitle(s); }, 0); });
|
|
1451
|
+
}).catch(function (e) {
|
|
1452
|
+
if (quiet) return; // 后台轮询失败:保留现有列表(见上面的说明)
|
|
1453
|
+
historyList.innerHTML = '<div class="history__hint">读取会话失败:' + (e && e.message ? e.message : e) + '</div>';
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
/* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
|
|
1457
|
+
置顶:本地 pin(长按会话行切换);任务:最近会话(默认前 8,可「展开更多」);
|
|
1458
|
+
空间:会话里出现过的 workspace(点行=按空间筛选任务,+=在该空间新建)。 */
|
|
1459
|
+
var LAST_SESSIONS = [];
|
|
1460
|
+
var SEC_COLLAPSED = {};
|
|
1461
|
+
var TASKS_EXPANDED = false;
|
|
1462
|
+
var WS_OPEN = {};
|
|
1463
|
+
var PINNED = [];
|
|
1464
|
+
try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
|
|
1465
|
+
function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} }
|
|
1466
|
+
var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
|
|
1467
|
+
var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
|
|
1468
|
+
var SPACES_ROOT = ''; // 工作空间根(health.workspacesRoot)
|
|
1469
|
+
var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
|
|
1470
|
+
var CUSTOM_SPACES = [];
|
|
1471
|
+
try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
|
|
1472
|
+
function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} }
|
|
1473
|
+
/* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。 */
|
|
1474
|
+
function collectSpaceKeys() {
|
|
1475
|
+
var seen = {}, out = [];
|
|
1476
|
+
function add(p) { if (p && !seen[p]) { seen[p] = 1; out.push(p); } }
|
|
1477
|
+
(ROOT_DIRS || []).forEach(function (d) { add(d.path); });
|
|
1478
|
+
CUSTOM_SPACES.forEach(add);
|
|
1479
|
+
(LAST_SESSIONS || []).forEach(function (s) { add(s.workspace || DW); });
|
|
1480
|
+
return out;
|
|
1481
|
+
}
|
|
1482
|
+
/* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
|
|
1483
|
+
会话标题」)。core 的 --sessions 已把「标题表覆盖 → prompt_head」合并成一个 title 字段,
|
|
1484
|
+
所以这里不再自己拼优先级 —— 此前本文件是 `TITLES[id] || prompt_head`、PC 端是另一套
|
|
1485
|
+
拼法,两端因此显示不同标题。
|
|
1486
|
+
TITLES 仍作为**兜底**:老版本 core 的 --sessions 没有 title 字段。 */
|
|
1487
|
+
function titleOf(s) { return s.title || TITLES[sidOf(s)] || s.prompt_head || String(sidOf(s)).slice(0, 8); }
|
|
1488
|
+
|
|
1489
|
+
/* ── 让 core 归纳标题(POST /desk/session/{id}/title/summarize)──
|
|
1490
|
+
为什么需要它:归纳口径已收进 core,但**要有人去触发**。桌面端有客户端在触发,
|
|
1491
|
+
移动端此前没有 —— 于是从手机发起的会话永远没人替它归纳,只能显示首问原文
|
|
1492
|
+
(列表里一片「继续」「好的」这类追问词当标题)。
|
|
1493
|
+
幂等:core 侧已有覆盖就直接返回,不重复调 LLM,所以列表刷新时可以放心逐条试。
|
|
1494
|
+
⚠️ 静默失败:归纳是锦上添花,绝不能因为它把列表刷崩或弹错(标题回落到 prompt_head 即可)。 */
|
|
1495
|
+
function ensureTitle(s) {
|
|
1496
|
+
if (!s || s.title_summarized) return; // core 说已经归纳过了 → 不用问
|
|
1497
|
+
var id = sidOf(s); if (!id) return;
|
|
1498
|
+
if (TITLE_SUMMARIZING[id]) return; // 别重复打同一发
|
|
1499
|
+
TITLE_SUMMARIZING[id] = 1;
|
|
1500
|
+
var hdr = { 'X-Magic-Token': TOKEN };
|
|
1501
|
+
fetch(BASE + '/desk/session/' + encodeURIComponent(id) + '/title/summarize',
|
|
1502
|
+
{ method: 'POST', headers: hdr })
|
|
1503
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1504
|
+
.then(function (j) {
|
|
1505
|
+
if (j && j.title) {
|
|
1506
|
+
s.title = j.title;
|
|
1507
|
+
s.title_summarized = true;
|
|
1508
|
+
TITLES[id] = j.title; // 也存一份,供老 core 的 titleOf 兜底
|
|
1509
|
+
/* 就地改那一行的标题,不整表重绘 —— 重绘会打断滚动位置与正在点的行。 */
|
|
1510
|
+
var el = document.querySelector('[data-sid="' + cssEscape(id) + '"] .srow__t');
|
|
1511
|
+
if (el) el.textContent = j.title;
|
|
1512
|
+
}
|
|
1513
|
+
})
|
|
1514
|
+
.catch(function () { /* 留着 prompt_head 就行 */ })
|
|
1515
|
+
.then(function () { delete TITLE_SUMMARIZING[id]; });
|
|
1516
|
+
}
|
|
1517
|
+
var TITLE_SUMMARIZING = {};
|
|
1518
|
+
/* 一次列表加载最多补归纳几条:每条都可能真调一次 LLM(收敛要几秒)。
|
|
1519
|
+
列表通常只有个位数会话,给足即可;超出的等下次刷新(core 幂等,不会重复算)。 */
|
|
1520
|
+
var TITLE_SUMMARIZE_CAP = 12;
|
|
1521
|
+
function cssEscape(v) { return String(v).replace(/["\\]/g, '\\$&'); }
|
|
1522
|
+
|
|
1523
|
+
function sidOf(s) { return s.session_id || s.run_id; }
|
|
1524
|
+
function isPinned(s) { return PINNED.indexOf(sidOf(s)) >= 0; }
|
|
1525
|
+
function togglePin(s) {
|
|
1526
|
+
var id = sidOf(s), i = PINNED.indexOf(id);
|
|
1527
|
+
if (i >= 0) PINNED.splice(i, 1); else PINNED.unshift(id);
|
|
1528
|
+
savePinned(); renderSessions(LAST_SESSIONS);
|
|
1529
|
+
}
|
|
1530
|
+
/* 长按会话行 → 浮动菜单:置顶/取消置顶、重命名、删除(对齐截图)。 */
|
|
1531
|
+
var rowMenuEl = null, rowScrimEl = null;
|
|
1532
|
+
function closeRowMenu() {
|
|
1533
|
+
if (rowMenuEl) { rowMenuEl.remove(); rowMenuEl = null; }
|
|
1534
|
+
if (rowScrimEl) { rowScrimEl.remove(); rowScrimEl = null; }
|
|
1535
|
+
}
|
|
1536
|
+
function openRowMenu(anchorEl, s) {
|
|
1537
|
+
closeRowMenu();
|
|
1538
|
+
rowScrimEl = document.createElement('div'); rowScrimEl.className = 'rowmenu__scrim';
|
|
1539
|
+
rowScrimEl.onclick = closeRowMenu;
|
|
1540
|
+
rowMenuEl = document.createElement('div'); rowMenuEl.className = 'rowmenu';
|
|
1541
|
+
function item(icon, label, onTap, danger) {
|
|
1542
|
+
var b = document.createElement('button'); b.type = 'button';
|
|
1543
|
+
b.className = 'rowmenu__item' + (danger ? ' rowmenu__item--danger' : '');
|
|
1544
|
+
b.appendChild(svgUse(icon, 18)); b.appendChild(elSpan('rowmenu__l', label));
|
|
1545
|
+
b.onclick = function (e) { e.stopPropagation(); onTap(); };
|
|
1546
|
+
rowMenuEl.appendChild(b);
|
|
1547
|
+
}
|
|
1548
|
+
item('i-pin', isPinned(s) ? '取消置顶' : '置顶', function () { closeRowMenu(); togglePin(s); });
|
|
1549
|
+
item('i-edit', '重命名', function () { closeRowMenu(); renameSession(s); });
|
|
1550
|
+
item('i-trash', '删除', function () { closeRowMenu(); deleteSessionRow(s); }, true);
|
|
1551
|
+
document.body.appendChild(rowScrimEl); document.body.appendChild(rowMenuEl);
|
|
1552
|
+
var r = anchorEl.getBoundingClientRect();
|
|
1553
|
+
var mw = rowMenuEl.offsetWidth, mh = rowMenuEl.offsetHeight;
|
|
1554
|
+
rowMenuEl.style.top = Math.max(12, Math.min(r.top, window.innerHeight - mh - 12)) + 'px';
|
|
1555
|
+
rowMenuEl.style.left = Math.max(12, Math.min(r.left + 10, window.innerWidth - mw - 12)) + 'px';
|
|
1556
|
+
}
|
|
1557
|
+
/* 重命名:写服务端标题表(PUT /desk/session/{id}/title),成功后就地更新并重绘。 */
|
|
1558
|
+
function renameSession(s) {
|
|
1559
|
+
var id = sidOf(s);
|
|
1560
|
+
var name = window.prompt('重命名会话', titleOf(s));
|
|
1561
|
+
if (name == null) return;
|
|
1562
|
+
name = name.trim(); if (!name) return;
|
|
1563
|
+
var hdr = { 'Content-Type': 'application/json' };
|
|
1564
|
+
if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
|
|
1565
|
+
fetch(BASE + '/desk/session/' + encodeURIComponent(id) + '/title', { method: 'PUT', headers: hdr, body: JSON.stringify({ title: name }) })
|
|
1566
|
+
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
1567
|
+
.then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
|
|
1568
|
+
.catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
|
|
1569
|
+
}
|
|
1570
|
+
/* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志),然后刷新列表。 */
|
|
1571
|
+
function deleteSessionRow(s) {
|
|
1572
|
+
var id = sidOf(s);
|
|
1573
|
+
if (!window.confirm('删除这个会话?此操作不可恢复。')) return;
|
|
1574
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
1575
|
+
fetch(BASE + '/desk/session/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr })
|
|
1576
|
+
.then(function (r) {
|
|
1577
|
+
if (!r.ok) return r.json().catch(function () { return {}; }).then(function (j) { throw new Error(j.error || ('HTTP ' + r.status)); });
|
|
1578
|
+
return r.json().catch(function () { return {}; });
|
|
1579
|
+
})
|
|
1580
|
+
.then(function () {
|
|
1581
|
+
var i = PINNED.indexOf(id); if (i >= 0) { PINNED.splice(i, 1); savePinned(); }
|
|
1582
|
+
if (TITLES[id]) { delete TITLES[id]; }
|
|
1583
|
+
/* 本地移除 + 静默重绘:不走 loadSessions(那会先塞「读取中…」再重建 = 整页闪) */
|
|
1584
|
+
LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return sidOf(x) !== id; });
|
|
1585
|
+
renderSessions(LAST_SESSIONS);
|
|
1586
|
+
})
|
|
1587
|
+
.catch(function (e) { window.alert('删除失败:' + (e && e.message ? e.message : e)); });
|
|
1588
|
+
}
|
|
1589
|
+
function elSpan(cls, text) { var n = document.createElement('span'); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
|
|
1590
|
+
function svgUse(href, size) {
|
|
1591
|
+
var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
1592
|
+
s.setAttribute('width', size); s.setAttribute('height', size);
|
|
1593
|
+
var u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
|
1594
|
+
u.setAttribute('href', href); s.appendChild(u); return s;
|
|
1595
|
+
}
|
|
1596
|
+
function secHead(label, count, collapsible, key) {
|
|
1597
|
+
var h = document.createElement('div'); h.className = 'sechead';
|
|
1598
|
+
h.appendChild(elSpan('sechead__t', label + ' (' + count + ')'));
|
|
1599
|
+
if (collapsible) {
|
|
1600
|
+
var chev = elSpan('sechead__chev' + (SEC_COLLAPSED[key] ? '' : ' is-open'));
|
|
1601
|
+
chev.appendChild(svgUse('#i-chev-r', 14));
|
|
1602
|
+
h.appendChild(chev); h.style.cursor = 'pointer';
|
|
1603
|
+
h.onclick = function () { SEC_COLLAPSED[key] = !SEC_COLLAPSED[key]; renderSessions(LAST_SESSIONS); };
|
|
1604
|
+
}
|
|
1605
|
+
return h;
|
|
1606
|
+
}
|
|
1607
|
+
/* 会话状态:由 /desk/sessions 的 state / alive 映射(running / waiting_approval / done / 其它)。 */
|
|
1608
|
+
function statusOf(s) {
|
|
1609
|
+
var st = String(s.state || '');
|
|
1610
|
+
if (st === 'running' || s.alive === true) return 'run';
|
|
1611
|
+
if (st === 'waiting_approval') return 'wait';
|
|
1612
|
+
if (st === 'done') return 'done';
|
|
1613
|
+
return 'idle';
|
|
1614
|
+
}
|
|
1615
|
+
/* 会话行图标:**设备形状按来源端**(mobile=手机 / 其它=电脑),状态用颜色区分(无角标)。 */
|
|
1616
|
+
function sessionIcon(s) {
|
|
1617
|
+
var ico = elSpan('srow__ico srow__ico--' + statusOf(s));
|
|
1618
|
+
ico.appendChild(svgUse(String(s && s.source) === 'mobile' ? '#i-phone' : '#i-laptop', 20));
|
|
1619
|
+
return ico;
|
|
1620
|
+
}
|
|
1621
|
+
/* ── 运行中任务的**真实进度** ──
|
|
1622
|
+
步数 = 会话日志里 tool_use 的次数(真实动作数);时长 = now − started_at。
|
|
1623
|
+
不整表重绘:只就地更新那一行的副标题与时间,避免闪烁/丢滚动位置。 */
|
|
1624
|
+
var RUN_STEPS = {}; // sid → 已执行步数
|
|
1625
|
+
var RUN_CURSOR = {}; // sid → 已读到的事件 seq(null = 还没读过,下次全量)
|
|
1626
|
+
var RUN_ROWS = {}; // sid → 该行的 DOM 引用
|
|
1627
|
+
var RUN_TICK = null;
|
|
1628
|
+
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
1629
|
+
function elapsedText(s) {
|
|
1630
|
+
var st = new Date(String(s.started_at || '').replace(' ', 'T'));
|
|
1631
|
+
if (isNaN(st.getTime())) return '';
|
|
1632
|
+
var sec = Math.max(0, Math.floor((Date.now() - st.getTime()) / 1000));
|
|
1633
|
+
var m = Math.floor(sec / 60), ss = sec % 60;
|
|
1634
|
+
return m >= 60 ? (Math.floor(m / 60) + ':' + pad2(m % 60) + ':' + pad2(ss)) : (m + ':' + pad2(ss));
|
|
1635
|
+
}
|
|
1636
|
+
function runningSub(s, steps) { return steps ? ('运行中 · 第 ' + steps + ' 步') : '运行中'; }
|
|
1637
|
+
/* 取「已跑到第几步」。**读过的部分不再重读**(2026-10-20):
|
|
1638
|
+
原来每 3s 对每条 running 会话拉一次全量 `/messages`,只为数 tool_use 的条数 ——
|
|
1639
|
+
长会话下等于让手机每 3 秒解析一遍几百 MB 的历史(列表页的主要卡顿来源)。
|
|
1640
|
+
现在记一个 seq 游标:首次全量(带 lean=1 让服务端裁掉 snapshot),之后只拉增量,
|
|
1641
|
+
步数在本地累加。游标超前(日志被重置/换会话)时服务端回 snapshotRequired,重置重来。 */
|
|
1642
|
+
function refreshProgress(sid) {
|
|
1643
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
1644
|
+
var after = RUN_CURSOR[sid];
|
|
1645
|
+
var url = BASE + '/desk/session/' + encodeURIComponent(sid) +
|
|
1646
|
+
(after == null ? '/messages?lean=1' : '/events?after=' + encodeURIComponent(after) + '&lean=1');
|
|
1647
|
+
fetch(url, { headers: hdr })
|
|
1648
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1649
|
+
.then(function (d) {
|
|
1650
|
+
if (!d) return;
|
|
1651
|
+
if (d.snapshotRequired) { // 游标不可信 → 丢掉重来
|
|
1652
|
+
RUN_CURSOR[sid] = null; RUN_STEPS[sid] = 0;
|
|
1653
|
+
refreshProgress(sid);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
var n = (after == null) ? 0 : (RUN_STEPS[sid] || 0);
|
|
1657
|
+
var maxSeq = 0;
|
|
1658
|
+
(d.events || []).forEach(function (e) {
|
|
1659
|
+
if ((e.kind || e.type) === 'tool_use') n++;
|
|
1660
|
+
if (e.seq > maxSeq) maxSeq = e.seq;
|
|
1661
|
+
});
|
|
1662
|
+
if (d.lastSeq != null && d.lastSeq > maxSeq) maxSeq = d.lastSeq;
|
|
1663
|
+
RUN_CURSOR[sid] = maxSeq || after || null;
|
|
1664
|
+
RUN_STEPS[sid] = n;
|
|
1665
|
+
var r = RUN_ROWS[sid];
|
|
1666
|
+
if (r && r.subEl) r.subEl.textContent = runningSub(r.s, n);
|
|
1667
|
+
}).catch(function () {});
|
|
1668
|
+
}
|
|
1669
|
+
function tickRunning() {
|
|
1670
|
+
var ids = Object.keys(RUN_ROWS);
|
|
1671
|
+
if (!ids.length) { if (RUN_TICK) { clearInterval(RUN_TICK); RUN_TICK = null; } return; }
|
|
1672
|
+
ids.forEach(function (sid) {
|
|
1673
|
+
var r = RUN_ROWS[sid];
|
|
1674
|
+
if (r && r.timeEl) r.timeEl.textContent = elapsedText(r.s);
|
|
1675
|
+
refreshProgress(sid);
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
function ensureTicker() {
|
|
1679
|
+
if (!RUN_TICK && Object.keys(RUN_ROWS).length) RUN_TICK = setInterval(tickRunning, 3000);
|
|
1680
|
+
}
|
|
1681
|
+
function sessionRow(s) {
|
|
1682
|
+
var b = document.createElement('button');
|
|
1683
|
+
b.type = 'button'; b.className = 'srow';
|
|
1684
|
+
b.setAttribute('data-sid', sidOf(s)); // ensureTitle 就地改标题时要按 id 找这一行
|
|
1685
|
+
var run = statusOf(s) === 'run';
|
|
1686
|
+
b.appendChild(sessionIcon(s));
|
|
1687
|
+
var col = elSpan('srow__col');
|
|
1688
|
+
col.appendChild(elSpan('srow__t', titleOf(s)));
|
|
1689
|
+
var sid = sidOf(s);
|
|
1690
|
+
var subEl = elSpan('srow__s', run ? runningSub(s, RUN_STEPS[sid] || 0)
|
|
1691
|
+
: [s.engine || '', s.model || ''].filter(Boolean).join(' · '));
|
|
1692
|
+
col.appendChild(subEl);
|
|
1693
|
+
var pin = elSpan('srow__pin' + (isPinned(s) ? ' is-on' : '')); pin.appendChild(svgUse('#i-pin', 16));
|
|
1694
|
+
b.appendChild(col); b.appendChild(pin);
|
|
1695
|
+
var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
|
|
1696
|
+
b.appendChild(timeEl);
|
|
1697
|
+
if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
|
|
1698
|
+
var lp = false, timer = null;
|
|
1699
|
+
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
|
|
1700
|
+
b.addEventListener('pointerdown', function () {
|
|
1701
|
+
cancel(); timer = setTimeout(function () { timer = null; lp = true; openRowMenu(b, s); }, 500);
|
|
1702
|
+
});
|
|
1703
|
+
b.addEventListener('pointerup', cancel);
|
|
1704
|
+
b.addEventListener('pointerleave', cancel);
|
|
1705
|
+
b.addEventListener('pointercancel', cancel);
|
|
1706
|
+
b.onclick = function () { if (lp) { lp = false; return; } openSession(sidOf(s), s); };
|
|
1707
|
+
return b;
|
|
1708
|
+
}
|
|
1709
|
+
/* 空间组:标题行 = 「📁 空间名 · 引擎 ⌄ +」,展开就把该空间的会话内联列在下面。 */
|
|
1710
|
+
function workspaceGroup(key, list) {
|
|
1711
|
+
var wrap = document.createElement('div');
|
|
1712
|
+
var head = document.createElement('div'); head.className = 'wsrow__head';
|
|
1713
|
+
var tog = document.createElement('button'); tog.type = 'button'; tog.className = 'wsrow__open';
|
|
1714
|
+
/* list 里可能混着「等待中」条目(串行排队,见 gateAsk):第一条未必有 engine,
|
|
1715
|
+
找第一个有的,别让空间标题的引擎名因为排队行而消失。 */
|
|
1716
|
+
var eng = '';
|
|
1717
|
+
for (var i = 0; i < list.length; i++) { if (list[i] && list[i].engine) { eng = String(list[i].engine); break; } }
|
|
1718
|
+
var waiting = list.filter(function (x) { return x && x.pendingWait; }).length;
|
|
1719
|
+
tog.appendChild(elSpan('wsrow__l', basename(key) + (eng ? ' · ' + eng : '')));
|
|
1720
|
+
/* 折叠状态也要看得见「这个空间有任务在排队」—— 徽标不随展开/折叠消失。 */
|
|
1721
|
+
if (waiting) tog.appendChild(elSpan('wsrow__badge', waiting + ' 等待中'));
|
|
1722
|
+
/* 默认展开规则:组里有等待中的任务就展开(否则它藏在折叠组里 = 用户以为没显示);
|
|
1723
|
+
用户手动点过(WS_OPEN[key] 已是 true/false)就完全按用户的来。 */
|
|
1724
|
+
var open = (WS_OPEN[key] !== undefined) ? !!WS_OPEN[key] : waiting > 0;
|
|
1725
|
+
var chev = elSpan('wsrow__chev' + (open ? ' is-open' : '')); chev.appendChild(svgUse('#i-chev-r', 16));
|
|
1726
|
+
tog.appendChild(chev);
|
|
1727
|
+
tog.onclick = function () { WS_OPEN[key] = !open; renderSessions(LAST_SESSIONS); };
|
|
1728
|
+
var add = document.createElement('button'); add.type = 'button'; add.className = 'wsrow__add';
|
|
1729
|
+
add.setAttribute('aria-label', '在此空间新建会话'); add.appendChild(svgUse('#i-plus', 16));
|
|
1730
|
+
add.onclick = function () { WS = key; newConversation(); };
|
|
1731
|
+
head.appendChild(tog); head.appendChild(add);
|
|
1732
|
+
wrap.appendChild(head);
|
|
1733
|
+
if (open) list.forEach(function (s) { wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s)); });
|
|
1734
|
+
return wrap;
|
|
1735
|
+
}
|
|
1736
|
+
function renderSessions(list) {
|
|
1737
|
+
LAST_SESSIONS = list || [];
|
|
1738
|
+
RUN_ROWS = {};
|
|
1739
|
+
/* 同一 session_id 可能有多轮 run,只留最新一条。 */
|
|
1740
|
+
var seen = {}, rows = [];
|
|
1741
|
+
LAST_SESSIONS.forEach(function (s) {
|
|
1742
|
+
var key = sidOf(s);
|
|
1743
|
+
if (!key || seen[key]) return;
|
|
1744
|
+
seen[key] = 1; rows.push(s);
|
|
1745
|
+
});
|
|
1746
|
+
rows.sort(function (a, b) {
|
|
1747
|
+
return String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
|
|
1748
|
+
});
|
|
1749
|
+
/* 一次性构建到 DocumentFragment,最后 replaceChildren 单次替换 —— 无「先清空」的中间空态,
|
|
1750
|
+
所以删除、折叠、展开等重绘都不闪。 */
|
|
1751
|
+
var frag = document.createDocumentFragment();
|
|
1752
|
+
if (!rows.length && !PENDING.length) {
|
|
1753
|
+
var hint = document.createElement('div'); hint.className = 'history__hint';
|
|
1754
|
+
hint.textContent = '还没有会话。点上面「新建任务」开一个。';
|
|
1755
|
+
frag.appendChild(hint);
|
|
1756
|
+
historyList.replaceChildren(frag);
|
|
1757
|
+
ensureTicker();
|
|
1758
|
+
if (!Object.keys(RUN_ROWS).length && RUN_TICK) { clearInterval(RUN_TICK); RUN_TICK = null; }
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/* 置顶:只在这里显示;置顶项不再出现在「任务 / 空间」里(去重) */
|
|
1763
|
+
var pinned = rows.filter(isPinned);
|
|
1764
|
+
var rest = rows.filter(function (s) { return !isPinned(s); });
|
|
1765
|
+
if (pinned.length) {
|
|
1766
|
+
frag.appendChild(secHead('置顶', pinned.length, false, 'pin'));
|
|
1767
|
+
if (!SEC_COLLAPSED.pin) pinned.forEach(function (s) { frag.appendChild(sessionRow(s)); });
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
|
|
1771
|
+
显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
|
|
1772
|
+
现在没有显示在所属 workspace 中」)—— 它们还没真正下发、没有 session_id,不能走
|
|
1773
|
+
sessionRow,所以带 pendingWait 标记混进空间组,由 workspaceGroup 分流渲染。 */
|
|
1774
|
+
var pendingTasks = PENDING.filter(function (p) { return !p.explicit; });
|
|
1775
|
+
var tasks = rest.filter(function (s) { return !s.workspace; });
|
|
1776
|
+
if (tasks.length || pendingTasks.length) {
|
|
1777
|
+
frag.appendChild(secHead('任务', tasks.length + pendingTasks.length, true, 'tasks'));
|
|
1778
|
+
if (!SEC_COLLAPSED.tasks) {
|
|
1779
|
+
pendingTasks.forEach(function (p) { frag.appendChild(pendingRow(p)); });
|
|
1780
|
+
var limit = TASKS_EXPANDED ? tasks.length : Math.min(8, tasks.length);
|
|
1781
|
+
tasks.slice(0, limit).forEach(function (s) { frag.appendChild(sessionRow(s)); });
|
|
1782
|
+
if (tasks.length > limit) {
|
|
1783
|
+
var more = document.createElement('button'); more.type = 'button'; more.className = 'more';
|
|
1784
|
+
more.textContent = '展开更多 (' + (tasks.length - limit) + ')';
|
|
1785
|
+
more.onclick = function () { TASKS_EXPANDED = true; renderSessions(LAST_SESSIONS); };
|
|
1786
|
+
frag.appendChild(more);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/* 空间:按 workspace 分组(且未置顶的会话) */
|
|
1792
|
+
var bySpace = {};
|
|
1793
|
+
rest.forEach(function (s) {
|
|
1794
|
+
var w = s.workspace; if (!w) return;
|
|
1795
|
+
(bySpace[w] = bySpace[w] || []).push(s);
|
|
1796
|
+
});
|
|
1797
|
+
/* 等待中的新任务:显式空间 → 放进该空间组**最上面**(状态最需要被看见)。
|
|
1798
|
+
不能只靠上面的 rest 建 key:某空间只有排队任务、还没有任何会话时,bySpace 里
|
|
1799
|
+
本来没有它,下面的 sKeys 就不会出现这个空间 —— 这正是「没显示在所属 workspace」的另一半。 */
|
|
1800
|
+
PENDING.forEach(function (p) {
|
|
1801
|
+
if (!p.explicit || !p.ws) return;
|
|
1802
|
+
(bySpace[p.ws] = bySpace[p.ws] || []).unshift(p);
|
|
1803
|
+
});
|
|
1804
|
+
/* 空间排序键:组内最新一条会话的 updated_at;组里只有排队任务时用它的 at。
|
|
1805
|
+
混入排队行后不能再用 bySpace[key][0].updated_at(第一条可能是排队行,没有该字段)。 */
|
|
1806
|
+
function spaceTime(key) {
|
|
1807
|
+
var t = 0;
|
|
1808
|
+
(bySpace[key] || []).forEach(function (x) {
|
|
1809
|
+
var ms = x.pendingWait ? x.at
|
|
1810
|
+
: new Date(String(x.updated_at || x.started_at || '').replace(' ', 'T')).getTime();
|
|
1811
|
+
if (ms > t) t = ms;
|
|
1812
|
+
});
|
|
1813
|
+
return t;
|
|
1814
|
+
}
|
|
1815
|
+
var sKeys = Object.keys(bySpace);
|
|
1816
|
+
sKeys.sort(function (a, b) { return spaceTime(b) - spaceTime(a); });
|
|
1817
|
+
if (sKeys.length) {
|
|
1818
|
+
frag.appendChild(secHead('空间', sKeys.length, true, 'space'));
|
|
1819
|
+
if (!SEC_COLLAPSED.space) sKeys.forEach(function (w) { frag.appendChild(workspaceGroup(w, bySpace[w] || [])); });
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
historyList.replaceChildren(frag);
|
|
1823
|
+
/* 有运行中的行 → 开实时进度定时器;没有就停掉 */
|
|
1824
|
+
ensureTicker();
|
|
1825
|
+
if (!Object.keys(RUN_ROWS).length && RUN_TICK) { clearInterval(RUN_TICK); RUN_TICK = null; }
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/* ══════════ 任务运行设置(设备 / 工作空间 / 权限模式)══════════
|
|
1829
|
+
复用共享组件那套底部抽屉样式(.pick*),只装宿主自己的三项 —— 与截图 1 同构。 */
|
|
1830
|
+
var hostSheet = null, hostScrim = null;
|
|
1831
|
+
function onHostKey(e) { if (e.key === 'Escape') { e.preventDefault(); closeHostSheet(); } }
|
|
1832
|
+
function closeHostSheet() {
|
|
1833
|
+
if (hostSheet) { hostSheet.remove(); hostSheet = null; }
|
|
1834
|
+
if (hostScrim) { hostScrim.remove(); hostScrim = null; }
|
|
1835
|
+
document.removeEventListener('keydown', onHostKey, true);
|
|
1836
|
+
}
|
|
1837
|
+
function hEl(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
|
|
1838
|
+
function basename(p) { var s = String(p || '').split('/').filter(Boolean); return s.pop() || String(p); }
|
|
1839
|
+
|
|
1840
|
+
/* ══════════ 产物阅读(产物卡「查看」)══════════
|
|
1841
|
+
PC 宿主自己绑了点击 → 右侧标签栏;移动端此前没人接这一下,所以「查看」点了没反应。
|
|
1842
|
+
这里用 document 级**捕获**委派接住文件档(图 / 视频 / 音频各有自己的形态,不碰),
|
|
1843
|
+
文本走 window.desk.pathRead(服务端 /desk/path-read,与桌面端 desk:path-read 同口径:
|
|
1844
|
+
512KB 上限、前 8000 字节含 NUL 判二进制、相对路径按 base 解析)。 */
|
|
1845
|
+
var ART_TEXT_EXT = /\.(?:md|markdown|txt|text|log|json|csv|tsv|ya?ml|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|sh|zsh|ini|conf|toml|env|srt|vtt)$/i;
|
|
1846
|
+
function fmtSize(n) {
|
|
1847
|
+
var b = Number(n) || 0;
|
|
1848
|
+
if (b < 1024) return b + ' B';
|
|
1849
|
+
if (b < 1024 * 1024) return (b / 1024).toFixed(b < 10240 ? 1 : 0) + ' KB';
|
|
1850
|
+
return (b / 1024 / 1024).toFixed(1) + ' MB';
|
|
1851
|
+
}
|
|
1852
|
+
function closeReader() { if (readerEl) readerEl.hidden = true; }
|
|
1853
|
+
function openReader(p) {
|
|
1854
|
+
if (!readerEl) return;
|
|
1855
|
+
var abs = String(p || '');
|
|
1856
|
+
rdTitle.textContent = basename(abs);
|
|
1857
|
+
rdSub.textContent = abs;
|
|
1858
|
+
rdBody.innerHTML = '<div class="reader__hint">读取中…</div>';
|
|
1859
|
+
readerEl.hidden = false;
|
|
1860
|
+
var rd = (window.desk && typeof window.desk.pathRead === 'function') ? window.desk.pathRead : null;
|
|
1861
|
+
if (!rd) {
|
|
1862
|
+
rdBody.innerHTML = '';
|
|
1863
|
+
rdBody.appendChild(hEl('div', 'reader__hint', '当前连接不支持读取本机文件。'));
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
Promise.resolve(rd({ path: abs, base: WS || '' })).then(function (r) {
|
|
1867
|
+
rdBody.innerHTML = '';
|
|
1868
|
+
if (!r || r.ok !== true) {
|
|
1869
|
+
rdBody.appendChild(hEl('div', 'reader__hint', (r && r.error) ? String(r.error) : '读取失败'));
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (r.tooBig) {
|
|
1873
|
+
rdBody.appendChild(hEl('div', 'reader__hint', '文件太大,暂不支持在手机上打开(' + fmtSize(r.size) + ',上限 512 KB)。'));
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
if (r.binary) {
|
|
1877
|
+
rdBody.appendChild(hEl('div', 'reader__hint', '这是二进制文件,无法作为文本阅读。'));
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
var text = String(r.text == null ? '' : r.text);
|
|
1881
|
+
if (/\.(?:md|markdown)$/i.test(abs) && typeof mdToHtmlOnly === 'function') {
|
|
1882
|
+
var box = hEl('div', 'md-body');
|
|
1883
|
+
try { box.innerHTML = mdToHtmlOnly(text); } catch (e) { box.textContent = text; }
|
|
1884
|
+
rdBody.appendChild(box);
|
|
1885
|
+
if (typeof highlightIn === 'function') { try { highlightIn(box); } catch (e) {} }
|
|
1886
|
+
/* 文档里的本机图片:换成回读地址(移动端 fileUrl 自带 w=720,慢网也出得来)。 */
|
|
1887
|
+
if (window.desk && typeof window.desk.mediaUrl === 'function') {
|
|
1888
|
+
Array.prototype.forEach.call(box.querySelectorAll('img[src]'), function (im) {
|
|
1889
|
+
var src = im.getAttribute('src') || '';
|
|
1890
|
+
if (!/^(?:file:\/\/|\/|~\/)/.test(src)) return;
|
|
1891
|
+
var u = window.desk.mediaUrl(src.replace(/^file:\/\//, ''));
|
|
1892
|
+
if (u) im.src = u;
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
} else {
|
|
1896
|
+
var pre = document.createElement('pre');
|
|
1897
|
+
pre.textContent = text;
|
|
1898
|
+
rdBody.appendChild(pre);
|
|
1899
|
+
}
|
|
1900
|
+
rdBody.scrollTop = 0;
|
|
1901
|
+
}).catch(function (e) {
|
|
1902
|
+
rdBody.innerHTML = '';
|
|
1903
|
+
rdBody.appendChild(hEl('div', 'reader__hint', '读取失败:' + (e && e.message ? e.message : e)));
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
document.addEventListener('click', function (e) {
|
|
1907
|
+
var t = e.target;
|
|
1908
|
+
var card = (t && t.closest) ? t.closest('.qa-art') : null;
|
|
1909
|
+
if (!card) return;
|
|
1910
|
+
if (card.querySelector('video,audio')) return; // 音视频:让原生控件自己处理
|
|
1911
|
+
if (!/qa-art-file/.test(card.className)) return; // 只接管文件档;图片有自己的缩略图链路
|
|
1912
|
+
var p = card.dataset ? card.dataset.path : card.getAttribute('data-path');
|
|
1913
|
+
if (!p) return;
|
|
1914
|
+
e.preventDefault(); e.stopPropagation();
|
|
1915
|
+
if (!ART_TEXT_EXT.test(String(p).split(/[?#]/)[0])) {
|
|
1916
|
+
toast('这个类型暂不支持在手机上打开:' + basename(p));
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
openReader(p);
|
|
1920
|
+
}, true);
|
|
1921
|
+
on('rdClose', closeReader);
|
|
1922
|
+
document.addEventListener('keydown', function (e) {
|
|
1923
|
+
if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
|
|
1924
|
+
}, true);
|
|
1925
|
+
function openHostSheet(title, build) {
|
|
1926
|
+
closeHostSheet();
|
|
1927
|
+
hostScrim = hEl('div', 'pick-scrim'); hostScrim.onclick = closeHostSheet;
|
|
1928
|
+
hostSheet = hEl('div', 'pick pick--on');
|
|
1929
|
+
hostSheet.appendChild(hEl('div', 'pick__grab'));
|
|
1930
|
+
var head = hEl('div', 'pick__head');
|
|
1931
|
+
head.appendChild(hEl('span', 'pick__title', title));
|
|
1932
|
+
var x = hEl('button', 'pick__x', '✕'); x.type = 'button'; x.setAttribute('aria-label', '关闭'); x.onclick = closeHostSheet;
|
|
1933
|
+
head.appendChild(x); hostSheet.appendChild(head);
|
|
1934
|
+
var body = hEl('div', 'pick__body'); hostSheet.appendChild(body);
|
|
1935
|
+
document.body.appendChild(hostScrim);
|
|
1936
|
+
document.body.appendChild(hostSheet);
|
|
1937
|
+
document.addEventListener('keydown', onHostKey, true);
|
|
1938
|
+
build(body);
|
|
1939
|
+
}
|
|
1940
|
+
function hRow(body, label, value, onClick, danger) {
|
|
1941
|
+
var b = hEl('button', 'pick__row'); b.type = 'button';
|
|
1942
|
+
var l = hEl('span', 'pick__l', label); l.style.flex = '1'; l.style.minWidth = '0';
|
|
1943
|
+
b.appendChild(l);
|
|
1944
|
+
if (value != null) {
|
|
1945
|
+
var v = hEl('span', 'pick__d', value); v.style.margin = '0';
|
|
1946
|
+
if (danger) v.style.color = 'var(--error-text)';
|
|
1947
|
+
b.appendChild(v);
|
|
1948
|
+
}
|
|
1949
|
+
if (onClick) { b.appendChild(hEl('span', 'pick__chev', '›')); b.onclick = onClick; }
|
|
1950
|
+
else { b.style.cursor = 'default'; }
|
|
1951
|
+
body.appendChild(b);
|
|
1952
|
+
}
|
|
1953
|
+
function applyWs() { if (conv && conv.setWorkspace) conv.setWorkspace(WS); }
|
|
1954
|
+
function openRunSettings() {
|
|
1955
|
+
openHostSheet('任务运行设置', function (body) {
|
|
1956
|
+
hRow(body, '设备', histSubtitle || '本机', null);
|
|
1957
|
+
hRow(body, '工作空间', basename(WS || DW) || '默认', function () { openWorkspacePicker(); });
|
|
1958
|
+
var label = (conv && conv.permissionLabel) ? conv.permissionLabel() : '沿用默认';
|
|
1959
|
+
var danger = !!(conv && conv.permissionTier && conv.permissionTier() === 'full');
|
|
1960
|
+
hRow(body, '权限模式', label, function () {
|
|
1961
|
+
closeHostSheet();
|
|
1962
|
+
if (conv && conv.openPermissionPicker) conv.openPermissionPicker();
|
|
1963
|
+
}, danger);
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
/* ── 任务模式门(串行)──
|
|
1967
|
+
serial:同一空间同时只跑**一个新任务**;新任务若发现该空间已有任务在跑,就每 3 秒轮询
|
|
1968
|
+
`/desk/sessions` 等它跑完再触发,并在列表里以「等待中」状态(紫色)显示。
|
|
1969
|
+
历史会话的追问 isNew=false,永远不等待。 */
|
|
1970
|
+
var PENDING = []; // 等待中的新任务(本地,未真正下发)
|
|
1971
|
+
function gateAsk(info) {
|
|
1972
|
+
if (TASK_MODE !== 'serial' || !info || !info.isNew) return Promise.resolve();
|
|
1973
|
+
var ws = info.workspace || DW;
|
|
1974
|
+
var pendId = null;
|
|
1975
|
+
function mark() {
|
|
1976
|
+
if (pendId != null) return;
|
|
1977
|
+
pendId = 'p' + Date.now() + '-' + Math.floor(Math.random() * 1000);
|
|
1978
|
+
/* explicit:**显式选了空间**才归进「空间」段;默认(不指定)落「任务」段 ——
|
|
1979
|
+
与 renderSessions 里会话按 `workspace` 是否为空分组的判定保持一致。
|
|
1980
|
+
只存 ws=DW 不够:DW 本身也可能被显式选成空间。 */
|
|
1981
|
+
PENDING.push({ id: pendId, ws: ws, explicit: !!info.workspace, pendingWait: true,
|
|
1982
|
+
prompt: info.prompt || '', engine: info.engine || '', at: Date.now() });
|
|
1983
|
+
renderSessions(LAST_SESSIONS);
|
|
1984
|
+
}
|
|
1985
|
+
function clear() {
|
|
1986
|
+
if (pendId == null) return;
|
|
1987
|
+
PENDING = PENDING.filter(function (p) { return p.id !== pendId; });
|
|
1988
|
+
pendId = null; renderSessions(LAST_SESSIONS);
|
|
1989
|
+
}
|
|
1990
|
+
return new Promise(function (resolve) {
|
|
1991
|
+
function check() {
|
|
1992
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
1993
|
+
fetch(BASE + '/desk/sessions', { headers: hdr }).then(function (r) { return r.ok ? r.json() : []; })
|
|
1994
|
+
.then(function (list) {
|
|
1995
|
+
var busy = (Array.isArray(list) ? list : []).some(function (s) {
|
|
1996
|
+
return ((s.workspace || DW) === ws) && (s.state === 'running' || s.alive === true);
|
|
1997
|
+
});
|
|
1998
|
+
if (!busy) { clear(); resolve(); } else { mark(); setTimeout(check, 3000); }
|
|
1999
|
+
})
|
|
2000
|
+
.catch(function () { clear(); resolve(); });
|
|
2001
|
+
}
|
|
2002
|
+
check();
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/* 等待中任务的列表行(不可点开,纯状态展示)。 */
|
|
2007
|
+
function pendingRow(p) {
|
|
2008
|
+
var b = document.createElement('div'); b.className = 'srow srow--pending';
|
|
2009
|
+
var ico = elSpan('srow__ico srow__ico--queued'); ico.appendChild(svgUse('#i-laptop', 20));
|
|
2010
|
+
b.appendChild(ico);
|
|
2011
|
+
var col = elSpan('srow__col');
|
|
2012
|
+
col.appendChild(elSpan('srow__t', String(p.prompt || '新任务').slice(0, 40)));
|
|
2013
|
+
col.appendChild(elSpan('srow__s', '等待中 · 上一个完成后自动开始'));
|
|
2014
|
+
b.appendChild(col);
|
|
2015
|
+
b.appendChild(elSpan('srow__time', ''));
|
|
2016
|
+
return b;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/* ── 通知:配置 + 真触发 ──
|
|
2020
|
+
优先级:Capacitor LocalNotifications(App 内系统通知)→ Web Notification(浏览器已授权)→ 应用内 toast。
|
|
2021
|
+
⚠️ App 关闭时 JS 不跑、触发不了;「任务完成 / 待确认」通知需要 App 在运行。 */
|
|
2022
|
+
var NOTIFY = { result: true, confirm: true, sched: true };
|
|
2023
|
+
try {
|
|
2024
|
+
var _nv = JSON.parse(localStorage.getItem('magic.notify') || '{}');
|
|
2025
|
+
if (_nv && typeof _nv === 'object') {
|
|
2026
|
+
NOTIFY.result = _nv.result !== false; NOTIFY.confirm = _nv.confirm !== false; NOTIFY.sched = _nv.sched !== false;
|
|
2027
|
+
}
|
|
2028
|
+
} catch (e) {}
|
|
2029
|
+
function saveNotify() { try { localStorage.setItem('magic.notify', JSON.stringify(NOTIFY)); } catch (e) {} }
|
|
2030
|
+
function notifyPlugin() {
|
|
2031
|
+
try { var C = window.Capacitor; return (C && C.Plugins && C.Plugins.LocalNotifications) || null; } catch (e) { return null; }
|
|
2032
|
+
}
|
|
2033
|
+
function notifyPermState() {
|
|
2034
|
+
if (notifyPlugin()) return 'plugin';
|
|
2035
|
+
try { if (window.Notification) return Notification.permission; } catch (e) {}
|
|
2036
|
+
return 'unknown';
|
|
2037
|
+
}
|
|
2038
|
+
function requestNotifyPermission() {
|
|
2039
|
+
var P = notifyPlugin();
|
|
2040
|
+
if (P && P.requestPermissions) { try { return Promise.resolve(P.requestPermissions()); } catch (e) {} }
|
|
2041
|
+
try { if (window.Notification && Notification.permission === 'default') return Notification.requestPermission(); } catch (e) {}
|
|
2042
|
+
return Promise.resolve();
|
|
2043
|
+
}
|
|
2044
|
+
var toastEl = null, toastT = null;
|
|
2045
|
+
function toast(msg) {
|
|
2046
|
+
if (toastEl) toastEl.remove();
|
|
2047
|
+
toastEl = document.createElement('div'); toastEl.className = 'toast'; toastEl.textContent = msg;
|
|
2048
|
+
document.body.appendChild(toastEl);
|
|
2049
|
+
if (toastT) clearTimeout(toastT);
|
|
2050
|
+
toastT = setTimeout(function () { if (toastEl) { toastEl.remove(); toastEl = null; } }, 3200);
|
|
2051
|
+
}
|
|
2052
|
+
function notify(kind, title, body) {
|
|
2053
|
+
if (!NOTIFY[kind]) return;
|
|
2054
|
+
var P = notifyPlugin();
|
|
2055
|
+
if (P && P.schedule) {
|
|
2056
|
+
try { P.schedule({ notifications: [{ id: Math.floor(Math.random() * 1e9), title: title, body: body }] }); return; } catch (e) {}
|
|
2057
|
+
}
|
|
2058
|
+
try {
|
|
2059
|
+
if (window.Notification) {
|
|
2060
|
+
if (Notification.permission === 'granted') { new Notification(title, { body: body }); return; }
|
|
2061
|
+
if (Notification.permission !== 'denied') {
|
|
2062
|
+
Notification.requestPermission().then(function (p) { if (p === 'granted') { try { new Notification(title, { body: body }); } catch (e) {} } });
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
} catch (e) {}
|
|
2067
|
+
toast(title + (body ? ':' + body : ''));
|
|
2068
|
+
}
|
|
2069
|
+
function nToggle(body, label, on, onSet) {
|
|
2070
|
+
var row = document.createElement('div'); row.className = 'setrow';
|
|
2071
|
+
row.appendChild(elSpan('setrow__l', label));
|
|
2072
|
+
var t = document.createElement('button'); t.type = 'button'; t.className = 'tgl' + (on ? ' is-on' : '');
|
|
2073
|
+
t.onclick = function () { on = !on; t.classList.toggle('is-on', on); onSet(on); };
|
|
2074
|
+
row.appendChild(t); body.appendChild(row);
|
|
2075
|
+
}
|
|
2076
|
+
function openNotifySettings() {
|
|
2077
|
+
openHostSheet('消息通知设置', function (body) {
|
|
2078
|
+
var st = notifyPermState();
|
|
2079
|
+
var label = st === 'plugin' ? '已接入' : (st === 'granted' ? '已开启' : (st === 'denied' ? '已拒绝' : '去授权'));
|
|
2080
|
+
hRow(body, '系统消息通知', label, function () { requestNotifyPermission().then(function () { openNotifySettings(); }); });
|
|
2081
|
+
nToggle(body, '任务结果通知', NOTIFY.result, function (v) { NOTIFY.result = v; saveNotify(); });
|
|
2082
|
+
nToggle(body, '任务待确认通知', NOTIFY.confirm, function (v) { NOTIFY.confirm = v; saveNotify(); });
|
|
2083
|
+
nToggle(body, '定时任务通知', NOTIFY.sched, function (v) { NOTIFY.sched = v; saveNotify(); });
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/* ── 轮询会话状态跳变 → 发通知(不重绘列表,避免打扰浏览)── */
|
|
2088
|
+
var PREV_STATE = {}, POLL = null, FIRST_POLL = true;
|
|
2089
|
+
function startPoll() { PREV_STATE = {}; FIRST_POLL = true; if (!POLL) POLL = setInterval(pollOnce, 5000); }
|
|
2090
|
+
function stopPoll() { if (POLL) { clearInterval(POLL); POLL = null; } }
|
|
2091
|
+
function pollOnce() {
|
|
2092
|
+
if (!BASE && !TOKEN) return;
|
|
2093
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
2094
|
+
fetch(BASE + '/desk/sessions', { headers: hdr }).then(function (r) { return r.ok ? r.json() : []; })
|
|
2095
|
+
.then(function (list) {
|
|
2096
|
+
(Array.isArray(list) ? list : []).forEach(function (s) {
|
|
2097
|
+
var id = sidOf(s), st = String(s.state || ''), prev = PREV_STATE[id];
|
|
2098
|
+
if (!FIRST_POLL && prev !== undefined && prev !== st) {
|
|
2099
|
+
if (st === 'done') notify('result', '任务完成', titleOf(s));
|
|
2100
|
+
else if (st === 'waiting_approval') notify('confirm', '任务待确认', titleOf(s));
|
|
2101
|
+
}
|
|
2102
|
+
PREV_STATE[id] = st;
|
|
2103
|
+
});
|
|
2104
|
+
FIRST_POLL = false;
|
|
2105
|
+
}).catch(function () {});
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/* 工作空间选择:默认 / 根目录下 magic-* / 自定义 / 会话里出现过的,一行一个,外加「自定义路径…」。 */
|
|
2109
|
+
function openWorkspacePicker() {
|
|
2110
|
+
var keys = collectSpaceKeys();
|
|
2111
|
+
openHostSheet('选择工作空间', function (body) {
|
|
2112
|
+
hRow(body, '默认(不指定)', null, function () { WS = ''; saveWs(); applyWs(); closeHostSheet(); openRunSettings(); });
|
|
2113
|
+
keys.forEach(function (w) {
|
|
2114
|
+
var isCustom = CUSTOM_SPACES.indexOf(w) >= 0;
|
|
2115
|
+
hRow(body, basename(w), isCustom ? '自定义' : null, function () { WS = w; saveWs(); applyWs(); closeHostSheet(); openRunSettings(); });
|
|
2116
|
+
});
|
|
2117
|
+
hRow(body, '自定义路径…', '+', function () {
|
|
2118
|
+
var p = window.prompt('输入工作空间的绝对路径', SPACES_ROOT || DW || '');
|
|
2119
|
+
if (p == null) return;
|
|
2120
|
+
p = p.trim(); if (!p) return;
|
|
2121
|
+
if (CUSTOM_SPACES.indexOf(p) < 0) { CUSTOM_SPACES.push(p); saveSpaces(); }
|
|
2122
|
+
WS = p; saveWs(); applyWs(); closeHostSheet(); loadSessions(); openRunSettings();
|
|
2123
|
+
});
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/* ══════════ 默认引擎 / 模型(「我的」)══════════
|
|
2128
|
+
存 magic.engine / magic.model,作为**新建任务**的初值传给对话组件;
|
|
2129
|
+
会话里输入框上方的级联切换也会回写这里(见 mountConversation 的 onPick)——
|
|
2130
|
+
与 PC 端「选过一次就一直用」的语义一致,「我的」里显示的就是下次新建会用的那一组。
|
|
2131
|
+
⚠️ 模型必须挂在引擎下:engine 为空时 model 一律清空,不留孤儿模型。 */
|
|
2132
|
+
function loadRunPref() {
|
|
2133
|
+
var o = { engine: '', model: '' };
|
|
2134
|
+
try {
|
|
2135
|
+
o.engine = localStorage.getItem('magic.engine') || '';
|
|
2136
|
+
o.model = localStorage.getItem('magic.model') || '';
|
|
2137
|
+
} catch (e) {}
|
|
2138
|
+
if (!o.engine) o.model = '';
|
|
2139
|
+
return o;
|
|
2140
|
+
}
|
|
2141
|
+
function saveRunPref() {
|
|
2142
|
+
try {
|
|
2143
|
+
localStorage.setItem('magic.engine', RUN_PREF.engine || '');
|
|
2144
|
+
localStorage.setItem('magic.model', RUN_PREF.model || '');
|
|
2145
|
+
} catch (e) {}
|
|
2146
|
+
paintRunPref();
|
|
2147
|
+
}
|
|
2148
|
+
function paintRunPref() {
|
|
2149
|
+
var e = document.getElementById('defaultEngine');
|
|
2150
|
+
var m = document.getElementById('defaultModel');
|
|
2151
|
+
if (e) e.textContent = RUN_PREF.engine || '自动(首个可用)';
|
|
2152
|
+
if (m) m.textContent = RUN_PREF.engine ? (RUN_PREF.model || '引擎默认') : '跟随引擎';
|
|
2153
|
+
}
|
|
2154
|
+
/* 引擎清单:走 client 的 /desk/engines(= core --engines 的原样 JSON,含 models)。
|
|
2155
|
+
⚠️ 这个请求会真的起各引擎的只读子命令探测模型,慢(秒级),所以只在打开选择器时拉,
|
|
2156
|
+
「我的」页本身不请求 —— 显示的是已记住的值,打开选择器才现探测。 */
|
|
2157
|
+
function fetchEngines() {
|
|
2158
|
+
var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
|
|
2159
|
+
return fetch(BASE + '/desk/engines?models=1', { headers: hdr }).then(function (r) {
|
|
2160
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
2161
|
+
return r.json();
|
|
2162
|
+
}).then(function (list) {
|
|
2163
|
+
ENGINES = (Array.isArray(list) ? list : []).filter(function (x) { return x && x.engine; });
|
|
2164
|
+
return ENGINES;
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
function openDefaultEnginePicker() {
|
|
2168
|
+
openHostSheet('默认引擎 / 模型', function (body) {
|
|
2169
|
+
var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
|
|
2170
|
+
hint.style.textAlign = 'left';
|
|
2171
|
+
body.appendChild(hint);
|
|
2172
|
+
fetchEngines().then(function (list) {
|
|
2173
|
+
if (hint.parentNode) hint.remove();
|
|
2174
|
+
var avail = list.filter(function (x) { return x.ok; });
|
|
2175
|
+
if (!avail.length) {
|
|
2176
|
+
var d = hEl('div', 'history__hint',
|
|
2177
|
+
'没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出每个引擎的一键安装命令)。');
|
|
2178
|
+
d.style.textAlign = 'left';
|
|
2179
|
+
body.appendChild(d);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
buildEngineCascade(body, avail);
|
|
2183
|
+
}, function (e) {
|
|
2184
|
+
hint.textContent = '拉取引擎清单失败:' + ((e && e.message) || e);
|
|
2185
|
+
});
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
/* 二列级联(左引擎 · 右该引擎的模型),与对话框里那套同构:
|
|
2189
|
+
点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。 */
|
|
2190
|
+
function buildEngineCascade(body, avail) {
|
|
2191
|
+
var wrap = hEl('div', 'pick__cols');
|
|
2192
|
+
var left = hEl('div', 'pick__col');
|
|
2193
|
+
var right = hEl('div', 'pick__col pick__col--r');
|
|
2194
|
+
wrap.appendChild(left); wrap.appendChild(right);
|
|
2195
|
+
body.appendChild(wrap);
|
|
2196
|
+
|
|
2197
|
+
var cur = avail[0];
|
|
2198
|
+
avail.forEach(function (en) { if (en.engine === RUN_PREF.engine) cur = en; });
|
|
2199
|
+
|
|
2200
|
+
function paint() {
|
|
2201
|
+
left.innerHTML = ''; right.innerHTML = '';
|
|
2202
|
+
var auto = hEl('button', 'pick__citem' + (!RUN_PREF.engine ? ' is-on' : ''));
|
|
2203
|
+
auto.type = 'button';
|
|
2204
|
+
auto.appendChild(hEl('span', 'pick__cl', '自动'));
|
|
2205
|
+
auto.appendChild(hEl('span', 'pick__cd', '首个可用引擎'));
|
|
2206
|
+
auto.onclick = function () { pickRunPref('', ''); };
|
|
2207
|
+
left.appendChild(auto);
|
|
2208
|
+
|
|
2209
|
+
avail.forEach(function (en) {
|
|
2210
|
+
var n = (en.models || []).length;
|
|
2211
|
+
/* is-on = 已保存的默认(浅蓝实底);is-cur = 右列此刻在浏览谁(淡底)——
|
|
2212
|
+
两者分开,默认是「自动」时不会出现两个一样的选中块。 */
|
|
2213
|
+
var b = hEl('button', 'pick__citem'
|
|
2214
|
+
+ (RUN_PREF.engine === en.engine ? ' is-on' : '')
|
|
2215
|
+
+ (en.engine === cur.engine && RUN_PREF.engine !== en.engine ? ' is-cur' : ''));
|
|
2216
|
+
b.type = 'button';
|
|
2217
|
+
b.appendChild(hEl('span', 'pick__cl', en.engine));
|
|
2218
|
+
b.appendChild(hEl('span', 'pick__cd', n ? (n + ' 个模型') : '引擎默认'));
|
|
2219
|
+
b.onclick = function () { cur = en; paint(); };
|
|
2220
|
+
left.appendChild(b);
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
var d = hEl('button', 'pick__citem' + (RUN_PREF.engine === cur.engine && !RUN_PREF.model ? ' is-on' : ''));
|
|
2224
|
+
d.type = 'button';
|
|
2225
|
+
d.appendChild(hEl('span', 'pick__cl', '引擎默认'));
|
|
2226
|
+
d.appendChild(hEl('span', 'pick__cd', '不指定模型,由引擎自己挑'));
|
|
2227
|
+
d.onclick = function () { pickRunPref(cur.engine, ''); };
|
|
2228
|
+
right.appendChild(d);
|
|
2229
|
+
|
|
2230
|
+
(cur.models || []).forEach(function (mm) {
|
|
2231
|
+
var b = hEl('button', 'pick__citem' + (RUN_PREF.engine === cur.engine && RUN_PREF.model === mm ? ' is-on' : ''));
|
|
2232
|
+
b.type = 'button';
|
|
2233
|
+
b.appendChild(hEl('span', 'pick__cl', mm));
|
|
2234
|
+
b.onclick = function () { pickRunPref(cur.engine, mm); };
|
|
2235
|
+
right.appendChild(b);
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
paint();
|
|
2239
|
+
}
|
|
2240
|
+
function pickRunPref(engine, model) {
|
|
2241
|
+
RUN_PREF.engine = engine || '';
|
|
2242
|
+
RUN_PREF.model = (engine && model) ? model : '';
|
|
2243
|
+
saveRunPref();
|
|
2244
|
+
applyRunPrefToLive();
|
|
2245
|
+
closeHostSheet();
|
|
2246
|
+
toast(RUN_PREF.engine
|
|
2247
|
+
? ('默认引擎:' + RUN_PREF.engine + ' · ' + (RUN_PREF.model || '引擎默认模型'))
|
|
2248
|
+
: '默认引擎:自动(首个可用)');
|
|
2249
|
+
}
|
|
2250
|
+
/* 改默认值只同步**还没发过任务的新会话**;历史会话保持它自己的引擎,
|
|
2251
|
+
否则「看一眼旧会话」会把它后续追问的引擎也换掉。 */
|
|
2252
|
+
function applyRunPrefToLive() {
|
|
2253
|
+
if (!conv || !conv.state || conv.state.session) return;
|
|
2254
|
+
var eng = RUN_PREF.engine;
|
|
2255
|
+
if (!eng) {
|
|
2256
|
+
var first = ENGINES.filter(function (x) { return x.ok; })[0];
|
|
2257
|
+
eng = first ? first.engine : '';
|
|
2258
|
+
}
|
|
2259
|
+
if (conv.setEngine) conv.setEngine(eng);
|
|
2260
|
+
if (conv.setModel) conv.setModel(eng ? (RUN_PREF.engine ? RUN_PREF.model : '') : '');
|
|
2261
|
+
}
|
|
2262
|
+
/* 会话里点了具体模型/「引擎默认」→ 记住成默认(组件 onPick 回调)。 */
|
|
2263
|
+
function rememberRunPick(engine, model) {
|
|
2264
|
+
RUN_PREF.engine = engine || '';
|
|
2265
|
+
RUN_PREF.model = (engine && model) ? model : '';
|
|
2266
|
+
saveRunPref();
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/* ══════════ 升级(重新加载)══════════
|
|
2270
|
+
App 壳与浏览器都会缓存 index.html / 组件 JS,换了新版本后重启也可能还是旧的。
|
|
2271
|
+
这里清掉 WebView 缓存与 CacheStorage,再带一个时间戳参数重进 —— 参数本身不参与路由,
|
|
2272
|
+
只用来让 WebView 认为 URL 变了,跳过旧的 HTTP 缓存。localStorage 一律不清:
|
|
2273
|
+
连接、主题、默认引擎、工作空间都存那儿,升级不该丢配置。 */
|
|
2274
|
+
function reloadApp() {
|
|
2275
|
+
var st = document.getElementById('upgradeState');
|
|
2276
|
+
if (st) st.textContent = '正在更新…';
|
|
2277
|
+
var done = function () {
|
|
2278
|
+
var u = new URL(location.href);
|
|
2279
|
+
u.searchParams.set('_t', String(Date.now()));
|
|
2280
|
+
location.replace(u.toString());
|
|
2281
|
+
};
|
|
2282
|
+
var jobs = [];
|
|
2283
|
+
try {
|
|
2284
|
+
if (window.caches && caches.keys) {
|
|
2285
|
+
jobs.push(caches.keys().then(function (ks) { return Promise.all(ks.map(function (k) { return caches.delete(k); })); }));
|
|
2286
|
+
}
|
|
2287
|
+
} catch (e) {}
|
|
2288
|
+
try {
|
|
2289
|
+
var C = window.Capacitor;
|
|
2290
|
+
if (C && C.Plugins && C.Plugins.WebView && C.Plugins.WebView.clearCache) {
|
|
2291
|
+
jobs.push(Promise.resolve(C.Plugins.WebView.clearCache()));
|
|
2292
|
+
}
|
|
2293
|
+
} catch (e) {}
|
|
2294
|
+
/* 清缓存失败也要继续重载:宁可没清干净,也不能点了没反应。 */
|
|
2295
|
+
Promise.all(jobs.map(function (p) { return p.catch(function () {}); })).then(done, done);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/* ══════════ 接线 ══════════ */
|
|
2299
|
+
document.getElementById('btnConnect').onclick = function () {
|
|
2300
|
+
var target = parseTarget(addrEl.value);
|
|
2301
|
+
if (!target) { showError('请输入 magic-agent 桌面地址(形如 http://192.168.1.5:8791/?token=…)'); return; }
|
|
2302
|
+
connect(target);
|
|
2303
|
+
};
|
|
2304
|
+
addrEl.addEventListener('input', function () { showError(''); });
|
|
2305
|
+
on('btnClear', function () { clearRecent(); renderRecent(); });
|
|
2306
|
+
/* 别处起的任务要能自己冒出来(列表页可见时静默轮询;见 startListPoll 的注释) */
|
|
2307
|
+
startListPoll();
|
|
2308
|
+
on('btnList', function () { showHistory(); });
|
|
2309
|
+
on('btnNewTask', function () { newConversation(); });
|
|
2310
|
+
on('btnSettings', function () { openRunSettings(); });
|
|
2311
|
+
Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
|
|
2312
|
+
b.onclick = function () {
|
|
2313
|
+
var t = b.getAttribute('data-tab');
|
|
2314
|
+
if (t === 'tasks') showHistory();
|
|
2315
|
+
else if (t === 'sched') showSched();
|
|
2316
|
+
else if (t === 'discover') showDiscover();
|
|
2317
|
+
else if (t === 'mine') showMine();
|
|
2318
|
+
};
|
|
2319
|
+
});
|
|
2320
|
+
on('btnMineBack', function () { showHistory(); });
|
|
2321
|
+
on('dscBrowser', function () { openBrowser(HOME_URL); });
|
|
2322
|
+
on('dscApps', function () { openComingSheet('应用', '暂无可用应用。桌面端发布的应用会出现在这里。'); });
|
|
2323
|
+
on('dscPlugins', function () { showPlugins(); });
|
|
2324
|
+
on('btnPluginsBack', function () { showDiscover(); });
|
|
2325
|
+
on('btnPluginDetailBack', function () { showPlugins(); });
|
|
2326
|
+
on('dscSkill', function () { openComingSheet('SKILL', '暂无可用 SKILL。技能包上线后将在此展示。'); });
|
|
2327
|
+
on('brClose', closeBrowser);
|
|
2328
|
+
on('brBack', brBack);
|
|
2329
|
+
on('brSites', openBrSites);
|
|
2330
|
+
on('brGo', openBrGo);
|
|
2331
|
+
on('brHome', brHome);
|
|
2332
|
+
on('brRefresh', brReload);
|
|
2333
|
+
on('brShare', brShare);
|
|
2334
|
+
on('brStar', brToggleStar);
|
|
2335
|
+
on('btnNotify', openNotifySettings);
|
|
2336
|
+
on('btnDefaultEngine', openDefaultEnginePicker);
|
|
2337
|
+
on('btnDefaultModel', openDefaultEnginePicker);
|
|
2338
|
+
on('btnLogout', disconnect);
|
|
2339
|
+
on('btnUpgrade', reloadApp);
|
|
2340
|
+
if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
|
|
2341
|
+
if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
|
|
2342
|
+
if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
|
|
2343
|
+
if (tglHaptic) tglHaptic.onclick = function () {
|
|
2344
|
+
PREFS.haptic = (PREFS.haptic === 'off') ? 'on' : 'off';
|
|
2345
|
+
savePref('magic.haptic', PREFS.haptic); applyHapticPref(PREFS.haptic);
|
|
2346
|
+
tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
/* ══════════ 网络代理(VPN 隧道)══════════
|
|
2350
|
+
把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。
|
|
2351
|
+
只在 App 壳里可用(需要原生 VpnService);网页环境如实说不支持。 */
|
|
2352
|
+
var tglVpn = document.getElementById('tglVpn');
|
|
2353
|
+
var VPN_ON = false; // 网络代理是否真的在跑(浏览器据此决定要不要再走 /b/ 中转)
|
|
2354
|
+
function vpnBase() {
|
|
2355
|
+
var b = BASE;
|
|
2356
|
+
if (!b && /^https?:$/i.test(location.protocol)) b = location.origin;
|
|
2357
|
+
return String(b || '').replace(/\/+$/, '');
|
|
2358
|
+
}
|
|
2359
|
+
function vpnPlugin() {
|
|
2360
|
+
var c = window.Capacitor;
|
|
2361
|
+
return (c && c.Plugins && c.Plugins.MagicVpn) ? c.Plugins.MagicVpn : null;
|
|
2362
|
+
}
|
|
2363
|
+
function refreshVpn() {
|
|
2364
|
+
var p = vpnPlugin();
|
|
2365
|
+
if (!p || !tglVpn) return;
|
|
2366
|
+
p.status().then(function (r) {
|
|
2367
|
+
VPN_ON = !!(r && r.running);
|
|
2368
|
+
tglVpn.classList.toggle('is-on', VPN_ON);
|
|
2369
|
+
}, function () {});
|
|
2370
|
+
}
|
|
2371
|
+
if (tglVpn) tglVpn.onclick = function () {
|
|
2372
|
+
var p = vpnPlugin();
|
|
2373
|
+
if (!p) { toast('仅 App 内可用'); return; }
|
|
2374
|
+
if (VPN_ON) {
|
|
2375
|
+
p.stop().then(function () {
|
|
2376
|
+
VPN_ON = false; tglVpn.classList.remove('is-on');
|
|
2377
|
+
toast('网络代理已关闭');
|
|
2378
|
+
if (!brEl.hidden && brStack.length) { brReload(); }
|
|
2379
|
+
}, function () { toast('关闭失败'); });
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
var b = vpnBase();
|
|
2383
|
+
if (!b) { toast('拿不到桌面端地址'); return; }
|
|
2384
|
+
p.start({ base: b, token: TOKEN || '' }).then(function (r) {
|
|
2385
|
+
if (r && r.error) { toast('启动失败:' + r.error); return; }
|
|
2386
|
+
VPN_ON = true;
|
|
2387
|
+
tglVpn.classList.add('is-on');
|
|
2388
|
+
toast('网络代理已开启');
|
|
2389
|
+
if (!brEl.hidden && brStack.length) { brReload(); } // 立刻按新方式重载当前页
|
|
2390
|
+
}, function (e) { toast('启动失败:' + ((e && e.message) || e)); });
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
/* 扫码:ZCode 在壳里用 ML Kit;网页环境用 BarcodeDetector 兜底,缺了如实说。 */
|
|
2394
|
+
var scanStream = null, scanTimer = 0, scanDetector = null;
|
|
2395
|
+
function stopScan() {
|
|
2396
|
+
if (scanTimer) { clearInterval(scanTimer); scanTimer = 0; }
|
|
2397
|
+
if (scanStream) { scanStream.getTracks().forEach(function (t) { t.stop(); }); scanStream = null; }
|
|
2398
|
+
scanEl.hidden = true;
|
|
2399
|
+
}
|
|
2400
|
+
document.getElementById('btnScanClose').onclick = stopScan;
|
|
2401
|
+
document.getElementById('btnScan').onclick = function () {
|
|
2402
|
+
if (!('BarcodeDetector' in window) || !navigator.mediaDevices) {
|
|
2403
|
+
showError('当前环境不支持扫码,请把桌面配对页的「桌面地址」粘贴到输入框。');
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
showError('');
|
|
2407
|
+
scanEl.hidden = false;
|
|
2408
|
+
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (stream) {
|
|
2409
|
+
scanStream = stream;
|
|
2410
|
+
scanVideo.srcObject = stream;
|
|
2411
|
+
scanVideo.play();
|
|
2412
|
+
var Detector = window.BarcodeDetector;
|
|
2413
|
+
scanDetector = new Detector({ formats: ['qr_code'] });
|
|
2414
|
+
scanTimer = setInterval(function () {
|
|
2415
|
+
if (!scanDetector || scanVideo.readyState < 2) return;
|
|
2416
|
+
scanDetector.detect(scanVideo).then(function (codes) {
|
|
2417
|
+
if (!codes || !codes.length) return;
|
|
2418
|
+
stopScan();
|
|
2419
|
+
var raw = codes[0].rawValue || '';
|
|
2420
|
+
var target = parseTarget(raw);
|
|
2421
|
+
if (target) connect(target);
|
|
2422
|
+
else showError('这个二维码不是 magic-agent 连接地址。');
|
|
2423
|
+
}).catch(function () {});
|
|
2424
|
+
}, 400);
|
|
2425
|
+
}).catch(function () {
|
|
2426
|
+
stopScan();
|
|
2427
|
+
showError('无法启动相机,请改用粘贴地址的方式。');
|
|
2428
|
+
});
|
|
2429
|
+
};
|
|
2430
|
+
|
|
2431
|
+
window.addEventListener('pagehide', stopScan);
|
|
2432
|
+
|
|
2433
|
+
/* ══════════ 首屏 ══════════
|
|
2434
|
+
· 打包进 App(Capacitor):boot.js 注入 window.MAGIC_BASE / MAGIC_TOKEN → 直连会话;
|
|
2435
|
+
· 浏览器同源调试:?token= 在,base 用同源 → 直连会话;
|
|
2436
|
+
· 其余情况:显示启动页,由用户粘贴/扫码连接。 */
|
|
2437
|
+
var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
|
|
2438
|
+
var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
|
|
2439
|
+
try {
|
|
2440
|
+
if (injectedToken || injectedBase) {
|
|
2441
|
+
BASE = injectedBase; TOKEN = injectedToken;
|
|
2442
|
+
showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
|
|
2443
|
+
} else {
|
|
2444
|
+
showLauncher();
|
|
2445
|
+
}
|
|
2446
|
+
} catch (e) {
|
|
2447
|
+
/* 任何初始化异常都退回启动页,绝不留白屏。 */
|
|
2448
|
+
try { showLauncher(); } catch (e2) {}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════
|
|
2452
|
+
时机:**首屏已经决定好显示哪一屏之后**再放。原因有两条:
|
|
2453
|
+
· 闪屏是「装饰」,不该推迟第一帧可用界面 —— 先让真正的界面在下面铺好;
|
|
2454
|
+
· 反过来说,也不能晚于首屏:它盖住的正是「界面在装配」这一小段,晚到就变成
|
|
2455
|
+
「界面已经能用了,突然又糊上一层瓶」。
|
|
2456
|
+
瓶身来自 MagicConversation.bottle(与读历史的 loading 同一份图形,见那边的注释)。
|
|
2457
|
+
收尾有三道保险,任何一条先到都会让它消失:
|
|
2458
|
+
① 灌满动画结束(1.15s + 淡出 .38s);
|
|
2459
|
+
② 绝对上限 2.6s 的兜底定时器 —— 动画被系统省电/降级掐掉时也不会卡住整屏;
|
|
2460
|
+
③ 用户第一次触摸就直接摘(慢启动时他已经在戳屏幕了,别让他隔着一层动画点)。
|
|
2461
|
+
`prefers-reduced-motion` 下不播动画(CSS 已把灌液关掉),仍然保留「满瓶 + 快速淡出」,
|
|
2462
|
+
因为它是 App 的身份动作,不是纯装饰。 */
|
|
2463
|
+
(function bottleSplash() {
|
|
2464
|
+
var el = document.getElementById('bottleSplash');
|
|
2465
|
+
var slot = document.getElementById('splashBottle');
|
|
2466
|
+
if (!el || !slot || !window.MagicConversation || !window.MagicConversation.bottle) return;
|
|
2467
|
+
function out() {
|
|
2468
|
+
if (!el || el.hidden) return;
|
|
2469
|
+
el.classList.add('is-out');
|
|
2470
|
+
setTimeout(function () { if (el) el.hidden = true; }, 420);
|
|
168
2471
|
}
|
|
2472
|
+
try { slot.appendChild(window.MagicConversation.bottle({ fill: 1, anim: 'rise' })); }
|
|
2473
|
+
catch (e) { el.hidden = true; return; } // 画不出瓶就别留一块空幕布
|
|
2474
|
+
/* ① 灌满 → 淡出:.95s 灌液已含在下面这一拍里(前快后缓,见 conversation.css 的 qbFill),
|
|
2475
|
+
再留 ~.5s 让「满瓶 + 名字」被看清,然后淡出 .38s。总时长 ≈ 1.8s。 */
|
|
2476
|
+
setTimeout(out, 1450);
|
|
2477
|
+
/* ② 兜底:动画被掐也一定撤 */
|
|
2478
|
+
setTimeout(out, 2600);
|
|
2479
|
+
/* ③ 用户先动了手:立刻撤 */
|
|
2480
|
+
['pointerdown', 'touchstart', 'keydown'].forEach(function (t) {
|
|
2481
|
+
window.addEventListener(t, out, { once: true, passive: true });
|
|
2482
|
+
});
|
|
169
2483
|
})();
|
|
170
2484
|
})();
|
|
171
2485
|
</script>
|