made-bridge 0.3.5 → 0.3.6
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/package.json +1 -1
- package/public/app.js +27 -2
- package/public/i18n-messages.js +10 -2
- package/public/styles.css +20 -0
- package/src/agent-sprites.mjs +74 -9
- package/src/agents/events.mjs +95 -1
- package/src/agents/shared.mjs +7 -2
- package/src/config.mjs +10 -4
- package/src/i18n.mjs +3 -2
- package/src/installed.mjs +18 -11
- package/src/jobs.mjs +66 -0
- package/src/plugins/enhance.mjs +1 -0
- package/src/plugins/hot-reload.mjs +64 -1
- package/src/plugins/registry.mjs +2 -1
- package/src/providers.mjs +4 -1
- package/src/server.mjs +9 -2
- package/src/sessions.mjs +1 -0
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -297,6 +297,28 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
|
|
|
297
297
|
showToast(error.message || t('app.065'), true);
|
|
298
298
|
} finally { state.busy.delete('rename-session'); render(); }
|
|
299
299
|
}
|
|
300
|
+
/** 工具调用轨迹(仅桥接器网页端展示,不下发设备):可折叠列表。 */
|
|
301
|
+
function toolTrace(job) {
|
|
302
|
+
const calls = Array.isArray(job.toolCalls) ? job.toolCalls.filter((call) => call && safeString(call.name)) : [];
|
|
303
|
+
if (!calls.length) return null;
|
|
304
|
+
const details = document.createElement('details');
|
|
305
|
+
details.className = 'tool-trace';
|
|
306
|
+
const summary = document.createElement('summary');
|
|
307
|
+
summary.textContent = t('app.231', { v0: calls.length });
|
|
308
|
+
details.append(summary);
|
|
309
|
+
const list = text('div', 'tool-trace-list');
|
|
310
|
+
for (const call of [...calls].reverse()) {
|
|
311
|
+
const line = text('div', `tool-trace-line ${call.state === 'completed' ? 'done' : 'active'}`);
|
|
312
|
+
line.append(
|
|
313
|
+
text('span', 'tool-trace-mark', call.state === 'completed' ? '✓' : '…'),
|
|
314
|
+
text('span', 'tool-trace-name', safeString(call.name)),
|
|
315
|
+
);
|
|
316
|
+
if (safeString(call.detail)) line.append(text('code', 'tool-trace-detail', safeString(call.detail)));
|
|
317
|
+
list.append(line);
|
|
318
|
+
}
|
|
319
|
+
details.append(list);
|
|
320
|
+
return details;
|
|
321
|
+
}
|
|
300
322
|
function renderJobs() {
|
|
301
323
|
if (!$('jobList')) return;
|
|
302
324
|
const list = $('jobList'); list.replaceChildren();
|
|
@@ -319,6 +341,7 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
|
|
|
319
341
|
card.append(top, text('div', 'job-instruction', t('app.073', { v0: (job.instruction || t('app.072')) })));
|
|
320
342
|
const answer = turnAnswer(job);
|
|
321
343
|
card.append(text('p', `job-summary turn-answer ${job.error && !job.retryPending ? 'error' : ''}`, t('app.077', { v0: (providerName(job.provider)), v1: (safeString(answer)) })));
|
|
344
|
+
const trace = toolTrace(job); if (trace) card.append(trace);
|
|
322
345
|
if (job.provider === 'codex' && job.retryPending) {
|
|
323
346
|
card.append(text('p', 'job-retry-notice', t('app.078')));
|
|
324
347
|
}
|
|
@@ -329,7 +352,7 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
|
|
|
329
352
|
if (actions.children.length) { const bottom = text('div', 'job-bottom'); bottom.append(actions); card.append(bottom); }
|
|
330
353
|
list.append(card);
|
|
331
354
|
}
|
|
332
|
-
/**
|
|
355
|
+
/** 历史会话抽屉:当前任务的全部指令与回复,从第一轮按时间往下排。数据来自 /api/state 的 jobs。 */
|
|
333
356
|
function renderHistory() {
|
|
334
357
|
const button = $('historyDrawerButton');
|
|
335
358
|
const overlay = $('historyDrawerOverlay');
|
|
@@ -346,18 +369,20 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
|
|
|
346
369
|
list.append(text('p', 'history-empty', t('app.229')));
|
|
347
370
|
return;
|
|
348
371
|
}
|
|
372
|
+
// /api/state 的 jobs 是新→旧,反转成第一轮在最上面,编号也按时间顺序。
|
|
349
373
|
[...jobs].reverse().forEach((job, index) => {
|
|
350
374
|
const info = jobStatus(job);
|
|
351
375
|
const turn = text('article', 'history-turn');
|
|
352
376
|
const top = text('div', 'history-turn-top');
|
|
353
377
|
top.append(
|
|
354
|
-
text('span', 'turn-number', t('app.228', { v0:
|
|
378
|
+
text('span', 'turn-number', t('app.228', { v0: index + 1 })),
|
|
355
379
|
text('span', 'history-turn-time', formatDateTime(job.createdAt)),
|
|
356
380
|
text('span', `job-status ${info.css}`, info.label),
|
|
357
381
|
);
|
|
358
382
|
turn.append(top, text('div', 'history-turn-instruction', job.instruction || t('app.072')));
|
|
359
383
|
const answer = turnAnswer(job);
|
|
360
384
|
if (answer) turn.append(text('p', `history-turn-answer ${job.error && !job.retryPending ? 'error' : ''}`, answer));
|
|
385
|
+
const trace = toolTrace(job); if (trace) turn.append(trace);
|
|
361
386
|
list.append(turn);
|
|
362
387
|
});
|
|
363
388
|
}
|
package/public/i18n-messages.js
CHANGED
|
@@ -905,8 +905,8 @@ export const messages = {
|
|
|
905
905
|
"en": "History"
|
|
906
906
|
},
|
|
907
907
|
"app.227": {
|
|
908
|
-
"zh-CN": "
|
|
909
|
-
"en": "Every instruction and reply of this task,
|
|
908
|
+
"zh-CN": "这个任务的全部指令与回复,第一轮在最上面。",
|
|
909
|
+
"en": "Every instruction and reply of this task, oldest first."
|
|
910
910
|
},
|
|
911
911
|
"app.228": {
|
|
912
912
|
"zh-CN": "第 {v0} 轮",
|
|
@@ -920,6 +920,14 @@ export const messages = {
|
|
|
920
920
|
"zh-CN": "关闭",
|
|
921
921
|
"en": "Close"
|
|
922
922
|
},
|
|
923
|
+
"app.231": {
|
|
924
|
+
"zh-CN": "工具调用({v0})",
|
|
925
|
+
"en": "Tool calls ({v0})"
|
|
926
|
+
},
|
|
927
|
+
"app.232": {
|
|
928
|
+
"zh-CN": "进行中",
|
|
929
|
+
"en": "running"
|
|
930
|
+
},
|
|
923
931
|
"tunnel.official.title": {
|
|
924
932
|
"zh-CN": "官方入口",
|
|
925
933
|
"en": "Official links"
|
package/public/styles.css
CHANGED
|
@@ -461,6 +461,26 @@
|
|
|
461
461
|
.history-turn-instruction { margin: 9px 0 0; font-size: var(--font-body); font-weight: 600; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
462
462
|
.history-turn-answer { margin: 7px 0 0; color: var(--muted); font-size: var(--font-body); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
463
463
|
.history-turn-answer.error { color: var(--red); }
|
|
464
|
+
|
|
465
|
+
/* 工具调用轨迹:仅桥接器网页端展示,可折叠;设备端不携带该数据 */
|
|
466
|
+
.tool-trace { margin: 2px 0 12px; border: 1px dashed var(--line); border-radius: 12px; background: var(--panel); }
|
|
467
|
+
.tool-trace summary { padding: 9px 13px; color: var(--muted); font-size: var(--font-small); font-weight: 700; cursor: pointer; user-select: none; list-style: none; display: flex; align-items: center; gap: 7px; }
|
|
468
|
+
.tool-trace summary::-webkit-details-marker { display: none; }
|
|
469
|
+
.tool-trace summary::after { content: '+'; margin-left: auto; color: var(--dim); font-size: 16px; }
|
|
470
|
+
.tool-trace[open] summary::after { content: '–'; }
|
|
471
|
+
.tool-trace summary:hover { color: var(--green-deep); }
|
|
472
|
+
.tool-trace-list { display: grid; gap: 6px; max-height: 320px; overflow-y: auto; padding: 4px 13px 12px; }
|
|
473
|
+
.tool-trace-line { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: var(--font-small); line-height: 1.5; }
|
|
474
|
+
.tool-trace-mark { flex: 0 0 auto; color: var(--green); font-weight: 700; }
|
|
475
|
+
.tool-trace-line.active .tool-trace-mark { color: var(--blue); animation: tool-trace-pulse 1.2s ease-in-out infinite; }
|
|
476
|
+
@keyframes tool-trace-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
|
477
|
+
.tool-trace-name { flex: 0 0 auto; color: var(--text); font-weight: 600; }
|
|
478
|
+
.tool-trace-detail { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px 8px; font-size: 13px; }
|
|
479
|
+
@media (max-width: 650px) {
|
|
480
|
+
.tool-trace-line { flex-wrap: wrap; }
|
|
481
|
+
.tool-trace-detail { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; }
|
|
482
|
+
}
|
|
483
|
+
|
|
464
484
|
@media (max-width: 650px) {
|
|
465
485
|
.history-head { padding: 18px 16px 13px; }
|
|
466
486
|
.history-list { padding: 14px 16px 24px; }
|
package/src/agent-sprites.mjs
CHANGED
|
@@ -52,24 +52,81 @@ function readManifestFile(dir) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 整帧状态动画表(可选)。素材明说「每个状态播哪几帧、多快、眨眼怎么插」,
|
|
57
|
+
* 固件照表播放,不推断素材结构。有这张表时 layers 可以省略。
|
|
58
|
+
* 返回归一化的 { [state]: { frames: [...], ms } } 或 null(没声明)。
|
|
59
|
+
*/
|
|
60
|
+
function validateFramesTable(raw) {
|
|
61
|
+
if (raw.frames === undefined) return null;
|
|
62
|
+
const table = raw.frames;
|
|
63
|
+
if (!table || typeof table !== 'object' || Array.isArray(table)) {
|
|
64
|
+
throw new SpriteError('manifest.frames 必须是对象');
|
|
65
|
+
}
|
|
66
|
+
const allowed = new Set([...MOOD_NAMES, 'blink']);
|
|
67
|
+
const out = {};
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (const [state, entry] of Object.entries(table)) {
|
|
70
|
+
if (!allowed.has(state)) throw new SpriteError(`frames 含未知状态:${state}`);
|
|
71
|
+
let files = null;
|
|
72
|
+
const extra = {};
|
|
73
|
+
if (Array.isArray(entry)) {
|
|
74
|
+
files = entry;
|
|
75
|
+
} else if (entry && typeof entry === 'object') {
|
|
76
|
+
const keys = Object.keys(entry);
|
|
77
|
+
if (keys.some((key) => !['frames', 'ms', 'period_ms', 'hold_ms'].includes(key))) {
|
|
78
|
+
throw new SpriteError(`frames.${state} 含不支持的字段`);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(entry.frames)) throw new SpriteError(`frames.${state}.frames 必须是数组`);
|
|
81
|
+
files = entry.frames;
|
|
82
|
+
if (entry.ms !== undefined && !isInt(entry.ms, 30, 2000)) throw new SpriteError(`frames.${state}.ms 范围 30–2000`);
|
|
83
|
+
if (entry.ms !== undefined) extra.ms = entry.ms;
|
|
84
|
+
if (entry.period_ms !== undefined && !isInt(entry.period_ms, 500, 60000)) throw new SpriteError(`frames.${state}.period_ms 范围 500–60000`);
|
|
85
|
+
if (entry.period_ms !== undefined) extra.period_ms = entry.period_ms;
|
|
86
|
+
if (entry.hold_ms !== undefined && !isInt(entry.hold_ms, 30, 2000)) throw new SpriteError(`frames.${state}.hold_ms 范围 30–2000`);
|
|
87
|
+
if (entry.hold_ms !== undefined) extra.hold_ms = entry.hold_ms;
|
|
88
|
+
} else {
|
|
89
|
+
throw new SpriteError(`frames.${state} 必须是数组或对象`);
|
|
90
|
+
}
|
|
91
|
+
if (files.length < 1 || files.length > MAX_FRAMES ||
|
|
92
|
+
files.some((file) => typeof file !== 'string' || !FILE_NAME.test(file))) {
|
|
93
|
+
throw new SpriteError(`frames.${state} 需要 1–${MAX_FRAMES} 个 PNG 文件名`);
|
|
94
|
+
}
|
|
95
|
+
total += files.length;
|
|
96
|
+
if (total > 12) throw new SpriteError('frames 总帧数超过 12(设备内存上限)');
|
|
97
|
+
out[state] = { frames: [...files], ...extra };
|
|
98
|
+
}
|
|
99
|
+
if (Object.keys(out).length < 1 || !out.idle) throw new SpriteError('frames 至少要声明 idle 轨道');
|
|
100
|
+
if (raw.frame_ms !== undefined && !isInt(raw.frame_ms, 30, 2000)) {
|
|
101
|
+
throw new SpriteError('manifest.frame_ms 范围 30–2000');
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
55
106
|
function validateManifest(raw) {
|
|
56
107
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpriteError('manifest 必须是对象');
|
|
57
|
-
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion', 'emotions', 'actions', 'commands'].includes(key))) {
|
|
108
|
+
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'frames', 'frame_ms', 'motion', 'emotions', 'actions', 'commands'].includes(key))) {
|
|
58
109
|
throw new SpriteError('manifest 包含不支持的字段');
|
|
59
110
|
}
|
|
60
111
|
if (!isInt(raw.version, 1, 2147483647)) throw new SpriteError('manifest.version 必须是正整数');
|
|
112
|
+
// 整帧状态动画(声明式):有 frames 表就不再需要 layers。固件只照表播放,
|
|
113
|
+
// 不去推断素材结构;老素材(layers 分层叠加)行为不变。
|
|
114
|
+
const frameTracks = validateFramesTable(raw);
|
|
61
115
|
const canvas = raw.canvas;
|
|
62
116
|
if (!canvas || typeof canvas !== 'object' || Array.isArray(canvas) ||
|
|
63
117
|
!isInt(canvas.w, 1, 512) || !isInt(canvas.h, 1, 512) ||
|
|
64
118
|
Object.keys(canvas).some((key) => !['w', 'h'].includes(key))) {
|
|
65
119
|
throw new SpriteError('manifest.canvas 必须是 {w, h}(各 1–512)');
|
|
66
120
|
}
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
// 整帧模式(frames 表)不需要 layers;分层模式 layers 必填。
|
|
122
|
+
if (!frameTracks) {
|
|
123
|
+
if (!Array.isArray(raw.layers) || raw.layers.length < 1 || raw.layers.length > MAX_LAYERS) {
|
|
124
|
+
throw new SpriteError(`manifest.layers 需要 1–${MAX_LAYERS} 层`);
|
|
125
|
+
}
|
|
69
126
|
}
|
|
70
127
|
const seen = new Set();
|
|
71
128
|
const referenced = new Set();
|
|
72
|
-
for (const layer of raw.layers) {
|
|
129
|
+
if (Array.isArray(raw.layers)) for (const layer of raw.layers) {
|
|
73
130
|
if (!layer || typeof layer !== 'object' || Array.isArray(layer)) throw new SpriteError('layers 条目必须是对象');
|
|
74
131
|
const keys = Object.keys(layer);
|
|
75
132
|
if (keys.some((key) => !['id', 'file', 'frames', 'x', 'y', 'z', 'blink', 'talk', 'alert'].includes(key))) {
|
|
@@ -225,6 +282,7 @@ function validateManifest(raw) {
|
|
|
225
282
|
return {
|
|
226
283
|
version: raw.version, canvas: canvas, layers: raw.layers, motion: raw.motion ?? null,
|
|
227
284
|
emotions: raw.emotions ?? null, actions: raw.actions ?? null, commands: raw.commands ?? null,
|
|
285
|
+
frames: frameTracks ?? null, frame_ms: raw.frame_ms ?? null,
|
|
228
286
|
};
|
|
229
287
|
}
|
|
230
288
|
|
|
@@ -269,11 +327,19 @@ export function readSprite(packageDir) {
|
|
|
269
327
|
if (action && typeof action.gif === 'string') gifReferencedHint.add(action.gif);
|
|
270
328
|
}
|
|
271
329
|
}
|
|
330
|
+
const declaredNames = new Set();
|
|
331
|
+
if (Array.isArray(manifest.layers)) {
|
|
332
|
+
for (const layer of manifest.layers) {
|
|
333
|
+
for (const file of layer.frames !== undefined ? layer.frames : [layer.file]) declaredNames.add(file);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (manifest.frames) {
|
|
337
|
+
for (const track of Object.values(manifest.frames)) for (const file of track.frames) declaredNames.add(file);
|
|
338
|
+
}
|
|
272
339
|
for (const name of fs.readdirSync(spriteDir)) {
|
|
273
340
|
const lower = name.toLowerCase();
|
|
274
341
|
if (lower.endsWith('.png')) {
|
|
275
|
-
const referenced =
|
|
276
|
-
(layer.frames !== undefined ? layer.frames : [layer.file]).includes(name));
|
|
342
|
+
const referenced = declaredNames.has(name);
|
|
277
343
|
if (!referenced) {
|
|
278
344
|
console.error(`[plugins] sprite 提示:${path.basename(spriteDir)}/${name} 未被 manifest 引用,不会被下发(建议删除旧素材省空间)`);
|
|
279
345
|
}
|
|
@@ -283,8 +349,7 @@ export function readSprite(packageDir) {
|
|
|
283
349
|
}
|
|
284
350
|
let total = 0;
|
|
285
351
|
const files = [];
|
|
286
|
-
for (const
|
|
287
|
-
for (const file of layer.frames !== undefined ? layer.frames : [layer.file]) {
|
|
352
|
+
for (const file of declaredNames) {
|
|
288
353
|
const target = path.join(spriteDir, file);
|
|
289
354
|
let bytes;
|
|
290
355
|
try {
|
|
@@ -297,7 +362,6 @@ export function readSprite(packageDir) {
|
|
|
297
362
|
if (total > SPRITE_MAX_TOTAL_BYTES) throw new SpriteError(`sprite/ 整包超过 512 KB`);
|
|
298
363
|
const data = fs.readFileSync(target);
|
|
299
364
|
files.push({ file, bytes, sha256: createHash('sha256').update(data).digest('hex') });
|
|
300
|
-
}
|
|
301
365
|
}
|
|
302
366
|
// GIF 动作引用的动画文件(多个动作共用同一文件只下发一份)
|
|
303
367
|
if (manifest.actions) {
|
|
@@ -321,6 +385,7 @@ export function readSprite(packageDir) {
|
|
|
321
385
|
const value = Object.freeze({
|
|
322
386
|
dir: spriteDir, version: manifest.version, canvas: manifest.canvas,
|
|
323
387
|
layers: manifest.layers, motion: manifest.motion, emotions: manifest.emotions,
|
|
388
|
+
frames: manifest.frames, frame_ms: manifest.frame_ms,
|
|
324
389
|
actions: manifest.actions, commands: manifest.commands,
|
|
325
390
|
files: Object.freeze(files.map((file) => Object.freeze(file))),
|
|
326
391
|
});
|
package/src/agents/events.mjs
CHANGED
|
@@ -36,19 +36,108 @@ export function streamActivity(event) {
|
|
|
36
36
|
return '';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// 工具调用捕获:从 CLI 原始 JSONL 事件里抽 {name, detail, state},只进桥接器
|
|
40
|
+
// 任务记录(网页端折叠展示),绝不混入设备进度文本。codex 的 item.* 事件与
|
|
41
|
+
// Claude 风格的 tool_call/assistant 事件都覆盖;认不出的形状安静返回 null。
|
|
42
|
+
const TOOL_DETAIL_LIMIT = 600;
|
|
43
|
+
|
|
44
|
+
function truncate(value, limit = TOOL_DETAIL_LIMIT) {
|
|
45
|
+
const text = String(value ?? '').trim();
|
|
46
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…(截断)`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toolDetail(parts) {
|
|
50
|
+
return truncate(parts.filter((part) => String(part || '').trim()).join(' '));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toolCallFromEvent(event) {
|
|
54
|
+
if (event.type === 'tool_call' && event.tool_call && typeof event.tool_call === 'object') {
|
|
55
|
+
const name = Object.keys(event.tool_call)[0] || '';
|
|
56
|
+
const args = event.tool_call[name];
|
|
57
|
+
return {
|
|
58
|
+
name: truncate(name, 120),
|
|
59
|
+
detail: toolDetail([name, typeof args === 'string' ? args : JSON.stringify(args)]),
|
|
60
|
+
state: event.subtype === 'completed' ? 'completed' : 'started',
|
|
61
|
+
key: '',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (event.type === 'stream_event' && event.event?.type === 'content_block_start' &&
|
|
65
|
+
event.event.content_block?.type === 'tool_use') {
|
|
66
|
+
return {
|
|
67
|
+
name: truncate(event.event.content_block.name, 120),
|
|
68
|
+
detail: toolDetail([event.event.content_block.name,
|
|
69
|
+
JSON.stringify(event.event.content_block.input ?? '')]),
|
|
70
|
+
state: 'started', key: String(event.event.index ?? ''),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
|
|
74
|
+
const block = event.message.content.find((part) => part?.type === 'tool_use');
|
|
75
|
+
if (!block) return null;
|
|
76
|
+
return {
|
|
77
|
+
name: truncate(block.name, 120),
|
|
78
|
+
detail: toolDetail([block.name, JSON.stringify(block.input ?? '')]),
|
|
79
|
+
state: 'started', key: String(block.id ?? ''),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (event.type !== 'item.started' && event.type !== 'item.completed') return null;
|
|
83
|
+
const item = event.item;
|
|
84
|
+
if (!item || typeof item !== 'object') return null;
|
|
85
|
+
const completed = event.type === 'item.completed';
|
|
86
|
+
switch (item.type) {
|
|
87
|
+
case 'command_execution':
|
|
88
|
+
return { name: '命令执行', detail: toolDetail([item.command, item.cwd]), state: completed ? 'completed' : 'started', key: String(item.id ?? '') };
|
|
89
|
+
case 'file_change':
|
|
90
|
+
return {
|
|
91
|
+
name: '文件修改',
|
|
92
|
+
detail: toolDetail(Array.isArray(item.changes)
|
|
93
|
+
? item.changes.map((change) => `${change?.path || ''}${change?.kind ? ` (${change.kind})` : ''}`)
|
|
94
|
+
: []),
|
|
95
|
+
state: completed ? 'completed' : 'started', key: String(item.id ?? ''),
|
|
96
|
+
};
|
|
97
|
+
case 'mcp_tool_call': case 'collab_tool_call':
|
|
98
|
+
return { name: truncate(item.tool || '工具调用', 120), detail: toolDetail([item.tool, JSON.stringify(item.arguments ?? '')]), state: completed ? 'completed' : 'started', key: String(item.id ?? '') };
|
|
99
|
+
case 'web_search':
|
|
100
|
+
return { name: '网页搜索', detail: toolDetail([item.query]), state: completed ? 'completed' : 'started', key: String(item.id ?? '') };
|
|
101
|
+
case 'reasoning': case 'agent_message': case 'todo_list': case 'error':
|
|
102
|
+
return null;
|
|
103
|
+
default:
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
39
108
|
// Only categorical activity is emitted by builtin adapters; raw tool arguments
|
|
40
|
-
// and reasoning never become the device progress text.
|
|
109
|
+
// and reasoning never become the device progress text. Tool calls are drained
|
|
110
|
+
// separately (takeToolCalls) for the bridge UI only.
|
|
41
111
|
export function jsonLineEvents(interpret, onProgress = () => {}, onSession = () => {}) {
|
|
42
112
|
let pending = '';
|
|
43
113
|
let rawTail = '';
|
|
44
114
|
let discardingOversized = false;
|
|
45
115
|
let sawEvent = false;
|
|
46
116
|
const state = { lastMessage: '', assistantDeltas: '', finalResult: '', terminalError: '' };
|
|
117
|
+
// 半开工具调用:key → 在 calls 里的引用。completed 事件来时把对应条目置为
|
|
118
|
+
// 完成;对不上号(CLI 没给 id 或乱序)就当作独立条目追加,宁可多一行。
|
|
119
|
+
const open = new Map();
|
|
120
|
+
const calls = [];
|
|
121
|
+
const record = (call) => {
|
|
122
|
+
if (!call || !call.name) return;
|
|
123
|
+
if (call.state === 'completed' && call.key && open.has(call.key)) {
|
|
124
|
+
const entry = open.get(call.key);
|
|
125
|
+
entry.state = 'completed';
|
|
126
|
+
if (call.detail && call.detail.length > entry.detail.length) entry.detail = call.detail;
|
|
127
|
+
open.delete(call.key);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const entry = { name: call.name, detail: call.detail || '', state: call.state, at: new Date().toISOString() };
|
|
131
|
+
calls.push(entry);
|
|
132
|
+
if (calls.length > 80) calls.splice(0, calls.length - 80);
|
|
133
|
+
if (entry.state === 'started' && call.key) open.set(call.key, entry);
|
|
134
|
+
};
|
|
47
135
|
const consume = (line) => {
|
|
48
136
|
let event;
|
|
49
137
|
try { event = JSON.parse(line); } catch { return; }
|
|
50
138
|
if (!event || typeof event !== 'object' || typeof event.type !== 'string') return;
|
|
51
139
|
sawEvent = true;
|
|
140
|
+
record(toolCallFromEvent(event));
|
|
52
141
|
const update = interpret(event, state) || {};
|
|
53
142
|
if (typeof update.sessionId === 'string') onSession(update.sessionId);
|
|
54
143
|
if (update.activity) onProgress(update.activity);
|
|
@@ -69,12 +158,17 @@ export function jsonLineEvents(interpret, onProgress = () => {}, onSession = ()
|
|
|
69
158
|
},
|
|
70
159
|
finish() {
|
|
71
160
|
if (!discardingOversized && pending.trim()) consume(pending);
|
|
161
|
+
for (const entry of open.values()) entry.state = 'completed';
|
|
162
|
+
open.clear();
|
|
72
163
|
return {
|
|
73
164
|
result: (state.finalResult || state.lastMessage || (sawEvent ? '任务已执行,未返回文字摘要。' : rawTail.trim())).slice(-8000),
|
|
74
165
|
hasAssistantText: Boolean(state.finalResult || state.lastMessage || !sawEvent),
|
|
75
166
|
error: state.terminalError, rawTail,
|
|
76
167
|
};
|
|
77
168
|
},
|
|
169
|
+
takeToolCalls() {
|
|
170
|
+
return calls.splice(0, calls.length);
|
|
171
|
+
},
|
|
78
172
|
};
|
|
79
173
|
}
|
|
80
174
|
|
package/src/agents/shared.mjs
CHANGED
|
@@ -109,7 +109,7 @@ function changeFooter(state, message) {
|
|
|
109
109
|
return state.changed ? `${message('改动文件:')}\n${state.changed}` : message('没有检测到代码改动。');
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
export function runCli({ job, project, signal, onProgress, onSession, config, model }, adapter) {
|
|
112
|
+
export function runCli({ job, project, signal, onProgress, onSession, onTool, config, model }, adapter) {
|
|
113
113
|
const startingGitSnapshot = repositorySnapshot(project.path);
|
|
114
114
|
const message = (value) => translateKnownMessage(value, config.locale);
|
|
115
115
|
// 每轮都拼上权限边界:用户原文一字不改,边界文案按界面语言翻译。
|
|
@@ -139,8 +139,10 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
|
|
|
139
139
|
let timer;
|
|
140
140
|
// 卡死看门狗:编程工具进程连续 idleMs 没有任何输出(进度、流式、stderr 都算)
|
|
141
141
|
// 视为卡死,杀掉进程树并把错误标记为可自动重跑,由任务车道决定是否重试。
|
|
142
|
+
// 默认跟 config.mjs 对齐到 15 分钟:CLI 工具的 stdout/stderr 都算活动,但素材
|
|
143
|
+
// 管线里外部生图是异步轮询,单张上限 300s 且期间不吐字,5 分钟会误杀。
|
|
142
144
|
const idleMs = Number(config?.stuckIdleMs)
|
|
143
|
-
|| Math.max(1, Number(config?.stuckIdleMinutes) ||
|
|
145
|
+
|| Math.max(1, Number(config?.stuckIdleMinutes) || 15) * 60 * 1000;
|
|
144
146
|
let idleTimer;
|
|
145
147
|
const bumpIdle = () => {
|
|
146
148
|
clearTimeout(idleTimer);
|
|
@@ -189,6 +191,9 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
|
|
|
189
191
|
child.stdout.on('data', (chunk) => {
|
|
190
192
|
bumpIdle();
|
|
191
193
|
events.write(chunk);
|
|
194
|
+
// 工具调用只走 onTool(桥接器任务记录,网页端折叠展示),不进设备进度文本。
|
|
195
|
+
const forwarded = events.takeToolCalls?.() || [];
|
|
196
|
+
for (const call of forwarded) onTool?.(call);
|
|
192
197
|
});
|
|
193
198
|
child.stderr.on('data', (chunk) => {
|
|
194
199
|
bumpIdle();
|
package/src/config.mjs
CHANGED
|
@@ -128,9 +128,11 @@ export function loadConfig(env = process.env) {
|
|
|
128
128
|
};
|
|
129
129
|
});
|
|
130
130
|
const defaultProject = raw.defaultProject || projects[0].id;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
// 默认编程助手只是可选偏好(未指定 provider 时兜底),不再是必须存在的"主力":
|
|
132
|
+
// 可以留空——没有助手享有特权,入口处没指定就明确报错让用户选。
|
|
133
|
+
const defaultProvider = typeof raw.defaultProvider === 'string' && PLUGIN_ID.test(raw.defaultProvider) ? raw.defaultProvider : '';
|
|
134
|
+
if (!seen.has(defaultProject)) {
|
|
135
|
+
throw new Error('默认项目不在配置中');
|
|
134
136
|
}
|
|
135
137
|
const port = Number(raw.port || 8787);
|
|
136
138
|
const devicePort = Number(raw.devicePort || 8788);
|
|
@@ -147,7 +149,11 @@ export function loadConfig(env = process.env) {
|
|
|
147
149
|
}
|
|
148
150
|
// 卡死自愈:任务连续 N 分钟没有任何输出(进度/流式/stderr 都算)视为卡死,
|
|
149
151
|
// 桥接器杀掉编程工具进程并自动重跑,最多 maxAutoRetries 次。
|
|
150
|
-
|
|
152
|
+
// 默认 15 分钟而不是 5 分钟:素材管线里一次外部生图是「提交异步任务 + 每 6s
|
|
153
|
+
// 轮询」,单张最长 300s(body 240s + sheet 300s ≈ 9 分钟),且轮询期间工具
|
|
154
|
+
// 不吐字。5 分钟会把一条正在正常跑的长任务当成卡死杀掉(2026-10-24 雪球
|
|
155
|
+
// jojo 任务被自己的看门狗反复打断实锤)。
|
|
156
|
+
const stuckIdleMinutes = Number(env.VIBE_STUCK_IDLE_MINUTES ?? raw.stuckIdleMinutes ?? 15);
|
|
151
157
|
if (!Number.isInteger(stuckIdleMinutes) || stuckIdleMinutes < 1 || stuckIdleMinutes > 60) {
|
|
152
158
|
throw new Error('卡死判定时长必须是 1 到 60 之间的整数(分钟)');
|
|
153
159
|
}
|
package/src/i18n.mjs
CHANGED
|
@@ -59,8 +59,9 @@ const messages = new Map([
|
|
|
59
59
|
['插件状态检查必须同步返回', 'The plugin status check must return synchronously'],
|
|
60
60
|
['任务已取消', 'Task cancelled'],
|
|
61
61
|
['默认编程助手未安装或已禁用', 'The default assistant is not installed or is disabled'],
|
|
62
|
-
['
|
|
63
|
-
['
|
|
62
|
+
['请先选择编程助手', 'Choose an assistant first'],
|
|
63
|
+
['请先在电脑桥接器里选择编程助手', 'Choose an assistant in the desktop bridge first'],
|
|
64
|
+
['请指定编程助手(createTask 需要显式 agentId)', 'Specify an assistant (createTask requires an explicit agentId)'],
|
|
64
65
|
['配对请求格式无效', 'Invalid pairing request'],
|
|
65
66
|
['设备名称无效', 'Invalid device name'],
|
|
66
67
|
['电脑配对窗口未开启', 'Pairing is not open on the computer'],
|
package/src/installed.mjs
CHANGED
|
@@ -189,11 +189,12 @@ export async function setPluginEnabled(config, id, enabled) {
|
|
|
189
189
|
if (!PLUGIN_ID.test(id || '')) throw fail('插件不存在');
|
|
190
190
|
const installed = (await listInstalled(config)).plugins;
|
|
191
191
|
if (!installed.some((plugin) => plugin.id === id)) throw fail('插件不存在');
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (!enabled && id ===
|
|
196
|
-
|
|
192
|
+
// 默认助手只是可选偏好,不再是"主力":停用它时直接清掉默认指向,不拦人。
|
|
193
|
+
const previousDefault = config.defaultProvider || '';
|
|
194
|
+
let defaultCleared = false;
|
|
195
|
+
if (!enabled && id === previousDefault) {
|
|
196
|
+
config.defaultProvider = '';
|
|
197
|
+
defaultCleared = true;
|
|
197
198
|
}
|
|
198
199
|
const previous = [...(config.disabledPlugins || [])];
|
|
199
200
|
const next = new Set(previous);
|
|
@@ -204,12 +205,17 @@ export async function setPluginEnabled(config, id, enabled) {
|
|
|
204
205
|
await reload(config);
|
|
205
206
|
} catch (error) {
|
|
206
207
|
config.disabledPlugins = previous;
|
|
208
|
+
if (defaultCleared) config.defaultProvider = previousDefault;
|
|
207
209
|
throw error;
|
|
208
210
|
}
|
|
209
211
|
try {
|
|
210
|
-
writeConfig(config, (raw) => {
|
|
212
|
+
writeConfig(config, (raw) => {
|
|
213
|
+
raw.disabledPlugins = config.disabledPlugins;
|
|
214
|
+
if (defaultCleared) raw.defaultProvider = '';
|
|
215
|
+
});
|
|
211
216
|
} catch (error) {
|
|
212
217
|
config.disabledPlugins = previous;
|
|
218
|
+
if (defaultCleared) config.defaultProvider = previousDefault;
|
|
213
219
|
await reload(config);
|
|
214
220
|
throw error;
|
|
215
221
|
}
|
|
@@ -252,11 +258,10 @@ export async function removePlugin(config, { id = '', file = '' } = {}) {
|
|
|
252
258
|
target = removalTarget(config, target);
|
|
253
259
|
const described = await describeModule(target, config);
|
|
254
260
|
const ids = described.map((plugin) => plugin.id).filter((item) => PLUGIN_ID.test(item));
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
261
|
+
// 默认助手只是可选偏好,不再是"主力":删的就是默认助手时清掉指向,不拦人。
|
|
262
|
+
const previousDefault = config.defaultProvider || '';
|
|
263
|
+
const removesDefault = ids.includes(previousDefault);
|
|
264
|
+
if (removesDefault) config.defaultProvider = '';
|
|
260
265
|
const previousDisabled = [...(config.disabledPlugins || [])];
|
|
261
266
|
const previousPlugins = [...(config.plugins || [])];
|
|
262
267
|
config.disabledPlugins = [...new Set([...previousDisabled, ...ids])];
|
|
@@ -266,10 +271,12 @@ export async function removePlugin(config, { id = '', file = '' } = {}) {
|
|
|
266
271
|
writeConfig(config, (raw) => {
|
|
267
272
|
raw.disabledPlugins = config.disabledPlugins;
|
|
268
273
|
if (Array.isArray(raw.plugins)) raw.plugins = config.plugins;
|
|
274
|
+
if (removesDefault) raw.defaultProvider = '';
|
|
269
275
|
});
|
|
270
276
|
} catch (error) {
|
|
271
277
|
config.disabledPlugins = previousDisabled;
|
|
272
278
|
config.plugins = previousPlugins;
|
|
279
|
+
if (removesDefault) config.defaultProvider = previousDefault;
|
|
273
280
|
await reload(config).catch(() => {});
|
|
274
281
|
throw error;
|
|
275
282
|
}
|
package/src/jobs.mjs
CHANGED
|
@@ -383,6 +383,34 @@ export class JobStore {
|
|
|
383
383
|
job.updatedAt = now();
|
|
384
384
|
this.save();
|
|
385
385
|
this.hooks?.notify('started', job);
|
|
386
|
+
// —— 插件型助手的卡死看门狗 ——
|
|
387
|
+
// CLI 型插件由 agents/shared.mjs 的子进程 idle 看门狗覆盖;插件型(自带
|
|
388
|
+
// run() 与工具环的助手)此前完全没有超时:模型请求/工具调用不返回就永久
|
|
389
|
+
// 挂着,任务永远停在 running(2026-10-24 雪球 jojo 任务挂 15 分钟零输出
|
|
390
|
+
// 零产物实锤)。这里按「多久没有任何进展」判定,中止后交给车道自动重跑。
|
|
391
|
+
// 阈值必须大于素材管线的合法最长静默时长:外部生图是异步任务轮询,单张
|
|
392
|
+
// 上限 300s(body 240s + sheet 300s ≈ 9 分钟),期间完全没有文本进度。
|
|
393
|
+
// 默认 15 分钟(见 config.mjs)。
|
|
394
|
+
let lastActivity = Date.now();
|
|
395
|
+
const touchActivity = () => { lastActivity = Date.now(); };
|
|
396
|
+
const stallMs = Math.max(1, Number(this.stuckIdleMinutes) || 15) * 60_000;
|
|
397
|
+
let stalled = false;
|
|
398
|
+
const stallTimer = setInterval(() => {
|
|
399
|
+
if (job.status !== 'running') return;
|
|
400
|
+
const idle = Date.now() - lastActivity;
|
|
401
|
+
if (idle < stallMs) return;
|
|
402
|
+
stalled = true;
|
|
403
|
+
controller.abort(new Error(`任务 ${Math.round(idle / 60_000)} 分钟没有任何进展,疑似卡死`));
|
|
404
|
+
}, 15_000);
|
|
405
|
+
if (stallTimer.unref) stallTimer.unref();
|
|
406
|
+
// 单轮硬上限 30 分钟:素材管线一次从零生成(body + sheet)最坏约 10 分钟,
|
|
407
|
+
// 加上模型侧重试仍有余量。
|
|
408
|
+
const hardTimer = setTimeout(() => {
|
|
409
|
+
if (job.status !== 'running') return;
|
|
410
|
+
stalled = true;
|
|
411
|
+
controller.abort(new Error('任务单轮运行超过 30 分钟,已停止'));
|
|
412
|
+
}, 30 * 60_000);
|
|
413
|
+
if (hardTimer.unref) hardTimer.unref();
|
|
386
414
|
try {
|
|
387
415
|
this.ensureWorkspace(job.vibeSessionId);
|
|
388
416
|
const project = this.projectFor(job);
|
|
@@ -395,6 +423,7 @@ export class JobStore {
|
|
|
395
423
|
if (!progress || progress === job.progress) return;
|
|
396
424
|
job.progress = progress;
|
|
397
425
|
job.progressAt = now();
|
|
426
|
+
touchActivity();
|
|
398
427
|
job.updatedAt = job.progressAt;
|
|
399
428
|
const current = Date.now();
|
|
400
429
|
if (current - (this.progressSavedAt.get(job.id) || 0) >= 750) {
|
|
@@ -413,6 +442,34 @@ export class JobStore {
|
|
|
413
442
|
job.updatedAt = now();
|
|
414
443
|
this.save();
|
|
415
444
|
}
|
|
445
|
+
}, (tool) => {
|
|
446
|
+
// 工具调用轨迹:仅存任务记录给桥接器网页端折叠展示,设备端
|
|
447
|
+
// /device/tasks 是字段白名单,天然带不出去。
|
|
448
|
+
if (job.status !== 'running') return;
|
|
449
|
+
const name = String(tool?.name || '').trim();
|
|
450
|
+
if (!name) return;
|
|
451
|
+
const entry = {
|
|
452
|
+
name: name.slice(0, 120),
|
|
453
|
+
detail: String(tool?.detail || '').slice(0, 600),
|
|
454
|
+
state: tool?.state === 'completed' ? 'completed' : 'started',
|
|
455
|
+
at: now(),
|
|
456
|
+
};
|
|
457
|
+
const calls = Array.isArray(job.toolCalls) ? job.toolCalls : [];
|
|
458
|
+
// 同名同参数的 completed 覆盖前一条 started,避免每个工具占两行。
|
|
459
|
+
const previous = calls.length - 1 >= 0 ? calls[calls.length - 1] : null;
|
|
460
|
+
if (previous && previous.state === 'started' && previous.name === entry.name &&
|
|
461
|
+
previous.detail === entry.detail) {
|
|
462
|
+
previous.state = 'completed';
|
|
463
|
+
} else {
|
|
464
|
+
calls.push(entry);
|
|
465
|
+
}
|
|
466
|
+
job.toolCalls = calls.slice(-40);
|
|
467
|
+
touchActivity();
|
|
468
|
+
const current = Date.now();
|
|
469
|
+
if (current - (this.progressSavedAt.get(job.id) || 0) >= 750) {
|
|
470
|
+
this.progressSavedAt.set(job.id, current);
|
|
471
|
+
this.save();
|
|
472
|
+
}
|
|
416
473
|
});
|
|
417
474
|
if (job.status === 'running') {
|
|
418
475
|
if (!outcome || !['completed', 'handed_off'].includes(outcome.status || 'completed')) {
|
|
@@ -434,6 +491,13 @@ export class JobStore {
|
|
|
434
491
|
this.hooks?.notify(job.status, job);
|
|
435
492
|
}
|
|
436
493
|
} catch (error) {
|
|
494
|
+
if (stalled && job.status === 'running') {
|
|
495
|
+
// 看门狗中止(插件型助手):按 CLI 型同样的语义交给车道自动重跑。
|
|
496
|
+
error = Object.assign(
|
|
497
|
+
new Error(`任务 ${Math.round(stallMs / 60_000)} 分钟没有任何进展,疑似卡死:已终止`),
|
|
498
|
+
{ retryable: true, autoRetry: true },
|
|
499
|
+
);
|
|
500
|
+
}
|
|
437
501
|
if (job.status === 'running') {
|
|
438
502
|
// 卡死自愈:看门狗杀掉无输出的编程工具进程后,同车道自动重跑,
|
|
439
503
|
// 最多 maxAutoRetries 次;重跑的是同一条指令(native 会话会 resume 同一 CLI 线程)。
|
|
@@ -462,6 +526,8 @@ export class JobStore {
|
|
|
462
526
|
this.hooks?.notify('failed', job);
|
|
463
527
|
}
|
|
464
528
|
} finally {
|
|
529
|
+
clearInterval(stallTimer);
|
|
530
|
+
clearTimeout(hardTimer);
|
|
465
531
|
this.controllers.delete(job.id);
|
|
466
532
|
this.progressSavedAt.delete(job.id);
|
|
467
533
|
}
|
package/src/plugins/enhance.mjs
CHANGED
|
@@ -91,6 +91,7 @@ export function createEnhancement(config) {
|
|
|
91
91
|
if (!wired.jobs) throw new Error('桥接器尚未就绪');
|
|
92
92
|
createTaskGuard();
|
|
93
93
|
const provider = agentId || config.defaultProvider;
|
|
94
|
+
if (!provider) throw new Error('请指定编程助手(createTask 需要显式 agentId)');
|
|
94
95
|
const project = projectId || wired.jobs.defaultProject;
|
|
95
96
|
const session = wired.jobs.createSession({ provider, projectId: project, title });
|
|
96
97
|
const job = wired.jobs.submit({ instruction, sessionId: session.id, provider, projectId: project, source: 'plugin' });
|
|
@@ -92,6 +92,14 @@ export async function reloadConfiguredPlugins(config) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
const RELOAD_DELAY_MS = 300;
|
|
95
|
+
// 热加载单次超时:reloadConfiguredPlugins 一旦挂起(插件 import 死锁/网络
|
|
96
|
+
// 等待),reloading 会永远停在 true,之后所有变更只排队、不再加载也
|
|
97
|
+
// 不打日志——表现为「桥接器对素材改动完全无反应」(2026-10-24 实锤)。
|
|
98
|
+
// 超时后强制解锁并记账,下一轮变更还能再试。
|
|
99
|
+
const RELOAD_TIMEOUT_MS = 30000;
|
|
100
|
+
// fs.watch 在 macOS 长跑进程里会静默失效(事件流断掉,无错误、无回调)。
|
|
101
|
+
// 轮询兜底:按 mtime 快照比对,watch 哑了也能热加载。
|
|
102
|
+
const POLL_INTERVAL_MS = 3000;
|
|
95
103
|
|
|
96
104
|
function relevantChange(filename) {
|
|
97
105
|
if (!filename) return true;
|
|
@@ -137,6 +145,12 @@ export function watchPlugins(config, { onReload } = {}) {
|
|
|
137
145
|
if (closed) return;
|
|
138
146
|
if (reloading) { pending = true; return; }
|
|
139
147
|
reloading = true;
|
|
148
|
+
let timedOut = false;
|
|
149
|
+
const guard = setTimeout(() => {
|
|
150
|
+
timedOut = true;
|
|
151
|
+
console.error(`[plugins] 热加载超过 ${RELOAD_TIMEOUT_MS}ms 未返回,强制解锁`
|
|
152
|
+
+ '(保留上一份插件;下一轮变更会再试)');
|
|
153
|
+
}, RELOAD_TIMEOUT_MS);
|
|
140
154
|
try {
|
|
141
155
|
await reloadConfiguredPlugins(config);
|
|
142
156
|
const ids = config.pluginsRuntime?.agents?.().map((plugin) => plugin.id) ?? [];
|
|
@@ -146,7 +160,11 @@ export function watchPlugins(config, { onReload } = {}) {
|
|
|
146
160
|
console.error(`[plugins] 热加载失败,继续使用上一份:${error.message}`);
|
|
147
161
|
onReload?.(error);
|
|
148
162
|
} finally {
|
|
163
|
+
clearTimeout(guard);
|
|
149
164
|
reloading = false;
|
|
165
|
+
// 无论成败都刷新快照:否则轮询兜底会把同一次改动再触发一遍
|
|
166
|
+
// (fs.watch 与轮询双通道各来一次)。
|
|
167
|
+
snapshotMtimes();
|
|
150
168
|
if (pending && !closed) {
|
|
151
169
|
pending = false;
|
|
152
170
|
schedule();
|
|
@@ -173,13 +191,58 @@ export function watchPlugins(config, { onReload } = {}) {
|
|
|
173
191
|
arm(filename, false);
|
|
174
192
|
arm(dir, false);
|
|
175
193
|
}
|
|
176
|
-
|
|
194
|
+
// ---- 轮询兜底(fs.watch 静默失效时仍能热加载)----
|
|
195
|
+
const mtimes = new Map();
|
|
196
|
+
|
|
197
|
+
function collect(dir, depth, out) {
|
|
198
|
+
let entries;
|
|
199
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
if (depth <= 0 || entry.name === 'node_modules') continue;
|
|
203
|
+
collect(path.join(dir, entry.name), depth - 1, out);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!relevantChange(entry.name)) continue;
|
|
207
|
+
const full = path.join(dir, entry.name);
|
|
208
|
+
try { out.set(full, fs.statSync(full).mtimeMs); } catch { /* 文件刚被删。 */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function scan() {
|
|
213
|
+
const out = new Map();
|
|
214
|
+
for (const root of pluginWatchRoots(config)) collect(root, 4, out);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function snapshotMtimes() {
|
|
219
|
+
mtimes.clear();
|
|
220
|
+
for (const [file, ms] of scan()) mtimes.set(file, ms);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function poll() {
|
|
224
|
+
if (closed || reloading) return;
|
|
225
|
+
const current = scan();
|
|
226
|
+
let changed = false;
|
|
227
|
+
for (const [file, ms] of current) {
|
|
228
|
+
if (mtimes.get(file) !== ms) { changed = true; break; }
|
|
229
|
+
}
|
|
230
|
+
if (!changed && current.size !== mtimes.size) changed = true;
|
|
231
|
+
mtimes.clear();
|
|
232
|
+
for (const [file, ms] of current) mtimes.set(file, ms);
|
|
233
|
+
if (changed && mtimes.size) schedule();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
snapshotMtimes();
|
|
237
|
+
const pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
|
238
|
+
if (watchers.length) console.error('[plugins] 正在监视插件变更(含轮询兜底)');
|
|
177
239
|
|
|
178
240
|
return {
|
|
179
241
|
close() {
|
|
180
242
|
closed = true;
|
|
181
243
|
if (timer) clearTimeout(timer);
|
|
182
244
|
timer = null;
|
|
245
|
+
clearInterval(pollTimer);
|
|
183
246
|
for (const watcher of watchers) {
|
|
184
247
|
try { watcher.close(); } catch { /* 已经关掉。 */ }
|
|
185
248
|
}
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -156,6 +156,7 @@ export async function loadConfiguredPlugins(config) {
|
|
|
156
156
|
await loadFile(filename, label);
|
|
157
157
|
}
|
|
158
158
|
const registry = createPluginRegistry(plugins);
|
|
159
|
-
|
|
159
|
+
// 默认助手是可选偏好:配置了才校验存在性;留空表示没有兜底助手。
|
|
160
|
+
if (config.defaultProvider && !registry.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
|
|
160
161
|
return registry;
|
|
161
162
|
}
|
package/src/providers.mjs
CHANGED
|
@@ -60,7 +60,7 @@ export function providerStates(config, locale = config.locale) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export function createRunner(config) {
|
|
63
|
-
const runner = async (job, project, signal, onProgress = () => {}, onSession = () => {}) => {
|
|
63
|
+
const runner = async (job, project, signal, onProgress = () => {}, onSession = () => {}, onTool = () => {}) => {
|
|
64
64
|
const plugin = agentFor(config, job.provider);
|
|
65
65
|
if (!plugin) throw new Error('编程工具插件未加载,请先安装或启用对应插件');
|
|
66
66
|
if (signal.aborted) throw new Error('任务已取消');
|
|
@@ -71,6 +71,9 @@ export function createRunner(config) {
|
|
|
71
71
|
job, project, signal, config, model,
|
|
72
72
|
onProgress: plugin.capabilities.progress ? onProgress : () => {},
|
|
73
73
|
onSession: plugin.capabilities.session === 'native' ? onSession : () => {},
|
|
74
|
+
// 工具调用轨迹(仅桥接器网页端展示):CLI 型插件的 runCli 会自动消费,
|
|
75
|
+
// 不认识该字段的旧插件安全忽略。
|
|
76
|
+
onTool,
|
|
74
77
|
});
|
|
75
78
|
};
|
|
76
79
|
runner.agent = (id) => agentFor(config, id);
|
package/src/server.mjs
CHANGED
|
@@ -251,7 +251,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
251
251
|
const publicTunnel = (value = tunnel.summary()) => localizeStatus(value, config.locale);
|
|
252
252
|
if (!config.pluginsRuntime && (config.plugins?.length || config.disabledPlugins?.length)) throw new Error('请先 await loadConfiguredPlugins(config) 并赋值 config.pluginsRuntime');
|
|
253
253
|
config.pluginsRuntime ||= createPluginRegistry();
|
|
254
|
-
if (!config.pluginsRuntime.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
|
|
254
|
+
if (config.defaultProvider && !config.pluginsRuntime.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
|
|
255
255
|
if (!config.speech) initializeSpeechSettings(config);
|
|
256
256
|
config.models = { ...Object.fromEntries(config.pluginsRuntime.agents().filter(p => p.capabilities.model).map(p => [p.id, p.model?.default || ''])), ...(config.codexModel ? {codex: config.codexModel} : {}), ...config.models };
|
|
257
257
|
validateModelSettings(Object.fromEntries(Object.entries(publicModels(config)).filter(([, value]) => value !== null)), config.pluginsRuntime);
|
|
@@ -909,6 +909,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
909
909
|
}
|
|
910
910
|
if (req.method === 'POST' && url.pathname === '/device/sessions') {
|
|
911
911
|
const provider = url.searchParams.get('provider') || config.defaultProvider;
|
|
912
|
+
if (!provider) throw new Error('请先在电脑桥接器里选择编程助手');
|
|
912
913
|
const projectId = url.searchParams.get('projectId') || config.defaultProject;
|
|
913
914
|
if (!config.pluginsRuntime.hasAgent(provider)) throw new Error('未知编程工具');
|
|
914
915
|
const payload = await body(req);
|
|
@@ -966,6 +967,9 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
966
967
|
deviceSend(res, 200, {
|
|
967
968
|
version: plugin.sprite.version, canvas: plugin.sprite.canvas,
|
|
968
969
|
layers: plugin.sprite.layers, ...(plugin.sprite.motion ? { motion: plugin.sprite.motion } : {}),
|
|
970
|
+
// 整帧状态动画(声明式,固件照表播);老素材没有这两项,照旧走分层叠加
|
|
971
|
+
...(plugin.sprite.frames ? { frames: plugin.sprite.frames } : {}),
|
|
972
|
+
...(plugin.sprite.frame_ms ? { frame_ms: plugin.sprite.frame_ms } : {}),
|
|
969
973
|
...(plugin.sprite.emotions ? { emotions: plugin.sprite.emotions } : {}),
|
|
970
974
|
...(plugin.sprite.actions ? { actions: plugin.sprite.actions } : {}),
|
|
971
975
|
...(plugin.sprite.commands ? { commands: plugin.sprite.commands } : {}),
|
|
@@ -1045,7 +1049,10 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
1045
1049
|
}
|
|
1046
1050
|
const match = url.pathname.match(/^\/device\/tasks\/([a-z0-9-]+)\/(confirm|cancel)$/);
|
|
1047
1051
|
if (req.method === 'POST' && match) {
|
|
1048
|
-
|
|
1052
|
+
const confirmed = publicJob(match[2] === 'confirm' ? store.confirm(match[1]) : store.cancel(match[1]), deviceLocale);
|
|
1053
|
+
// 工具调用轨迹只给桥接器网页端,任何设备回包都不携带。
|
|
1054
|
+
const { toolCalls, ...deviceJob } = confirmed;
|
|
1055
|
+
deviceSend(res, 200, deviceJob);
|
|
1049
1056
|
return;
|
|
1050
1057
|
}
|
|
1051
1058
|
// 板端截屏回传:固件把当前画面以 RGB565 裸帧 POST 上来(tools/shot.mjs 触发)。
|
package/src/sessions.mjs
CHANGED
|
@@ -65,6 +65,7 @@ export class SessionStore {
|
|
|
65
65
|
|
|
66
66
|
create({ provider, projectId, title, legacy = false, cliSessionId = '' } = {}) {
|
|
67
67
|
const selectedProvider = provider || this.defaultProvider;
|
|
68
|
+
if (!selectedProvider) throw new Error('请先选择编程助手');
|
|
68
69
|
const selectedProject = projectId || this.defaultProject;
|
|
69
70
|
const plugin = this.agent(selectedProvider);
|
|
70
71
|
if (!plugin && !(legacy && PROVIDER_ID.test(selectedProvider))) throw new Error('不支持的编程工具');
|