magic-agent 2.6.0 → 2.6.3
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 +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- 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 +11 -0
- package/package.json +8 -3
|
@@ -178,12 +178,34 @@
|
|
|
178
178
|
别去改 BOTTLE_CAVITY 的 d。 */
|
|
179
179
|
/* 进选择器 / 参与「首个可用」判定的引擎:探测成功、**已登录**且能列出模型。
|
|
180
180
|
没登录或取不到模型清单的(如 trae、登出后的 codebuddy-ai)不出现
|
|
181
|
-
(2026-10-25 用户要求:「登录了几个账号就显示几个」)。
|
|
181
|
+
(2026-10-25 用户要求:「登录了几个账号就显示几个」)。
|
|
182
|
+
⚠️ 「取不到模型清单」必须分两种(core --engines 的 models_source):
|
|
183
|
+
· none —— 引擎**本身不提供**模型选择(arkclaw 按 claw_id 绑死模型、
|
|
184
|
+
codebuddy-gateway 用网关进程的模型)。这不是故障,引擎完全可用,
|
|
185
|
+
右列显示「引擎默认」即可 —— 以前按 models_note 一刀切,把它一起藏了。
|
|
186
|
+
· failed —— 有动态来源但探测失败(CLI 非 0 退出等),继续隐藏。
|
|
187
|
+
老 core 没有 models_source 时,按 ErrNoModelSource 的固定文案前缀兜底。 */
|
|
188
|
+
function modelsSourceOf(en) {
|
|
189
|
+
return en.models_source || en.modelsSource || '';
|
|
190
|
+
}
|
|
191
|
+
/* 「没有模型可选」的原因,翻成人话(core 的 models_note 是英文诊断串)。
|
|
192
|
+
arkclaw / MagicAI 的模型绑在网关的 claw_id 上,codebuddy-gateway 的绑在网关进程上 ——
|
|
193
|
+
两者都不是"选不了模型就不可用",只是模型不在这台机器上选。 */
|
|
194
|
+
function noModelChoiceHint(en) {
|
|
195
|
+
var note = String(en.models_note || en.modelsNote || '');
|
|
196
|
+
if (/claw_id/i.test(note)) return '模型由网关的 claw_id 决定';
|
|
197
|
+
if (/gateway/i.test(note)) return '模型由网关进程决定';
|
|
198
|
+
return '不指定模型,由引擎自己挑';
|
|
199
|
+
}
|
|
182
200
|
function pickableEngines(list) {
|
|
183
201
|
return (list || []).filter(function (en) {
|
|
184
202
|
if (!en || !en.ok) return false;
|
|
185
203
|
if (en.logged_in === false || en.loggedIn === false) return false;
|
|
186
|
-
|
|
204
|
+
var src = modelsSourceOf(en);
|
|
205
|
+
if (src === 'none') return true;
|
|
206
|
+
if (src === 'failed') return false;
|
|
207
|
+
if (/^no dynamic model source/.test(en.models_note || en.modelsNote || '')) return true;
|
|
208
|
+
return (en.models || []).length > 0 || !(en.models_note || en.modelsNote);
|
|
187
209
|
});
|
|
188
210
|
}
|
|
189
211
|
var BOTTLE_VB = { w: 64, h: 104, x: 16, y: 4 };
|
|
@@ -2427,6 +2449,21 @@
|
|
|
2427
2449
|
left.appendChild(b);
|
|
2428
2450
|
});
|
|
2429
2451
|
var models = qaModelsOrdered(state.engines, cur.engine, cur.models || []);
|
|
2452
|
+
if (!models.length) {
|
|
2453
|
+
/* 引擎不提供模型清单(models_source = none:arkclaw 按 claw_id 绑模型、
|
|
2454
|
+
codebuddy-gateway 用网关进程的模型)。必须给一个「引擎默认」落定项 ——
|
|
2455
|
+
否则左列能选中、右列却是空的,用户无从"确定",notifyPick 也永远不触发。 */
|
|
2456
|
+
var d = el('button', 'pick__citem' + (state.engine === cur.engine && !state.model ? ' is-on' : ''));
|
|
2457
|
+
d.type = 'button';
|
|
2458
|
+
d.appendChild(el('span', 'pick__cl', '引擎默认'));
|
|
2459
|
+
d.appendChild(el('span', 'pick__cd',
|
|
2460
|
+
modelsSourceOf(cur) === 'none' ? noModelChoiceHint(cur) : '不指定模型,由引擎自己挑'));
|
|
2461
|
+
d.onclick = function () {
|
|
2462
|
+
state.engine = cur.engine; state.model = '';
|
|
2463
|
+
syncPickers(); notifyPick(); Sheet.close();
|
|
2464
|
+
};
|
|
2465
|
+
right.appendChild(d);
|
|
2466
|
+
}
|
|
2430
2467
|
models.forEach(function (mm) {
|
|
2431
2468
|
var b = el('button', 'pick__citem' + (state.engine === cur.engine && state.model === mm ? ' is-on' : ''));
|
|
2432
2469
|
b.type = 'button';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* ══════════ 应用页(「发现 → 应用」子页面)══════════
|
|
2
|
+
从 index.html 拆出:选择器只服务这一页(别的页面不引用)。
|
|
3
|
+
一级清单(应用 + 启停徽标)、二级详情(启动 / 停止 / 在内置浏览器打开)。
|
|
4
|
+
清单行沿用 `.plugin*` 那一套(与「插件」「脚本」两页同形),这里只补本页特有的东西。
|
|
5
|
+
CSS 变量(--ink / --muted / --faint / --border / --subtle / --primary-action /
|
|
6
|
+
--running / --tint-run / --stop / --tint-stop / --error-text)仍由 index.html 的 :root 统一提供。
|
|
7
|
+
⚠️ 本页**不定义任何颜色令牌** —— 颜色全走 var(),
|
|
8
|
+
否则 tests/check-ui-spec.mjs 的金标比对会红。 */
|
|
9
|
+
|
|
10
|
+
/* 地址那一行:等宽字、一行省略(长路径不该把整行撑成两行)。 */
|
|
11
|
+
.app__url{font:12px/1.5 var(--mono);color:var(--ink-soft);white-space:nowrap;overflow:hidden;
|
|
12
|
+
text-overflow:ellipsis}
|
|
13
|
+
/* 主按钮一排三个时的次级动作:同样吃 .btn,只调宽度,保证小屏上也能并排放。 */
|
|
14
|
+
.app__acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
|
15
|
+
.app__acts .btn{flex:1;min-width:96px;margin-top:0}
|
|
16
|
+
/* 进行中(启动 / 停止)时按钮上的文字由 JS 换,禁用态由 .btn[disabled] 管。 */
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/* 应用页(移动端「发现 → 应用」)—— 独立子页面模块。
|
|
2
|
+
*
|
|
3
|
+
* 为什么单独一个文件:跟脚本页(scripts.js)、需求页(reqs.js)同一条纪律 ——
|
|
4
|
+
* 这一页的 DOM、渲染、启停、状态轮询全在一处,不往 index.html 里塞。
|
|
5
|
+
*
|
|
6
|
+
* 这一页回答的是「magic-shorts / magic-video 的工作台,现在跑着没有、能不能打开」。
|
|
7
|
+
* 工作台跑在**这台桌面**上(不是手机上):服务端负责起进程 / 杀进程组,
|
|
8
|
+
* 手机只发起,然后在**内置浏览器**里打开(走桌面端的网络,见 apps.go 文件头)。
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 不假装成功:状态一律以服务端探活的结果为准(state.running / state.managed),
|
|
11
|
+
* 本页不根据「我点过启动」自己推断「在跑」。
|
|
12
|
+
*
|
|
13
|
+
* 与宿主(index.html)的边界:只通过 mount(host) 注入的能力交互 ——
|
|
14
|
+
* getBase / getToken(切环境后是活的,必须 getter,不能传快照)、
|
|
15
|
+
* hEl / elSpan / svgUse / toast / openSheet / closeSheet /
|
|
16
|
+
* showRoot(回清单)/ showDetail(进详情)/ backToDiscover / openInBrowser。
|
|
17
|
+
*
|
|
18
|
+
* 数据形状见 CLIENT-CONTRACT §2(/desk/apps):
|
|
19
|
+
* GET /desk/apps → {apps:[{id,name,tagline,description?,homepage?,cli,command,port,
|
|
20
|
+
* installed,sourcePath?,note?,openUrl?,
|
|
21
|
+
* state:{state,running,managed,pid?,port?,url,localUrl?,
|
|
22
|
+
* logTail?,startedAt?,error?}}], notes:[…]}
|
|
23
|
+
* POST /desk/apps/{id}/{start|stop} → SSE
|
|
24
|
+
* event: open {id,action}
|
|
25
|
+
* event: end {ok,code,error?,app}
|
|
26
|
+
*/
|
|
27
|
+
(function () {
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
var H = null; // 宿主(index.html 注入)
|
|
31
|
+
var els = {}; // 本页内部元素
|
|
32
|
+
var ITEMS = []; // 最近一次清单
|
|
33
|
+
var DETAIL = null; // 二级视图:{id}
|
|
34
|
+
var BUSY = {}; // id → 正在做的动作(start / stop),防重复点
|
|
35
|
+
var LAST_LOG = {}; // id → 上次失败的输出尾巴(收尾重画后仍看得到)
|
|
36
|
+
var POLL = null; // 详情页的轮询定时器
|
|
37
|
+
|
|
38
|
+
/* 状态徽标:与脚本页同一套语义(运行中=绿、已停止=石褐)。
|
|
39
|
+
「运行中但不可托管」(桌面端起的)另给一个主色徽标:能打开、但停不了。 */
|
|
40
|
+
function stateText(st) {
|
|
41
|
+
var s = (st && st.state) || 'stopped';
|
|
42
|
+
if (s === 'running') return st && st.managed ? '运行中' : '运行中 · 外部';
|
|
43
|
+
if (s === 'starting') return '启动中';
|
|
44
|
+
return '已停止';
|
|
45
|
+
}
|
|
46
|
+
function stateBadge(st) {
|
|
47
|
+
var s = (st && st.state) || 'stopped';
|
|
48
|
+
if (s === 'running') return st && st.managed ? 'pbadge--run' : 'pbadge--new';
|
|
49
|
+
if (s === 'starting') return 'pbadge--new';
|
|
50
|
+
return 'pbadge--stop';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hEl(tag, cls, text) { return H.hEl(tag, cls, text); }
|
|
54
|
+
function elSpan(cls, text) { return H.elSpan(cls, text); }
|
|
55
|
+
function svgUse(id, size) { return H.svgUse(id, size); }
|
|
56
|
+
function toast(msg) { return H.toast(msg); }
|
|
57
|
+
|
|
58
|
+
function api(path) { return H.getBase() + path; }
|
|
59
|
+
function headers() {
|
|
60
|
+
return H.getToken() ? { 'X-Magic-Token': H.getToken() } : {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function byId(id) {
|
|
64
|
+
for (var i = 0; i < ITEMS.length; i++) if (ITEMS[i].id === id) return ITEMS[i];
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ── 一级:应用清单 ── */
|
|
69
|
+
function renderList() {
|
|
70
|
+
if (!els.list) return;
|
|
71
|
+
var frag = document.createDocumentFragment();
|
|
72
|
+
if (!ITEMS.length) {
|
|
73
|
+
els.list.replaceChildren(hEl('div', 'history__hint',
|
|
74
|
+
'桌面端没有返回任何应用。'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
frag.appendChild(hEl('div', 'history__group', '工作台'));
|
|
78
|
+
ITEMS.forEach(function (it) {
|
|
79
|
+
var st = it.state || {};
|
|
80
|
+
var row = hEl('button', 'plugin'); row.type = 'button';
|
|
81
|
+
var ico = hEl('span', 'plugin__ico' + (st.running ? ' is-on' : ''));
|
|
82
|
+
ico.appendChild(svgUse('#i-grid', 22));
|
|
83
|
+
var col = hEl('span', 'plugin__col');
|
|
84
|
+
col.appendChild(hEl('span', 'plugin__t', it.name));
|
|
85
|
+
col.appendChild(hEl('span', 'plugin__s', it.tagline || ''));
|
|
86
|
+
row.appendChild(ico); row.appendChild(col);
|
|
87
|
+
row.appendChild(elSpan('pbadge ' + stateBadge(st),
|
|
88
|
+
BUSY[it.id] ? (BUSY[it.id] === 'start' ? '启动中…' : '停止中…') : stateText(st)));
|
|
89
|
+
row.onclick = function () { openDetail(it.id); };
|
|
90
|
+
frag.appendChild(row);
|
|
91
|
+
});
|
|
92
|
+
frag.appendChild(hEl('div', 'pd__note',
|
|
93
|
+
'工作台在「这台桌面」上跑(不是手机上);打开时用内置浏览器,走桌面端的网络。'));
|
|
94
|
+
els.list.replaceChildren(frag);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadList(quiet) {
|
|
98
|
+
if (!els.list) return;
|
|
99
|
+
if (!quiet) els.list.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
100
|
+
return fetch(api('/desk/apps'), { headers: headers() })
|
|
101
|
+
.then(function (r) {
|
|
102
|
+
if (r.status === 404) throw new Error('当前桌面端没有应用能力(需要较新版本)');
|
|
103
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
104
|
+
return r.json();
|
|
105
|
+
})
|
|
106
|
+
.then(function (j) {
|
|
107
|
+
ITEMS = (j && j.apps) || [];
|
|
108
|
+
renderList();
|
|
109
|
+
/* 详情页开着时,顺手刷一次(从启动状态变成运行中要靠它)。 */
|
|
110
|
+
if (DETAIL) renderDetail(true);
|
|
111
|
+
})
|
|
112
|
+
.catch(function (e) {
|
|
113
|
+
if (quiet && ITEMS.length) return;
|
|
114
|
+
els.list.innerHTML = '';
|
|
115
|
+
els.list.appendChild(hEl('div', 'history__hint',
|
|
116
|
+
'读取应用列表失败:' + ((e && e.message) || e)));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ── 二级:详情(启动 / 停止 / 在内置浏览器打开)── */
|
|
121
|
+
function openDetail(id) {
|
|
122
|
+
DETAIL = { id: id };
|
|
123
|
+
H.showDetail();
|
|
124
|
+
renderDetail(false);
|
|
125
|
+
loadList(true);
|
|
126
|
+
startPoll();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function backToList() {
|
|
130
|
+
stopPoll();
|
|
131
|
+
DETAIL = null;
|
|
132
|
+
H.showRoot();
|
|
133
|
+
loadList(true);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* 启动中 / 运行中的时候轮询一次清单:启动是服务端等端口应答,手机这边
|
|
137
|
+
只需要看它什么时候变过来(以及外部实例是否自己退了)。 */
|
|
138
|
+
function startPoll() {
|
|
139
|
+
stopPoll();
|
|
140
|
+
POLL = setInterval(function () { if (DETAIL) loadList(true); }, 2500);
|
|
141
|
+
}
|
|
142
|
+
function stopPoll() {
|
|
143
|
+
if (POLL) { clearInterval(POLL); POLL = null; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderDetail(keepScroll) {
|
|
147
|
+
if (!els.detailBody || !DETAIL) return;
|
|
148
|
+
var it = byId(DETAIL.id);
|
|
149
|
+
var box = els.detailBody;
|
|
150
|
+
var logBox = box.querySelector('#appLog');
|
|
151
|
+
var keepLog = keepScroll && logBox ? logBox.scrollTop : 0;
|
|
152
|
+
box.innerHTML = '';
|
|
153
|
+
if (!it) {
|
|
154
|
+
box.appendChild(hEl('div', 'history__hint', '这个应用不在清单里了(可能桌面端换了环境)。'));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
var st = it.state || {};
|
|
158
|
+
var wrap = hEl('div', 'pd');
|
|
159
|
+
|
|
160
|
+
var head = hEl('div', 'pd__head');
|
|
161
|
+
head.appendChild(hEl('span', 'pd__name', it.name));
|
|
162
|
+
head.appendChild(elSpan('pbadge ' + stateBadge(st), stateText(st)));
|
|
163
|
+
wrap.appendChild(head);
|
|
164
|
+
wrap.appendChild(hEl('p', 'pd__tag', it.tagline || ''));
|
|
165
|
+
|
|
166
|
+
/* 状态说明:服务端说的原话(为什么是这个状态),本页不自己编。 */
|
|
167
|
+
if (it.note) wrap.appendChild(hEl('div', 'pd__note', it.note));
|
|
168
|
+
if (st.running && st.localUrl) {
|
|
169
|
+
wrap.appendChild(hEl('div', 'pd__note', '桌面端地址:' + st.localUrl));
|
|
170
|
+
}
|
|
171
|
+
if (st.running && st.managed && st.pid) {
|
|
172
|
+
wrap.appendChild(hEl('div', 'pd__note', '进程 PID ' + st.pid + '(随工作台起的子进程一起走)'));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* 动作:运行中主角是「在内置浏览器打开」,没跑时主角是「启动」。 */
|
|
176
|
+
var acts = hEl('div', 'app__acts');
|
|
177
|
+
var busy = BUSY[it.id];
|
|
178
|
+
if (st.running) {
|
|
179
|
+
var openBtn = hEl('button', 'btn btn--filled', '在内置浏览器打开');
|
|
180
|
+
openBtn.type = 'button';
|
|
181
|
+
openBtn.onclick = function () { openApp(it); };
|
|
182
|
+
acts.appendChild(openBtn);
|
|
183
|
+
|
|
184
|
+
var stopBtn = hEl('button', 'btn btn--outlined',
|
|
185
|
+
busy === 'stop' ? '正在停止…' : (st.managed ? '停止' : '停止(到桌面端)'));
|
|
186
|
+
stopBtn.type = 'button';
|
|
187
|
+
stopBtn.disabled = !!busy || !st.managed;
|
|
188
|
+
if (!st.managed) stopBtn.title = '这个工作台不是从手机启动的,请到桌面端停止';
|
|
189
|
+
stopBtn.onclick = function () { if (st.managed) runAction(it, 'stop'); };
|
|
190
|
+
acts.appendChild(stopBtn);
|
|
191
|
+
} else {
|
|
192
|
+
var startBtn = hEl('button', 'btn btn--filled',
|
|
193
|
+
busy === 'start' ? '正在启动…' : (st.state === 'starting' ? '启动中…' : '启动'));
|
|
194
|
+
startBtn.type = 'button';
|
|
195
|
+
startBtn.disabled = !!busy || st.state === 'starting';
|
|
196
|
+
startBtn.onclick = function () { runAction(it, 'start'); };
|
|
197
|
+
acts.appendChild(startBtn);
|
|
198
|
+
}
|
|
199
|
+
wrap.appendChild(acts);
|
|
200
|
+
|
|
201
|
+
/* 输出尾巴:起不来时原因就在这几行里(stderr 原样),不让人去桌面翻日志。 */
|
|
202
|
+
var logLines = (LAST_LOG[it.id] && LAST_LOG[it.id].length) ? LAST_LOG[it.id] : (st.logTail || []);
|
|
203
|
+
if (logLines.length) {
|
|
204
|
+
var logSec = hEl('div', 'pd__sec');
|
|
205
|
+
logSec.appendChild(hEl('div', 'pd__h', '最近输出'));
|
|
206
|
+
var pre = hEl('pre', 'pd__log pd__log--script', logLines.join('\n'));
|
|
207
|
+
pre.id = 'appLog';
|
|
208
|
+
pre.hidden = false;
|
|
209
|
+
pre.scrollTop = keepLog;
|
|
210
|
+
logSec.appendChild(pre);
|
|
211
|
+
wrap.appendChild(logSec);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* 介绍 / 怎么起 / 主页。 */
|
|
215
|
+
wrap.appendChild(pdSection('介绍', hEl('p', 'pd__p', it.description || '')));
|
|
216
|
+
|
|
217
|
+
var cmdSec = hEl('div', 'pd__sec');
|
|
218
|
+
cmdSec.appendChild(hEl('div', 'pd__h', '等价命令(在桌面端执行)'));
|
|
219
|
+
cmdSec.appendChild(hEl('pre', 'pd__code', it.command || ''));
|
|
220
|
+
var copyBtn = hEl('button', 'btn btn--outlined', '复制命令');
|
|
221
|
+
copyBtn.type = 'button';
|
|
222
|
+
copyBtn.onclick = function () { copyText(it.command || '', '命令已复制'); };
|
|
223
|
+
cmdSec.appendChild(copyBtn);
|
|
224
|
+
wrap.appendChild(cmdSec);
|
|
225
|
+
|
|
226
|
+
if (!it.installed) {
|
|
227
|
+
var miss = hEl('div', 'pd__note',
|
|
228
|
+
'PATH 上没有 ' + it.cli + '。装法:' + (it.install || '见项目主页') +
|
|
229
|
+
'(「发现 → 插件」那一页可以直接点安装)。');
|
|
230
|
+
missingStyle(miss);
|
|
231
|
+
wrap.appendChild(miss);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (it.homepage) {
|
|
235
|
+
var home = hEl('button', 'setrow');
|
|
236
|
+
home.appendChild(hEl('span', 'setrow__l', '项目主页'));
|
|
237
|
+
home.appendChild(hEl('span', 'setrow__v', it.homepage));
|
|
238
|
+
home.onclick = function () { H.openInBrowser(it.homepage); };
|
|
239
|
+
var card = hEl('div', 'setcard'); card.appendChild(home);
|
|
240
|
+
wrap.appendChild(card);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
box.appendChild(wrap);
|
|
244
|
+
if (els.detailTitle) els.detailTitle.textContent = it.name;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function missingStyle(node) { node.style.color = 'var(--error-text)'; }
|
|
248
|
+
|
|
249
|
+
function pdSection(title, node) {
|
|
250
|
+
var sec = hEl('div', 'pd__sec');
|
|
251
|
+
sec.appendChild(hEl('div', 'pd__h', title));
|
|
252
|
+
sec.appendChild(node);
|
|
253
|
+
return sec;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* 打开工作台:交给宿主的内置浏览器。
|
|
257
|
+
openUrl 是**相对地址**(/workbench/{id}/):先到入口探活 + 跳转,再由服务端
|
|
258
|
+
把根路径请求转给工作台(页面用 '/api/…',必须在根下跑)。 */
|
|
259
|
+
function openApp(it) {
|
|
260
|
+
var u = (it && it.openUrl) || ((it && it.state && it.state.url) || '');
|
|
261
|
+
if (!u) { toast('这个应用没有给出地址'); return; }
|
|
262
|
+
if (!H.openInBrowser) { toast('当前界面不支持内置浏览器'); return; }
|
|
263
|
+
/* landscape:工作台自己声明「按桌面宽屏排版」(magic-video 是 true)。
|
|
264
|
+
宿主据此在竖屏下先转横屏 —— 让浏览器去量页面宽度是量不出来的(它是响应式的,
|
|
265
|
+
只溢出十几个像素,恰好切掉顶栏那排 tab)。 */
|
|
266
|
+
H.openInBrowser(u, { id: it.id, landscape: !!it.landscape });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ── 启停(SSE)──
|
|
270
|
+
手解 SSE(跟脚本页、插件页同一套写法):fetch + ReadableStream,
|
|
271
|
+
因为要带自定义头 X-Magic-Token,EventSource 带不了。 */
|
|
272
|
+
function runAction(it, action) {
|
|
273
|
+
if (BUSY[it.id]) return;
|
|
274
|
+
BUSY[it.id] = action;
|
|
275
|
+
delete LAST_LOG[it.id];
|
|
276
|
+
renderDetail(false);
|
|
277
|
+
renderList();
|
|
278
|
+
|
|
279
|
+
var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
280
|
+
var opts = { method: 'POST', headers: headers() };
|
|
281
|
+
if (ctrl) opts.signal = ctrl.signal;
|
|
282
|
+
/* 等端口应答最多 20 秒(服务端侧),这里给足,避免长轮询被当成失败。 */
|
|
283
|
+
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 45000);
|
|
284
|
+
|
|
285
|
+
function finish(errText) {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
delete BUSY[it.id];
|
|
288
|
+
if (errText) {
|
|
289
|
+
toast((action === 'start' ? '启动失败:' : '停止失败:') + errText);
|
|
290
|
+
} else {
|
|
291
|
+
toast(action === 'start' ? '已启动' : '已停止');
|
|
292
|
+
}
|
|
293
|
+
loadList(true);
|
|
294
|
+
if (DETAIL) renderDetail(true);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fetch(api('/desk/apps/' + encodeURIComponent(it.id) + '/' + action), opts)
|
|
298
|
+
.then(function (resp) {
|
|
299
|
+
if (!resp.ok || !resp.body) {
|
|
300
|
+
return resp.text().then(function (t) {
|
|
301
|
+
throw new Error((t || ('HTTP ' + resp.status)).slice(0, 300));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
|
|
305
|
+
var ended = false;
|
|
306
|
+
return (function pump() {
|
|
307
|
+
return reader.read().then(function (chunk) {
|
|
308
|
+
if (chunk.done) return null;
|
|
309
|
+
buf += dec.decode(chunk.value, { stream: true });
|
|
310
|
+
var blocks = buf.split('\n\n');
|
|
311
|
+
buf = blocks.pop();
|
|
312
|
+
blocks.forEach(function (b) { if (!ended) ended = handleBlock(it.id, b, finish); });
|
|
313
|
+
return ended ? null : pump();
|
|
314
|
+
});
|
|
315
|
+
})();
|
|
316
|
+
})
|
|
317
|
+
.then(function () { if (!ended) finish(''); })
|
|
318
|
+
.catch(function (e) {
|
|
319
|
+
if (e && e.name === 'AbortError') {
|
|
320
|
+
/* 超时多半是工作台在起(20 秒 + 预热);不报失败 —— 让轮询去拿真实状态。 */
|
|
321
|
+
finish('');
|
|
322
|
+
loadList(true);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
finish((e && e.message) || String(e));
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* 解析一个 SSE 块;收到 end 帧 → 收尾并返回 true(调用方停止继续读)。 */
|
|
330
|
+
function handleBlock(id, block, finish) {
|
|
331
|
+
var name = '', data = '';
|
|
332
|
+
block.split('\n').forEach(function (ln) {
|
|
333
|
+
if (ln.indexOf('event: ') === 0) name = ln.slice(7).trim();
|
|
334
|
+
else if (ln.indexOf('data: ') === 0) data += ln.slice(6);
|
|
335
|
+
});
|
|
336
|
+
if (!data) return false;
|
|
337
|
+
var ev; try { ev = JSON.parse(data); } catch (e) { return false; }
|
|
338
|
+
if (name !== 'end') return false;
|
|
339
|
+
/* end 帧带完整的最新状态:先落到 ITEMS 里,再收尾重画。 */
|
|
340
|
+
if (ev && ev.app && ev.app.id) {
|
|
341
|
+
var replaced = false;
|
|
342
|
+
for (var i = 0; i < ITEMS.length; i++) {
|
|
343
|
+
if (ITEMS[i].id === ev.app.id) { ITEMS[i] = ev.app; replaced = true; break; }
|
|
344
|
+
}
|
|
345
|
+
if (!replaced) ITEMS.push(ev.app);
|
|
346
|
+
var tail = (ev.app.state && ev.app.state.logTail) || [];
|
|
347
|
+
if (tail.length) LAST_LOG[ev.app.id] = tail;
|
|
348
|
+
}
|
|
349
|
+
if (ev && ev.ok) { finish(''); return true; }
|
|
350
|
+
var msg = (ev && ev.error) ? String(ev.error) : '服务端没有说明原因';
|
|
351
|
+
/* 409(已在启动 / 外部实例)不是故障,原样提示即可。 */
|
|
352
|
+
finish(msg);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function copyText(text, okMsg) {
|
|
357
|
+
try {
|
|
358
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
359
|
+
navigator.clipboard.writeText(text).then(function () { toast(okMsg); },
|
|
360
|
+
function () { toast('复制失败'); });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
} catch (e) {}
|
|
364
|
+
toast('当前环境不支持复制');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* ── 挂载 ── */
|
|
368
|
+
function mount(host) {
|
|
369
|
+
H = host;
|
|
370
|
+
var root = H.rootEl, detail = H.detailEl;
|
|
371
|
+
if (!root || !detail) return;
|
|
372
|
+
root.innerHTML =
|
|
373
|
+
'<div class="history__top">' +
|
|
374
|
+
'<button class="topbar__icon" id="btnAppsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
|
|
375
|
+
'<span class="history__title">应用</span>' +
|
|
376
|
+
'</div>' +
|
|
377
|
+
'<div class="history__list" id="appList"></div>';
|
|
378
|
+
detail.innerHTML =
|
|
379
|
+
'<div class="history__top">' +
|
|
380
|
+
'<button class="topbar__icon" id="btnAppDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
|
|
381
|
+
'<span class="history__title" id="appDetailTitle">应用</span>' +
|
|
382
|
+
'</div>' +
|
|
383
|
+
'<div class="history__list" id="appDetailBody"></div>';
|
|
384
|
+
els.list = root.querySelector('#appList');
|
|
385
|
+
els.detailBody = detail.querySelector('#appDetailBody');
|
|
386
|
+
els.detailTitle = detail.querySelector('#appDetailTitle');
|
|
387
|
+
var back = root.querySelector('#btnAppsBack');
|
|
388
|
+
if (back) back.onclick = function () { H.backToDiscover(); };
|
|
389
|
+
var dback = detail.querySelector('#btnAppDetailBack');
|
|
390
|
+
if (dback) dback.onclick = function () { backToList(); };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* 进入「发现 → 应用」:拉一次清单(只读,不启动任何东西)。 */
|
|
394
|
+
function show() {
|
|
395
|
+
DETAIL = null;
|
|
396
|
+
stopPoll();
|
|
397
|
+
loadList(false);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
window.MagicApps = {
|
|
401
|
+
mount: mount,
|
|
402
|
+
show: show,
|
|
403
|
+
/* 给测试与调试用:当前清单快照(只读)。 */
|
|
404
|
+
items: function () { return ITEMS.slice(); }
|
|
405
|
+
};
|
|
406
|
+
})();
|