magic-agent 2.4.3 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +8 -2
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/* 脚本页(移动端「发现 → 脚本」)—— 独立子页面模块。
|
|
2
|
+
*
|
|
3
|
+
* 为什么单独一个文件:跟需求页(reqs.js)同一条纪律 —— 这一页的 DOM、渲染、
|
|
4
|
+
* 启停与日志流式全在一处,不往 index.html 那 6900 行里塞。
|
|
5
|
+
*
|
|
6
|
+
* 这一页回答的是「这台桌面上那几个脚本,现在跑着没有、日志长什么样」。
|
|
7
|
+
* 用户自己登记脚本(路径 + 参数),服务端负责 spawn / 杀进程组 / 收日志。
|
|
8
|
+
*
|
|
9
|
+
* 与宿主(index.html)的边界:只通过 mount(host) 注入的能力交互 ——
|
|
10
|
+
* getBase / getToken(切环境后是活的,必须 getter,不能传快照)、
|
|
11
|
+
* hEl / elSpan / svgUse / toast / hideAll / setActive /
|
|
12
|
+
* showRoot(回清单)/ showDetail(进详情)/ openSheet(表单抽屉)。
|
|
13
|
+
*
|
|
14
|
+
* 数据形状见 CLIENT-CONTRACT §2(/desk/scripts):
|
|
15
|
+
* GET /desk/scripts → {scripts:[{id,name,path,args[],cwd,note,
|
|
16
|
+
* state:{state,pid,exitCode,startedAt,endedAt,logSeq,error}}]}
|
|
17
|
+
* GET /desk/scripts/{id}/log?after=<seq> → SSE
|
|
18
|
+
* event: line {seq,text}
|
|
19
|
+
* event: state {state,exitCode,pid,...}
|
|
20
|
+
*/
|
|
21
|
+
(function () {
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
var H = null; // 宿主(index.html 注入)
|
|
25
|
+
var els = {}; // 本页内部元素
|
|
26
|
+
var ITEMS = []; // 最近一次清单
|
|
27
|
+
var DETAIL = null; // 二级视图:{id}
|
|
28
|
+
var LOG_SEQ = {}; // id → 已经拿到的最后一行 seq(断线重连的 after)
|
|
29
|
+
var LOG_TEXT = {}; // id → 日志文本(切页回来还在)
|
|
30
|
+
var STREAMING = {}; // id → 正在跑的日志流(防重复订阅)
|
|
31
|
+
var BUSY = {}; // id → 正在进行的动作(启停),防重复点
|
|
32
|
+
|
|
33
|
+
/* ── 状态文案与徽标 ──
|
|
34
|
+
状态是服务端给的,不在客户端猜:running / stopped / done / failed。 */
|
|
35
|
+
var STATE_TEXT = {
|
|
36
|
+
running: '运行中',
|
|
37
|
+
stopped: '已停止',
|
|
38
|
+
done: '已结束',
|
|
39
|
+
failed: '失败',
|
|
40
|
+
};
|
|
41
|
+
var STATE_BADGE = {
|
|
42
|
+
running: 'pbadge--run',
|
|
43
|
+
stopped: 'pbadge--stop',
|
|
44
|
+
done: 'pbadge--done',
|
|
45
|
+
failed: 'pbadge--fail',
|
|
46
|
+
};
|
|
47
|
+
function stateText(s) { return STATE_TEXT[s] || s || '未知'; }
|
|
48
|
+
function stateBadge(s) { return STATE_BADGE[s] || 'pbadge--stop'; }
|
|
49
|
+
|
|
50
|
+
/* 宿主能力的短名(函数体里沿用原 index 的写法)。 */
|
|
51
|
+
function hEl(tag, cls, text) { return H.hEl(tag, cls, text); }
|
|
52
|
+
function elSpan(cls, text) { return H.elSpan(cls, text); }
|
|
53
|
+
function svgUse(id, size) { return H.svgUse(id, size); }
|
|
54
|
+
function toast(msg) { return H.toast(msg); }
|
|
55
|
+
|
|
56
|
+
/* ── 路径显示 ──
|
|
57
|
+
⚠️ 末级目录名要**同时认 '\\' 与 '/'**:桌面可能是 Windows,路径是
|
|
58
|
+
D:\tools\build.cmd,只按 '/' 切会得到整条路径(记忆 project_client_path_handling)。
|
|
59
|
+
下发给服务端的路径保持原样(后端自己按 Windows 口径处理)。 */
|
|
60
|
+
function baseName(p) {
|
|
61
|
+
var s = String(p || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
62
|
+
var parts = s.split('/').filter(Boolean);
|
|
63
|
+
return parts.pop() || String(p || '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function api(path) { return H.getBase() + path; }
|
|
67
|
+
function headers() { return H.getToken() ? { 'X-Magic-Token': H.getToken() } : {}; }
|
|
68
|
+
function jsonHeaders() {
|
|
69
|
+
var h = headers();
|
|
70
|
+
h['Content-Type'] = 'application/json';
|
|
71
|
+
return h;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── 表单 → 请求体 ──
|
|
75
|
+
单独拎出来(而不是塞在 commit 里)是为了能**单独测**:
|
|
76
|
+
这一段负责把「用户填的一堆字符串」变成后端要的形状,
|
|
77
|
+
形状错了服务端会直接 400,用户在手机上却看不出哪填错了。
|
|
78
|
+
|
|
79
|
+
⚠️ 环境变量:一行一条,空行忽略。**不去校验 KEY=VALUE** ——
|
|
80
|
+
后端会校验并回一句人话错误,前端再抄一遍只会两处规则不同步。
|
|
81
|
+
⚠️ 参数按空白切分(不过 shell,服务端拿到的是独立 argv 元素)。 */
|
|
82
|
+
function buildPayload(inputs) {
|
|
83
|
+
var args = String(inputs.args.value || '').trim();
|
|
84
|
+
var env = String(inputs.env.value || '').split('\n')
|
|
85
|
+
.map(function (s) { return s.trim(); })
|
|
86
|
+
.filter(function (s) { return s !== ''; });
|
|
87
|
+
return {
|
|
88
|
+
name: String(inputs.name.value || '').trim(),
|
|
89
|
+
path: String(inputs.path.value || '').trim(),
|
|
90
|
+
args: args ? args.split(/\s+/) : [],
|
|
91
|
+
env: env,
|
|
92
|
+
cwd: String(inputs.cwd.value || '').trim(),
|
|
93
|
+
interpreter: String(inputs.interpreter.value || '').trim(),
|
|
94
|
+
note: String(inputs.note.value || '').trim(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ── 一级:脚本清单 ── */
|
|
99
|
+
function renderList() {
|
|
100
|
+
if (!els.list) return;
|
|
101
|
+
var frag = document.createDocumentFragment();
|
|
102
|
+
if (!ITEMS.length) {
|
|
103
|
+
frag.appendChild(hEl('div', 'history__hint',
|
|
104
|
+
'还没有脚本。点右上角「添加」登记一个脚本文件(路径 + 参数),就能在这里启停它、看它的日志。'));
|
|
105
|
+
els.list.replaceChildren(frag);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
frag.appendChild(hEl('div', 'history__group', '已登记'));
|
|
109
|
+
ITEMS.forEach(function (it) {
|
|
110
|
+
var row = hEl('button', 'plugin'); row.type = 'button';
|
|
111
|
+
var ico = hEl('span', 'plugin__ico' + (it.state && it.state.state === 'running' ? ' is-on' : ''));
|
|
112
|
+
ico.appendChild(svgUse('#i-cpu', 22));
|
|
113
|
+
var col = hEl('span', 'plugin__col');
|
|
114
|
+
col.appendChild(hEl('span', 'plugin__t', it.name || baseName(it.path)));
|
|
115
|
+
var sub = baseName(it.path);
|
|
116
|
+
if (it.args && it.args.length) sub += ' · ' + it.args.join(' ');
|
|
117
|
+
col.appendChild(hEl('span', 'plugin__s', sub));
|
|
118
|
+
row.appendChild(ico); row.appendChild(col);
|
|
119
|
+
var st = (it.state && it.state.state) || 'stopped';
|
|
120
|
+
row.appendChild(elSpan('pbadge ' + stateBadge(st), stateText(st)));
|
|
121
|
+
row.onclick = function () { openDetail(it.id); };
|
|
122
|
+
frag.appendChild(row);
|
|
123
|
+
});
|
|
124
|
+
frag.appendChild(hEl('div', 'pd__note',
|
|
125
|
+
'脚本在**这台桌面**上跑(不是手机上);停止会连它自己起的子进程一起收掉。'));
|
|
126
|
+
els.list.replaceChildren(frag);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function loadList(quiet) {
|
|
130
|
+
if (!els.list) return;
|
|
131
|
+
if (!quiet) els.list.innerHTML = '<div class="history__hint">读取中…</div>';
|
|
132
|
+
return fetch(api('/desk/scripts'), { headers: headers() })
|
|
133
|
+
.then(function (r) {
|
|
134
|
+
if (r.status === 404) throw new Error('当前桌面端没有脚本能力(需要较新版本)');
|
|
135
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
136
|
+
return r.json();
|
|
137
|
+
})
|
|
138
|
+
.then(function (j) {
|
|
139
|
+
ITEMS = (j && j.scripts) || [];
|
|
140
|
+
renderList();
|
|
141
|
+
})
|
|
142
|
+
.catch(function (e) {
|
|
143
|
+
if (quiet && ITEMS.length) return;
|
|
144
|
+
els.list.innerHTML = '';
|
|
145
|
+
els.list.appendChild(hEl('div', 'history__hint',
|
|
146
|
+
'读取脚本清单失败:' + ((e && e.message) || e)));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── 二级:详情(启停 + 日志)── */
|
|
151
|
+
function currentItem(id) {
|
|
152
|
+
for (var i = 0; i < ITEMS.length; i++) if (ITEMS[i].id === id) return ITEMS[i];
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function openDetail(id) {
|
|
157
|
+
DETAIL = { id: id };
|
|
158
|
+
/* 日志从头看(服务端环形缓冲里最近的几百行),seq 归零重收一遍。
|
|
159
|
+
⚠️ 必须把上一条订阅**中止**掉再重开:否则那条连接还挂在服务端,
|
|
160
|
+
新订阅被 STREAMING 挡掉 → 页面只剩历史日志、再没有新行。 */
|
|
161
|
+
abortStream(id);
|
|
162
|
+
LOG_SEQ[id] = 0;
|
|
163
|
+
LOG_TEXT[id] = '';
|
|
164
|
+
H.showDetail();
|
|
165
|
+
renderDetail();
|
|
166
|
+
streamLog(id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function backToList() {
|
|
170
|
+
if (DETAIL) abortStream(DETAIL.id);
|
|
171
|
+
DETAIL = null;
|
|
172
|
+
H.showRoot();
|
|
173
|
+
loadList(true);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderDetail() {
|
|
177
|
+
if (!els.detailBody || !DETAIL) return;
|
|
178
|
+
var it = currentItem(DETAIL.id);
|
|
179
|
+
var box = els.detailBody;
|
|
180
|
+
box.innerHTML = '';
|
|
181
|
+
if (!it) {
|
|
182
|
+
box.appendChild(hEl('div', 'history__hint', '这个脚本不在清单里了(可能已被删除)。'));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
var st = (it.state && it.state.state) || 'stopped';
|
|
186
|
+
var wrap = hEl('div', 'pd');
|
|
187
|
+
|
|
188
|
+
var head = hEl('div', 'pd__head');
|
|
189
|
+
head.appendChild(hEl('span', 'pd__name', it.name || baseName(it.path)));
|
|
190
|
+
head.appendChild(elSpan('pbadge ' + stateBadge(st), stateText(st)));
|
|
191
|
+
wrap.appendChild(head);
|
|
192
|
+
|
|
193
|
+
/* 状态说明:把「服务端说的」摊开,不让用户猜。 */
|
|
194
|
+
if (it.state && it.state.error) {
|
|
195
|
+
var err = hEl('div', 'pd__note', '⚠ ' + it.state.error);
|
|
196
|
+
err.style.color = 'var(--error-text)';
|
|
197
|
+
wrap.appendChild(err);
|
|
198
|
+
}
|
|
199
|
+
if (st === 'running' && it.state && it.state.pid) {
|
|
200
|
+
wrap.appendChild(hEl('div', 'pd__note', '进程 PID ' + it.state.pid));
|
|
201
|
+
}
|
|
202
|
+
if (st === 'done' || st === 'failed') {
|
|
203
|
+
wrap.appendChild(hEl('div', 'pd__note',
|
|
204
|
+
'退出码 ' + (it.state && it.state.exitCode != null ? it.state.exitCode : '-')));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* 脚本定义:路径 / 参数 / 工作目录,一行一条,别挤成一段。 */
|
|
208
|
+
wrap.appendChild(hEl('div', 'pd__note', '路径:' + it.path));
|
|
209
|
+
if (it.interpreter) {
|
|
210
|
+
wrap.appendChild(hEl('div', 'pd__note', '解释器:' + it.interpreter));
|
|
211
|
+
}
|
|
212
|
+
if (it.args && it.args.length) {
|
|
213
|
+
wrap.appendChild(hEl('div', 'pd__note', '参数:' + it.args.join(' ')));
|
|
214
|
+
}
|
|
215
|
+
if (it.env && it.env.length) {
|
|
216
|
+
wrap.appendChild(hEl('div', 'pd__note', '环境变量:' + it.env.join(' · ')));
|
|
217
|
+
}
|
|
218
|
+
if (it.cwd) wrap.appendChild(hEl('div', 'pd__note', '工作目录:' + it.cwd));
|
|
219
|
+
if (it.note) wrap.appendChild(hEl('div', 'pd__note', '备注:' + it.note));
|
|
220
|
+
|
|
221
|
+
/* 动作:启停是主角,编辑/删除次要。 */
|
|
222
|
+
var actions = hEl('div', 'pd__actions');
|
|
223
|
+
var running = st === 'running';
|
|
224
|
+
var runBtn = hEl('button', 'btn btn--filled', running ? '停止' : '启动');
|
|
225
|
+
runBtn.type = 'button';
|
|
226
|
+
runBtn.disabled = !!BUSY[it.id];
|
|
227
|
+
runBtn.onclick = function () { running ? stopScript(it.id) : startScript(it.id); };
|
|
228
|
+
actions.appendChild(runBtn);
|
|
229
|
+
|
|
230
|
+
var editBtn = hEl('button', 'btn btn--outlined', '编辑');
|
|
231
|
+
editBtn.type = 'button';
|
|
232
|
+
editBtn.onclick = function () { openForm(it); };
|
|
233
|
+
actions.appendChild(editBtn);
|
|
234
|
+
|
|
235
|
+
var delBtn = hEl('button', 'btn btn--outlined', '删除');
|
|
236
|
+
delBtn.type = 'button';
|
|
237
|
+
delBtn.onclick = function () { removeScript(it); };
|
|
238
|
+
actions.appendChild(delBtn);
|
|
239
|
+
wrap.appendChild(actions);
|
|
240
|
+
|
|
241
|
+
/* 日志区:真实输出,不是「已启动」三个字。 */
|
|
242
|
+
var logWrap = hEl('div', 'pd__sec');
|
|
243
|
+
logWrap.appendChild(hEl('div', 'pd__h', '日志'));
|
|
244
|
+
var log = hEl('pre', 'pd__log pd__log--script', LOG_TEXT[it.id] || '');
|
|
245
|
+
log.id = 'scriptLog';
|
|
246
|
+
logWrap.appendChild(log);
|
|
247
|
+
wrap.appendChild(logWrap);
|
|
248
|
+
|
|
249
|
+
box.appendChild(wrap);
|
|
250
|
+
if (els.detailTitle) els.detailTitle.textContent = it.name || baseName(it.path);
|
|
251
|
+
scrollLog();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function scrollLog() {
|
|
255
|
+
var log = document.getElementById('scriptLog');
|
|
256
|
+
if (log) log.scrollTop = log.scrollHeight;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function appendLog(id, text) {
|
|
260
|
+
LOG_TEXT[id] = (LOG_TEXT[id] || '') + text + '\n';
|
|
261
|
+
if (DETAIL && DETAIL.id === id) {
|
|
262
|
+
var log = document.getElementById('scriptLog');
|
|
263
|
+
if (log) {
|
|
264
|
+
log.textContent = LOG_TEXT[id];
|
|
265
|
+
log.scrollTop = log.scrollHeight;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ── 日志流(SSE)──
|
|
271
|
+
手解 SSE(跟插件页同一套写法):fetch + ReadableStream,
|
|
272
|
+
因为要带自定义头 X-Magic-Token,EventSource 带不了。
|
|
273
|
+
⚠️ 每条订阅带一个 AbortController:切页/重开时要能**中止**它 ——
|
|
274
|
+
服务端那条 SSE 会一直挂着到脚本结束,不中止就会漏连接、
|
|
275
|
+
而且重开详情时旧订阅还占着 STREAMING,新订阅起不来。 */
|
|
276
|
+
function streamLog(id) {
|
|
277
|
+
if (STREAMING[id]) return;
|
|
278
|
+
var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
279
|
+
STREAMING[id] = ctrl || true;
|
|
280
|
+
var after = LOG_SEQ[id] || 0;
|
|
281
|
+
var opts = { headers: headers() };
|
|
282
|
+
if (ctrl) opts.signal = ctrl.signal;
|
|
283
|
+
fetch(api('/desk/scripts/' + encodeURIComponent(id) + '/log?after=' + after), opts)
|
|
284
|
+
.then(function (resp) {
|
|
285
|
+
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
|
|
286
|
+
var reader = resp.body.getReader(), dec = new TextDecoder(), buf = '';
|
|
287
|
+
return (function pump() {
|
|
288
|
+
return reader.read().then(function (chunk) {
|
|
289
|
+
if (chunk.done) return null;
|
|
290
|
+
buf += dec.decode(chunk.value, { stream: true });
|
|
291
|
+
var blocks = buf.split('\n\n');
|
|
292
|
+
buf = blocks.pop();
|
|
293
|
+
blocks.forEach(function (b) { handleBlock(id, b); });
|
|
294
|
+
return pump();
|
|
295
|
+
});
|
|
296
|
+
})();
|
|
297
|
+
})
|
|
298
|
+
.catch(function (e) {
|
|
299
|
+
/* 主动中止不算错误(我们自己切的页 / 重开了详情)。 */
|
|
300
|
+
if (e && e.name === 'AbortError') return;
|
|
301
|
+
if (DETAIL && DETAIL.id === id) toast('日志流中断:' + ((e && e.message) || e));
|
|
302
|
+
})
|
|
303
|
+
.then(function () { delete STREAMING[id]; });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* 中止某个脚本的日志订阅。 */
|
|
307
|
+
function abortStream(id) {
|
|
308
|
+
var c = STREAMING[id];
|
|
309
|
+
if (c && c.abort) c.abort();
|
|
310
|
+
delete STREAMING[id];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handleBlock(id, block) {
|
|
314
|
+
var name = '', data = '';
|
|
315
|
+
block.split('\n').forEach(function (ln) {
|
|
316
|
+
if (ln.indexOf('event: ') === 0) name = ln.slice(7).trim();
|
|
317
|
+
else if (ln.indexOf('data: ') === 0) data += ln.slice(6);
|
|
318
|
+
});
|
|
319
|
+
if (!data) return;
|
|
320
|
+
var ev; try { ev = JSON.parse(data); } catch (e) { return; }
|
|
321
|
+
if (name === 'line') {
|
|
322
|
+
if (ev.seq) LOG_SEQ[id] = ev.seq;
|
|
323
|
+
appendLog(id, ev.text == null ? '' : String(ev.text));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (name === 'state') {
|
|
327
|
+
/* 状态变了:更新徽标 + 停掉订阅(服务端在非 running 时也会自己收尾)。 */
|
|
328
|
+
var it = currentItem(id);
|
|
329
|
+
if (it) it.state = ev;
|
|
330
|
+
if (DETAIL && DETAIL.id === id) {
|
|
331
|
+
renderDetail();
|
|
332
|
+
if (ev && ev.state && ev.state !== 'running') {
|
|
333
|
+
loadList(true);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* ── 启停 ── */
|
|
340
|
+
function startScript(id) {
|
|
341
|
+
if (BUSY[id]) return;
|
|
342
|
+
BUSY[id] = 'start';
|
|
343
|
+
renderDetail();
|
|
344
|
+
fetch(api('/desk/scripts/' + encodeURIComponent(id) + '/start'), { method: 'POST', headers: headers() })
|
|
345
|
+
.then(function (r) {
|
|
346
|
+
return r.json().then(function (d) {
|
|
347
|
+
if (!r.ok) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
348
|
+
return d;
|
|
349
|
+
});
|
|
350
|
+
})
|
|
351
|
+
.then(function () {
|
|
352
|
+
delete BUSY[id];
|
|
353
|
+
toast('已启动');
|
|
354
|
+
/* 重开一条订阅:上一条可能已经因为「脚本结束」被服务端收尾了,
|
|
355
|
+
也可能还挂着 —— 两种情况都要先中止,否则新订阅起不来。 */
|
|
356
|
+
abortStream(id);
|
|
357
|
+
LOG_SEQ[id] = 0;
|
|
358
|
+
LOG_TEXT[id] = '';
|
|
359
|
+
loadList(true);
|
|
360
|
+
renderDetail();
|
|
361
|
+
streamLog(id);
|
|
362
|
+
})
|
|
363
|
+
.catch(function (e) {
|
|
364
|
+
delete BUSY[id];
|
|
365
|
+
toast('启动失败:' + ((e && e.message) || e));
|
|
366
|
+
loadList(true);
|
|
367
|
+
renderDetail();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function stopScript(id) {
|
|
372
|
+
if (BUSY[id]) return;
|
|
373
|
+
BUSY[id] = 'stop';
|
|
374
|
+
renderDetail();
|
|
375
|
+
fetch(api('/desk/scripts/' + encodeURIComponent(id) + '/stop'), { method: 'POST', headers: headers() })
|
|
376
|
+
.then(function (r) {
|
|
377
|
+
return r.json().then(function (d) {
|
|
378
|
+
if (!r.ok) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
379
|
+
return d;
|
|
380
|
+
});
|
|
381
|
+
})
|
|
382
|
+
.then(function () {
|
|
383
|
+
delete BUSY[id];
|
|
384
|
+
toast('已停止');
|
|
385
|
+
loadList(true);
|
|
386
|
+
renderDetail();
|
|
387
|
+
})
|
|
388
|
+
.catch(function (e) {
|
|
389
|
+
delete BUSY[id];
|
|
390
|
+
toast('停止失败:' + ((e && e.message) || e));
|
|
391
|
+
loadList(true);
|
|
392
|
+
renderDetail();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function removeScript(it) {
|
|
397
|
+
if (!window.confirm('删除脚本「' + (it.name || baseName(it.path)) + '」?配置与日志都会被删掉。')) return;
|
|
398
|
+
fetch(api('/desk/scripts/' + encodeURIComponent(it.id)), { method: 'DELETE', headers: headers() })
|
|
399
|
+
.then(function (r) {
|
|
400
|
+
return r.json().then(function (d) {
|
|
401
|
+
if (!r.ok) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
402
|
+
return d;
|
|
403
|
+
});
|
|
404
|
+
})
|
|
405
|
+
.then(function () {
|
|
406
|
+
toast('已删除');
|
|
407
|
+
delete LOG_TEXT[it.id];
|
|
408
|
+
delete LOG_SEQ[it.id];
|
|
409
|
+
backToList();
|
|
410
|
+
})
|
|
411
|
+
.catch(function (e) { toast('删除失败:' + ((e && e.message) || e)); });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* ── 新增 / 编辑表单(宿主抽屉)──
|
|
415
|
+
⚠️ 不用 window.prompt:悬浮球与 Android WebView 都没实现它,调用只会静默返回 null
|
|
416
|
+
(表现就是「点了没反应」,见 index.html 的同款说明)。 */
|
|
417
|
+
function openForm(it) {
|
|
418
|
+
var editing = !!(it && it.id);
|
|
419
|
+
var def = it || { name: '', path: '', args: [], cwd: '', interpreter: '', env: [], note: '' };
|
|
420
|
+
H.openSheet(editing ? '编辑脚本' : '添加脚本', function (body) {
|
|
421
|
+
var fields = [
|
|
422
|
+
{ key: 'name', label: '名称', value: def.name || '', placeholder: '留空 = 用文件名' },
|
|
423
|
+
{ key: 'path', label: '脚本文件路径(绝对路径)', value: def.path || '', placeholder: 'D:\\tools\\build.cmd' },
|
|
424
|
+
{ key: 'interpreter', label: '解释器(可空)', value: def.interpreter || '', placeholder: '如 C:\\Python313\\python.exe' },
|
|
425
|
+
{ key: 'args', label: '参数(空格分隔)', value: (def.args || []).join(' '), placeholder: '可选,例如 --port 8080' },
|
|
426
|
+
{ key: 'env', label: '环境变量(每行一个 KEY=VALUE)', value: (def.env || []).join('\n'), placeholder: '可选,例如 PYTHONUNBUFFERED=1' },
|
|
427
|
+
{ key: 'cwd', label: '工作目录(可空)', value: def.cwd || '', placeholder: '留空 = 脚本所在目录' },
|
|
428
|
+
{ key: 'note', label: '备注(可空)', value: def.note || '', placeholder: '给自己看的说明' },
|
|
429
|
+
];
|
|
430
|
+
var err = hEl('div', 'field__err'); err.hidden = true;
|
|
431
|
+
var inputs = {};
|
|
432
|
+
fields.forEach(function (f) {
|
|
433
|
+
var box = hEl('div', 'field');
|
|
434
|
+
var lab = hEl('label', '', f.label);
|
|
435
|
+
box.appendChild(lab);
|
|
436
|
+
/* 环境变量是多行的(一条一行填),其余单行。 */
|
|
437
|
+
if (f.key === 'env') {
|
|
438
|
+
var ta = document.createElement('textarea');
|
|
439
|
+
ta.value = f.value;
|
|
440
|
+
ta.placeholder = f.placeholder;
|
|
441
|
+
ta.setAttribute('autocapitalize', 'off');
|
|
442
|
+
ta.setAttribute('autocorrect', 'off');
|
|
443
|
+
ta.setAttribute('spellcheck', 'false');
|
|
444
|
+
box.appendChild(ta);
|
|
445
|
+
inputs[f.key] = ta;
|
|
446
|
+
} else {
|
|
447
|
+
var inp = document.createElement('input');
|
|
448
|
+
inp.type = 'text';
|
|
449
|
+
inp.value = f.value;
|
|
450
|
+
if (f.placeholder) inp.placeholder = f.placeholder;
|
|
451
|
+
inp.setAttribute('autocapitalize', 'off');
|
|
452
|
+
inp.setAttribute('autocorrect', 'off');
|
|
453
|
+
inp.setAttribute('spellcheck', 'false');
|
|
454
|
+
box.appendChild(inp);
|
|
455
|
+
inputs[f.key] = inp;
|
|
456
|
+
}
|
|
457
|
+
body.appendChild(box);
|
|
458
|
+
});
|
|
459
|
+
body.appendChild(err);
|
|
460
|
+
var ok = hEl('button', 'btn btn--filled', editing ? '保存' : '添加');
|
|
461
|
+
ok.type = 'button';
|
|
462
|
+
body.appendChild(ok);
|
|
463
|
+
|
|
464
|
+
function commit() {
|
|
465
|
+
var path = String(inputs.path.value || '').trim();
|
|
466
|
+
if (!path) {
|
|
467
|
+
err.textContent = '要填脚本文件的绝对路径';
|
|
468
|
+
err.hidden = false;
|
|
469
|
+
inputs.path.focus();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
var payload = buildPayload(inputs);
|
|
473
|
+
var req = editing
|
|
474
|
+
? fetch(api('/desk/scripts/' + encodeURIComponent(it.id)), {
|
|
475
|
+
method: 'PUT', headers: jsonHeaders(), body: JSON.stringify(payload) })
|
|
476
|
+
: fetch(api('/desk/scripts'), {
|
|
477
|
+
method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload) });
|
|
478
|
+
ok.disabled = true;
|
|
479
|
+
req.then(function (r) {
|
|
480
|
+
return r.json().then(function (d) {
|
|
481
|
+
if (!r.ok) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
482
|
+
return d;
|
|
483
|
+
});
|
|
484
|
+
})
|
|
485
|
+
.then(function (d) {
|
|
486
|
+
H.closeSheet();
|
|
487
|
+
toast(editing ? '已保存' : '已添加');
|
|
488
|
+
loadList(true);
|
|
489
|
+
if (DETAIL && DETAIL.id === d.id) renderDetail();
|
|
490
|
+
})
|
|
491
|
+
.catch(function (e) {
|
|
492
|
+
ok.disabled = false;
|
|
493
|
+
err.textContent = (e && e.message) || String(e);
|
|
494
|
+
err.hidden = false;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
ok.onclick = commit;
|
|
498
|
+
inputs.path.onkeydown = function (e) { if (e.key === 'Enter') { e.preventDefault(); commit(); } };
|
|
499
|
+
setTimeout(function () { try { inputs.path.focus(); } catch (e) {} }, 30);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* ── 挂载 ── */
|
|
504
|
+
function mount(host) {
|
|
505
|
+
H = host;
|
|
506
|
+
var root = H.rootEl, detail = H.detailEl;
|
|
507
|
+
if (!root || !detail) return;
|
|
508
|
+
root.innerHTML =
|
|
509
|
+
'<div class="history__top">' +
|
|
510
|
+
'<button class="topbar__icon" id="btnScriptsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
|
|
511
|
+
'<span class="history__title">脚本</span>' +
|
|
512
|
+
'<button class="history__disconnect" id="btnScriptsAdd" type="button">添加</button>' +
|
|
513
|
+
'</div>' +
|
|
514
|
+
'<div class="history__list" id="scriptList"></div>';
|
|
515
|
+
detail.innerHTML =
|
|
516
|
+
'<div class="history__top">' +
|
|
517
|
+
'<button class="topbar__icon" id="btnScriptDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
|
|
518
|
+
'<span class="history__title" id="scriptDetailTitle">脚本</span>' +
|
|
519
|
+
'</div>' +
|
|
520
|
+
'<div class="history__list" id="scriptDetailBody"></div>';
|
|
521
|
+
els.list = root.querySelector('#scriptList');
|
|
522
|
+
els.detailBody = detail.querySelector('#scriptDetailBody');
|
|
523
|
+
els.detailTitle = detail.querySelector('#scriptDetailTitle');
|
|
524
|
+
var back = root.querySelector('#btnScriptsBack');
|
|
525
|
+
if (back) back.onclick = function () { H.backToDiscover(); };
|
|
526
|
+
var add = root.querySelector('#btnScriptsAdd');
|
|
527
|
+
if (add) add.onclick = function () { openForm(null); };
|
|
528
|
+
var dback = detail.querySelector('#btnScriptDetailBack');
|
|
529
|
+
if (dback) dback.onclick = function () { backToList(); };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/* 进入「发现 → 脚本」:拉一次清单(只读,不启停任何东西)。 */
|
|
533
|
+
function show() {
|
|
534
|
+
DETAIL = null;
|
|
535
|
+
loadList(false);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
window.MagicScripts = { mount: mount, show: show };
|
|
539
|
+
})();
|