made-bridge 0.2.3 → 0.3.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/package.json +1 -1
- package/public/app.js +85 -2
- package/public/i18n-static.js +23 -0
- package/public/index.html +16 -0
- package/src/agent-sprites.mjs +124 -9
- package/src/agents/events.mjs +4 -0
- package/src/agents/shared.mjs +8 -5
- package/src/agents/toolbox.mjs +15 -0
- package/src/board-link.mjs +13 -0
- package/src/config.mjs +8 -5
- package/src/firmware-store.mjs +166 -0
- package/src/installed.mjs +3 -2
- package/src/jobs.mjs +93 -7
- package/src/plugin-package.mjs +13 -3
- package/src/plugins/contracts.d.ts +44 -0
- package/src/plugins/enhance.mjs +5 -1
- package/src/plugins/hot-reload.mjs +189 -0
- package/src/plugins/registry.mjs +8 -4
- package/src/providers.mjs +27 -22
- package/src/server.mjs +106 -5
- package/src/sessions.mjs +1 -2
- package/src/usb-receiver.mjs +74 -26
- package/src/agents/codex.mjs +0 -94
- package/src/agents/cursor.mjs +0 -31
- package/src/agents/index.mjs +0 -7
- package/src/agents/qoder.mjs +0 -34
- package/src/agents/workbuddy.mjs +0 -121
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -861,7 +861,58 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
|
|
|
861
861
|
});
|
|
862
862
|
if (cards.length !== grid.children.length || cards.some((card, index) => grid.children[index] !== card)) grid.replaceChildren(...cards);
|
|
863
863
|
}
|
|
864
|
-
|
|
864
|
+
let firmwareBusy = false;
|
|
865
|
+
let firmwareView = null;
|
|
866
|
+
async function loadFirmware() {
|
|
867
|
+
if (!$('firmwareStatus')) return;
|
|
868
|
+
try {
|
|
869
|
+
firmwareView = await request('/api/firmware');
|
|
870
|
+
renderFirmware();
|
|
871
|
+
} catch (error) {
|
|
872
|
+
$('firmwareStatus').textContent = error.message || t('app.020');
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function renderFirmware() {
|
|
876
|
+
if (!$('firmwareList') || !firmwareView) return;
|
|
877
|
+
const current = firmwareView.current;
|
|
878
|
+
$('firmwareStatus').textContent = current
|
|
879
|
+
? `${current.version} · ${current.size} B`
|
|
880
|
+
: (firmwareView.otaUrl || t('static.161'));
|
|
881
|
+
const url = $('firmwareUrl');
|
|
882
|
+
if (url && document.activeElement !== url) url.value = firmwareView.otaUrl || '';
|
|
883
|
+
const list = $('firmwareList');
|
|
884
|
+
list.replaceChildren();
|
|
885
|
+
for (const item of firmwareView.items || []) {
|
|
886
|
+
const row = text('div', 'info-row');
|
|
887
|
+
row.append(text('span', 'info-name', item.current ? `${item.version} · ${t('static.162')}` : item.version));
|
|
888
|
+
const actions = text('span', 'info-value');
|
|
889
|
+
if (!item.current) {
|
|
890
|
+
const use = text('button', 'small-button', t('static.163'));
|
|
891
|
+
use.type = 'button';
|
|
892
|
+
use.addEventListener('click', () => firmwareAct({ action: 'current', id: item.id }, 'static.169'));
|
|
893
|
+
actions.append(use);
|
|
894
|
+
}
|
|
895
|
+
const del = text('button', 'small-button', t('static.164'));
|
|
896
|
+
del.type = 'button';
|
|
897
|
+
del.addEventListener('click', () => firmwareAct({ action: 'delete', id: item.id }, 'static.169'));
|
|
898
|
+
actions.append(del);
|
|
899
|
+
row.append(actions);
|
|
900
|
+
list.append(row);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async function firmwareAct(body, okKey) {
|
|
904
|
+
if (firmwareBusy) return;
|
|
905
|
+
firmwareBusy = true;
|
|
906
|
+
try {
|
|
907
|
+
const data = await post('/api/firmware', body);
|
|
908
|
+
firmwareView = data.firmware || data;
|
|
909
|
+
setUiMessage($('firmwareMessage'), m(okKey));
|
|
910
|
+
renderFirmware();
|
|
911
|
+
} catch (error) {
|
|
912
|
+
setUiMessage($('firmwareMessage'), error.message || '');
|
|
913
|
+
} finally { firmwareBusy = false; }
|
|
914
|
+
}
|
|
915
|
+
function render() { renderSelects(); renderSessions(); renderJobs(); renderBoard(); renderPairing(); renderTunnel(); renderReceiver(); renderFirmware(); renderConnection(); renderModelSettings(); renderVoiceSettings(); renderProviders(); }
|
|
865
916
|
function applyLanguage(locale) {
|
|
866
917
|
const changed = setLocale(locale);
|
|
867
918
|
if (!changed && document.documentElement.lang === locale) {
|
|
@@ -1024,5 +1075,37 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
|
|
|
1024
1075
|
});
|
|
1025
1076
|
applyStaticTranslations();
|
|
1026
1077
|
$('languageSelect').value = getLocale();
|
|
1027
|
-
|
|
1078
|
+
$('firmwareForm')?.addEventListener('submit', (event) => {
|
|
1079
|
+
event.preventDefault();
|
|
1080
|
+
firmwareAct({ action: 'url', otaUrl: $('firmwareUrl').value }, 'static.165');
|
|
1081
|
+
});
|
|
1082
|
+
$('firmwareSync')?.addEventListener('click', () => firmwareAct({ action: 'sync' }, 'static.166'));
|
|
1083
|
+
$('firmwareUseBridge')?.addEventListener('click', () => firmwareAct({ action: 'url', otaUrl: '/device/firmware/current' }, 'static.165'));
|
|
1084
|
+
$('firmwareUploadForm')?.addEventListener('submit', async (event) => {
|
|
1085
|
+
event.preventDefault();
|
|
1086
|
+
const file = $('firmwareFile')?.files?.[0];
|
|
1087
|
+
if (!file || firmwareBusy) return;
|
|
1088
|
+
firmwareBusy = true;
|
|
1089
|
+
try {
|
|
1090
|
+
const token = await sessionToken();
|
|
1091
|
+
const response = await fetch('/api/firmware', {
|
|
1092
|
+
method: 'POST',
|
|
1093
|
+
headers: {
|
|
1094
|
+
'Content-Type': 'application/octet-stream',
|
|
1095
|
+
'X-Vibe-Token': token,
|
|
1096
|
+
'X-Firmware-Version': $('firmwareVersion').value.trim(),
|
|
1097
|
+
},
|
|
1098
|
+
body: await file.arrayBuffer(),
|
|
1099
|
+
});
|
|
1100
|
+
const data = await response.json().catch(() => ({}));
|
|
1101
|
+
if (!response.ok) throw new Error(data.error || t('app.017', { v0: response.status }));
|
|
1102
|
+
firmwareView = data;
|
|
1103
|
+
$('firmwareFile').value = '';
|
|
1104
|
+
setUiMessage($('firmwareMessage'), m('static.168'));
|
|
1105
|
+
renderFirmware();
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
setUiMessage($('firmwareMessage'), error.message || '');
|
|
1108
|
+
} finally { firmwareBusy = false; }
|
|
1109
|
+
});
|
|
1110
|
+
render(); refresh(); loadFirmware(); setInterval(refresh, 4000);
|
|
1028
1111
|
})();
|
package/public/i18n-static.js
CHANGED
|
@@ -596,6 +596,29 @@ export const staticMessages = {
|
|
|
596
596
|
"zh-CN": "主题包都在这里管理。应用到码得后,设备解锁并连上电脑时才会换上这套外观。",
|
|
597
597
|
"en": "Theme packs are managed here. made applies one after it is unlocked and connected to this computer."
|
|
598
598
|
},
|
|
599
|
+
"static.150": { "zh-CN": "固件更新", "en": "Firmware update" },
|
|
600
|
+
"static.151": {
|
|
601
|
+
"zh-CN": "这个地址写在码得读取的配置里。码得拿到后只从该地址下载,桥接器不会把地址推下去。填本机路径时,码得从这台桥接器取下面管理的当前固件包。",
|
|
602
|
+
"en": "This address is part of the config made requests. made downloads only from that address. Use the local path to have made fetch the current package stored on this bridge."
|
|
603
|
+
},
|
|
604
|
+
"static.152": { "zh-CN": "正在读取固件…", "en": "Loading firmware…" },
|
|
605
|
+
"static.153": { "zh-CN": "固件地址", "en": "Firmware URL" },
|
|
606
|
+
"static.154": { "zh-CN": "保存地址", "en": "Save URL" },
|
|
607
|
+
"static.155": { "zh-CN": "拉取到本地", "en": "Download locally" },
|
|
608
|
+
"static.156": { "zh-CN": "使用本机地址", "en": "Use this bridge" },
|
|
609
|
+
"static.157": { "zh-CN": "本地版本号", "en": "Local version" },
|
|
610
|
+
"static.158": { "zh-CN": "固件文件", "en": "Firmware file" },
|
|
611
|
+
"static.159": { "zh-CN": "保存到本地", "en": "Save locally" },
|
|
612
|
+
"static.160": { "zh-CN": "已保存固件", "en": "Saved firmware" },
|
|
613
|
+
"static.161": { "zh-CN": "还没有本地固件。", "en": "No local firmware yet." },
|
|
614
|
+
"static.162": { "zh-CN": "当前", "en": "Current" },
|
|
615
|
+
"static.163": { "zh-CN": "设为当前", "en": "Make current" },
|
|
616
|
+
"static.164": { "zh-CN": "删除", "en": "Delete" },
|
|
617
|
+
"static.165": { "zh-CN": "地址已保存。", "en": "URL saved." },
|
|
618
|
+
"static.166": { "zh-CN": "已拉取到本地。", "en": "Downloaded locally." },
|
|
619
|
+
"static.167": { "zh-CN": "已通知码得更新。", "en": "made was told to update." },
|
|
620
|
+
"static.168": { "zh-CN": "固件已保存到本地。", "en": "Firmware saved locally." },
|
|
621
|
+
"static.169": { "zh-CN": "本地固件已更新。", "en": "Local firmware updated." },
|
|
599
622
|
"language.label": {
|
|
600
623
|
"zh-CN": "语言",
|
|
601
624
|
"en": "Language"
|
package/public/index.html
CHANGED
|
@@ -72,6 +72,22 @@
|
|
|
72
72
|
<p id="receiverHelp" class="panel-intro receiver-help"><span data-i18n="static.041">独立接收端无需屏幕:通过 USB 插入电脑后,这里自动显示热点名称和密码。在码得内打开“齿轮 → 连接 → 接收端”,扫描并点选热点,再输入这里显示的密码并保存;无需退出到桌面设置。新固件的热点密码为 8 位数字,六位配对码随后单独核对。码得直接用 USB 插电脑时,无需填写热点信息。</span></p>
|
|
73
73
|
</section>
|
|
74
74
|
|
|
75
|
+
<section id="firmware" class="panel info-panel" aria-labelledby="firmware-title">
|
|
76
|
+
<div class="panel-head"><div><h2 id="firmware-title"><span data-i18n="static.150">固件更新</span></h2><p class="panel-intro"><span data-i18n="static.151">这个地址写在码得读取的配置里。码得拿到后只从该地址下载,桥接器不会把地址推下去。填本机路径时,码得从这台桥接器取下面管理的当前固件包。</span></p></div></div>
|
|
77
|
+
<p id="firmwareStatus" class="pairing-state" role="status" aria-live="polite"><span data-i18n="static.152">正在读取固件…</span></p>
|
|
78
|
+
<form id="firmwareForm" class="voice-settings-form" autocomplete="off">
|
|
79
|
+
<div><label class="field-label" for="firmwareUrl"><span data-i18n="static.153">固件地址</span></label><input id="firmwareUrl" class="secret-input" type="text" inputmode="url" spellcheck="false" placeholder="https://example.com/made.bin"></div>
|
|
80
|
+
<div class="voice-settings-actions"><button id="firmwareSaveUrl" class="primary-button" type="submit"><span data-i18n="static.154">保存地址</span></button><button id="firmwareSync" class="small-button" type="button"><span data-i18n="static.155">拉取到本地</span></button><button id="firmwareUseBridge" class="small-button" type="button"><span data-i18n="static.156">使用本机地址</span></button></div>
|
|
81
|
+
</form>
|
|
82
|
+
<form id="firmwareUploadForm" class="voice-settings-form">
|
|
83
|
+
<div><label class="field-label" for="firmwareVersion"><span data-i18n="static.157">本地版本号</span></label><input id="firmwareVersion" class="secret-input" type="text" spellcheck="false" maxlength="32" placeholder="1.2.3"></div>
|
|
84
|
+
<div><label class="field-label" for="firmwareFile"><span data-i18n="static.158">固件文件</span></label><input id="firmwareFile" type="file" accept=".bin,application/octet-stream"></div>
|
|
85
|
+
<div class="voice-settings-actions"><button id="firmwareUpload" class="small-button" type="submit"><span data-i18n="static.159">保存到本地</span></button></div>
|
|
86
|
+
</form>
|
|
87
|
+
<div id="firmwareList" class="pairing-list" aria-label="已保存固件" data-i18n-aria-label="static.160"></div>
|
|
88
|
+
<p id="firmwareMessage" class="voice-settings-message" role="status" aria-live="polite"></p>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
75
91
|
</div>
|
|
76
92
|
<div class="stack">
|
|
77
93
|
<section id="voiceSettings" class="panel info-panel" aria-labelledby="voice-settings-title">
|
package/src/agent-sprites.mjs
CHANGED
|
@@ -17,10 +17,13 @@ export const SPRITE_MAX_CHUNK = 192 * 1024;
|
|
|
17
17
|
export const SPRITE_MAX_FILE_BYTES = 256 * 1024;
|
|
18
18
|
const MANIFEST_MAX_BYTES = 16 * 1024;
|
|
19
19
|
const MAX_LAYERS = 8;
|
|
20
|
-
const MAX_FRAMES =
|
|
20
|
+
const MAX_FRAMES = 8;
|
|
21
|
+
// 情绪表允许的状态名(与固件 Mood 枚举一一对应)。
|
|
22
|
+
const MOOD_NAMES = new Set(['idle', 'talking', 'running', 'waiting', 'success', 'sad', 'offline']);
|
|
21
23
|
// ≤20 字符:固件 SPIFFS 缓存对象名 = '/' + "s<hash6>_" + 文件名 + "~",
|
|
22
24
|
// 而 SPIFFS 只给 31 字节,20 是留了 1 字节余量的上限。
|
|
23
25
|
const FILE_NAME = /^[A-Za-z0-9_-]{1,16}\.png$/i;
|
|
26
|
+
const GIF_NAME = /^[A-Za-z0-9_-]{1,16}\.gif$/i;
|
|
24
27
|
const LAYER_ID = /^[A-Za-z][A-Za-z0-9_]{0,15}$/;
|
|
25
28
|
const RESERVED_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
26
29
|
|
|
@@ -51,7 +54,7 @@ function readManifestFile(dir) {
|
|
|
51
54
|
|
|
52
55
|
function validateManifest(raw) {
|
|
53
56
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpriteError('manifest 必须是对象');
|
|
54
|
-
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion'].includes(key))) {
|
|
57
|
+
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion', 'emotions', 'actions'].includes(key))) {
|
|
55
58
|
throw new SpriteError('manifest 包含不支持的字段');
|
|
56
59
|
}
|
|
57
60
|
if (!isInt(raw.version, 1, 2147483647)) throw new SpriteError('manifest.version 必须是正整数');
|
|
@@ -123,7 +126,88 @@ function validateManifest(raw) {
|
|
|
123
126
|
throw new SpriteError('motion.sway_period_ms 范围 200–10000');
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
|
-
|
|
129
|
+
if (raw.emotions !== undefined) {
|
|
130
|
+
const emotions = raw.emotions;
|
|
131
|
+
if (!emotions || typeof emotions !== 'object' || Array.isArray(emotions)) {
|
|
132
|
+
throw new SpriteError('emotions 必须是对象');
|
|
133
|
+
}
|
|
134
|
+
for (const [mood, entry] of Object.entries(emotions)) {
|
|
135
|
+
if (!MOOD_NAMES.has(mood)) throw new SpriteError(`emotions 含未知状态:${mood}`);
|
|
136
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) ||
|
|
137
|
+
Object.keys(entry).length < 1) {
|
|
138
|
+
throw new SpriteError(`emotions.${mood} 必须是非空对象(层id → 帧序号 | "cycle")`);
|
|
139
|
+
}
|
|
140
|
+
for (const [layerId, sel] of Object.entries(entry)) {
|
|
141
|
+
const layer = raw.layers.find((item) => item.id === layerId);
|
|
142
|
+
if (!layer) throw new SpriteError(`emotions.${mood} 引用未知层:${layerId}`);
|
|
143
|
+
const frameCount = (layer.frames !== undefined ? layer.frames : [layer.file]).length;
|
|
144
|
+
if (sel === 'cycle') continue;
|
|
145
|
+
if (!isInt(sel, 0, frameCount - 1)) {
|
|
146
|
+
throw new SpriteError(`emotions.${mood}.${layerId} 需要帧序号 0–${frameCount - 1} 或 "cycle"`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 行动系统:actions 表(可选)。两种动作形态二选一:
|
|
152
|
+
// 帧表动作 = { frame_ms, loops, tracks }(对既有层帧序的关键帧覆盖);
|
|
153
|
+
// GIF 动作 = { gif, ms }(整帧动画文件,播放 ms 毫秒)。
|
|
154
|
+
// 上限与固件一致:≤16 动作 / 每动作 ≤8 轨道 / 每轨道 ≤32 步。
|
|
155
|
+
const gifReferenced = new Set();
|
|
156
|
+
if (raw.actions !== undefined) {
|
|
157
|
+
const actions = raw.actions;
|
|
158
|
+
if (!actions || typeof actions !== 'object' || Array.isArray(actions)) {
|
|
159
|
+
throw new SpriteError('actions 必须是对象');
|
|
160
|
+
}
|
|
161
|
+
const names = Object.keys(actions);
|
|
162
|
+
if (names.length > 16) throw new SpriteError('actions 最多 16 个动作');
|
|
163
|
+
for (const [name, action] of Object.entries(actions)) {
|
|
164
|
+
if (!/^[a-z][a-z0-9_]{0,15}$/.test(name)) throw new SpriteError(`actions.${name} 名字须为小写字母开头的 [a-z0-9_]{0,15}`);
|
|
165
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
166
|
+
throw new SpriteError(`actions.${name} 必须是对象`);
|
|
167
|
+
}
|
|
168
|
+
if (action.gif !== undefined) {
|
|
169
|
+
// GIF 动作:整帧动画,ms 是播放时长(设备端流式解码,文件不分帧)
|
|
170
|
+
if (Object.keys(action).some((key) => !['gif', 'ms'].includes(key))) {
|
|
171
|
+
throw new SpriteError(`actions.${name} 包含不支持的字段(GIF 动作只支持 gif/ms)`);
|
|
172
|
+
}
|
|
173
|
+
if (typeof action.gif !== 'string' || !GIF_NAME.test(action.gif)) {
|
|
174
|
+
throw new SpriteError(`actions.${name}.gif 需要 1–16 位 [A-Za-z0-9_-] 的 .gif 文件名`);
|
|
175
|
+
}
|
|
176
|
+
if (action.ms !== undefined && !isInt(action.ms, 100, 10000)) {
|
|
177
|
+
throw new SpriteError(`actions.${name}.ms 范围 100–10000`);
|
|
178
|
+
}
|
|
179
|
+
gifReferenced.add(action.gif);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(action).some((key) => !['frame_ms', 'loops', 'tracks'].includes(key))) {
|
|
183
|
+
throw new SpriteError(`actions.${name} 包含不支持的字段`);
|
|
184
|
+
}
|
|
185
|
+
if (action.frame_ms !== undefined && !isInt(action.frame_ms, 30, 2000)) {
|
|
186
|
+
throw new SpriteError(`actions.${name}.frame_ms 范围 30–2000`);
|
|
187
|
+
}
|
|
188
|
+
if (action.loops !== undefined && !isInt(action.loops, 1, 10)) {
|
|
189
|
+
throw new SpriteError(`actions.${name}.loops 范围 1–10`);
|
|
190
|
+
}
|
|
191
|
+
const tracks = action.tracks;
|
|
192
|
+
if (!tracks || typeof tracks !== 'object' || Array.isArray(tracks) ||
|
|
193
|
+
Object.keys(tracks).length < 1 || Object.keys(tracks).length > 8) {
|
|
194
|
+
throw new SpriteError(`actions.${name}.tracks 需要 1–8 条轨道(层id → 帧序号数组)`);
|
|
195
|
+
}
|
|
196
|
+
for (const [layerId, steps] of Object.entries(tracks)) {
|
|
197
|
+
const layer = raw.layers.find((item) => item.id === layerId);
|
|
198
|
+
if (!layer) throw new SpriteError(`actions.${name} 引用未知层:${layerId}`);
|
|
199
|
+
const frameCount = (layer.frames !== undefined ? layer.frames : [layer.file]).length;
|
|
200
|
+
if (!Array.isArray(steps) || steps.length < 1 || steps.length > 32 ||
|
|
201
|
+
steps.some((step) => !isInt(step, 0, frameCount - 1))) {
|
|
202
|
+
throw new SpriteError(`actions.${name}.tracks.${layerId} 需要 1–32 个帧序号(0–${frameCount - 1})`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
version: raw.version, canvas: canvas, layers: raw.layers, motion: raw.motion ?? null,
|
|
209
|
+
emotions: raw.emotions ?? null, actions: raw.actions ?? null,
|
|
210
|
+
};
|
|
127
211
|
}
|
|
128
212
|
|
|
129
213
|
/** 按目录内容 mtime 缓存读取结果;素材被替换时自动失效。
|
|
@@ -157,12 +241,23 @@ export function readSprite(packageDir) {
|
|
|
157
241
|
const raw = readManifestFile(spriteDir);
|
|
158
242
|
if (!raw) return null;
|
|
159
243
|
const manifest = validateManifest(raw);
|
|
160
|
-
// 引用完整性:目录里的 PNG 必须全部被 manifest
|
|
244
|
+
// 引用完整性:目录里的 PNG 必须全部被 manifest 层引用、GIF 必须被某个
|
|
245
|
+
// GIF 动作引用,帮开发者抓住文件名拼写错误。
|
|
246
|
+
const gifReferenced = new Set();
|
|
247
|
+
if (manifest.actions) {
|
|
248
|
+
for (const action of Object.values(manifest.actions)) {
|
|
249
|
+
if (action && typeof action.gif === 'string') gifReferenced.add(action.gif);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
161
252
|
for (const name of fs.readdirSync(spriteDir)) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
253
|
+
const lower = name.toLowerCase();
|
|
254
|
+
if (lower.endsWith('.png')) {
|
|
255
|
+
const referenced = manifest.layers.some((layer) =>
|
|
256
|
+
(layer.frames !== undefined ? layer.frames : [layer.file]).includes(name));
|
|
257
|
+
if (!referenced) throw new SpriteError(`sprite/${name} 未被 manifest 引用`);
|
|
258
|
+
} else if (lower.endsWith('.gif')) {
|
|
259
|
+
if (!gifReferenced.has(name)) throw new SpriteError(`sprite/${name} 未被任何 GIF 动作引用`);
|
|
260
|
+
}
|
|
166
261
|
}
|
|
167
262
|
let total = 0;
|
|
168
263
|
const files = [];
|
|
@@ -182,9 +277,29 @@ export function readSprite(packageDir) {
|
|
|
182
277
|
files.push({ file, bytes, sha256: createHash('sha256').update(data).digest('hex') });
|
|
183
278
|
}
|
|
184
279
|
}
|
|
280
|
+
// GIF 动作引用的动画文件(多个动作共用同一文件只下发一份)
|
|
281
|
+
if (manifest.actions) {
|
|
282
|
+
const listed = new Set(files.map((entry) => entry.file));
|
|
283
|
+
for (const action of Object.values(manifest.actions)) {
|
|
284
|
+
if (!action || typeof action.gif !== 'string' || listed.has(action.gif)) continue;
|
|
285
|
+
const target = path.join(spriteDir, action.gif);
|
|
286
|
+
let bytes;
|
|
287
|
+
try {
|
|
288
|
+
bytes = fs.statSync(target).size;
|
|
289
|
+
} catch {
|
|
290
|
+
throw new SpriteError(`GIF 动作引用的素材不存在:sprite/${action.gif}`);
|
|
291
|
+
}
|
|
292
|
+
if (bytes > SPRITE_MAX_FILE_BYTES) throw new SpriteError(`素材单文件超过 256 KB:${action.gif}`);
|
|
293
|
+
total += bytes;
|
|
294
|
+
if (total > SPRITE_MAX_TOTAL_BYTES) throw new SpriteError(`sprite/ 整包超过 512 KB`);
|
|
295
|
+
const data = fs.readFileSync(target);
|
|
296
|
+
files.push({ file: action.gif, bytes, sha256: createHash('sha256').update(data).digest('hex') });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
185
299
|
const value = Object.freeze({
|
|
186
300
|
dir: spriteDir, version: manifest.version, canvas: manifest.canvas,
|
|
187
|
-
layers: manifest.layers, motion: manifest.motion,
|
|
301
|
+
layers: manifest.layers, motion: manifest.motion, emotions: manifest.emotions,
|
|
302
|
+
actions: manifest.actions,
|
|
188
303
|
files: Object.freeze(files.map((file) => Object.freeze(file))),
|
|
189
304
|
});
|
|
190
305
|
cache.set(spriteDir, { stamp, value });
|
package/src/agents/events.mjs
CHANGED
|
@@ -88,5 +88,9 @@ export function assistantEvent(event, state, { append = false } = {}) {
|
|
|
88
88
|
} else if (event.type === 'result') {
|
|
89
89
|
if (typeof event.result === 'string') state.finalResult = event.result;
|
|
90
90
|
if (event.is_error || (event.subtype && event.subtype !== 'success')) state.terminalError = '编程工具报告任务失败';
|
|
91
|
+
// 终局事件以最后 outcome 为准:部分 CLI 会先吐可恢复错误(如流断开重试)、
|
|
92
|
+
// 随后仍以 success 收尾(实测 2026-10-16 小鱼任务:codex 自身 task_complete,
|
|
93
|
+
// 桥接器却因早前的 error 事件判死)。先失败后成功 = 成功;只看首个终态会假失败。
|
|
94
|
+
else if (event.subtype === 'success' || event.is_error === false) state.terminalError = '';
|
|
91
95
|
} else if (event.type === 'turn.failed') state.terminalError = '编程工具报告任务失败';
|
|
92
96
|
}
|
package/src/agents/shared.mjs
CHANGED
|
@@ -81,16 +81,19 @@ export function gitState(cwd) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function repositorySnapshot(cwd) {
|
|
84
|
-
if (!fs.existsSync(cwd)) {
|
|
85
|
-
fs.mkdirSync(cwd, { recursive: true });
|
|
86
|
-
spawnSync('git', ['init'], { cwd, encoding: 'utf8', timeout: 15000 });
|
|
87
|
-
}
|
|
84
|
+
if (!fs.existsSync(cwd)) fs.mkdirSync(cwd, { recursive: true });
|
|
88
85
|
if (!fs.statSync(cwd).isDirectory()) {
|
|
89
86
|
throw new Error(`代码目录不存在:${cwd}`);
|
|
90
87
|
}
|
|
91
|
-
|
|
88
|
+
let repo = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
92
89
|
cwd, encoding: 'utf8', timeout: 5000,
|
|
93
90
|
});
|
|
91
|
+
if (repo.status !== 0) {
|
|
92
|
+
spawnSync('git', ['init'], { cwd, encoding: 'utf8', timeout: 15000 });
|
|
93
|
+
repo = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
94
|
+
cwd, encoding: 'utf8', timeout: 5000,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
94
97
|
if (repo.status !== 0) {
|
|
95
98
|
throw new Error('目标项目必须是 Git 仓库,以便查看和恢复改动');
|
|
96
99
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// CLI 执行工具箱:把 CLI 型助手插件需要的机制汇总成一份,经 config.runtime
|
|
2
|
+
// 注入插件。权限边界指令统一在 runCli 里拼(设备侧唯一护栏,不允许各插件
|
|
3
|
+
// 各写一遍);平台差异(Windows .cmd、PATHEXT)统一在 plugin-runtime.mjs。
|
|
4
|
+
//
|
|
5
|
+
// 内置 CLI 助手(codex/cursor/qoder)自 2026-10 起已插件化为市场包,桥接器
|
|
6
|
+
// 不再内置任何编程助手;这套机制是它们在桥接器侧的公共底座。
|
|
7
|
+
import { probeCli, runCli, gitState } from './shared.mjs';
|
|
8
|
+
import {
|
|
9
|
+
jsonLineEvents, assistantEvent, streamActivity, toolActivity, textBlocks,
|
|
10
|
+
} from './events.mjs';
|
|
11
|
+
|
|
12
|
+
export const cliToolbox = Object.freeze({
|
|
13
|
+
probeCli, runCli, gitState,
|
|
14
|
+
jsonLineEvents, assistantEvent, streamActivity, toolActivity, textBlocks,
|
|
15
|
+
});
|
package/src/board-link.mjs
CHANGED
|
@@ -170,6 +170,19 @@ export function createBoardLink() {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
},
|
|
173
|
+
// 行动系统:动作是命令清单里的 action.<名> 条目(设备把可用动作并入
|
|
174
|
+
// 能力清单上报),这里从清单派生名单,给插件与桌面 UI 做参考——设备对
|
|
175
|
+
// 不认识的动作命令本来就会静默忽略,不拿名单做下发闸门。
|
|
176
|
+
actionsOf(deviceId) {
|
|
177
|
+
const catalog = catalogs.get(deviceId);
|
|
178
|
+
if (!catalog) return [];
|
|
179
|
+
return catalog.commands.map((item) => item.name)
|
|
180
|
+
.filter((name) => name.startsWith('action.'))
|
|
181
|
+
.map((name) => name.slice(7));
|
|
182
|
+
},
|
|
183
|
+
allActions() {
|
|
184
|
+
return Object.fromEntries([...catalogs.keys()].map((id) => [id, this.actionsOf(id)]));
|
|
185
|
+
},
|
|
173
186
|
pending(deviceId) {
|
|
174
187
|
return (queues.get(deviceId) || []).map((item) => ({
|
|
175
188
|
id: item.id, name: item.name, fields: { ...item.fields }, audio: item.audio === true,
|
package/src/config.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { loadOpenAIKey } from './openai-key.mjs';
|
|
7
7
|
import { pluginRuntime } from './plugin-runtime.mjs';
|
|
8
|
+
import { cliToolbox } from './agents/toolbox.mjs';
|
|
8
9
|
import { DEFAULT_CODEX_MODEL, loadModelSettings, validateModelSettings, validateStoredModels } from './model-settings.mjs';
|
|
9
10
|
import { DEFAULT_TRANSCRIBE_MODEL, DEFAULT_TRANSCRIBE_URL, loadVoiceSettings, validateTranscribeModel, validateTranscribeUrl } from './voice-settings.mjs';
|
|
10
11
|
|
|
@@ -198,14 +199,16 @@ export function loadConfig(env = process.env) {
|
|
|
198
199
|
environmentOpenaiKey,
|
|
199
200
|
transcribeUrl: savedVoiceSettings?.url || validateTranscribeUrl(env.VIBE_TRANSCRIBE_URL || DEFAULT_TRANSCRIBE_URL),
|
|
200
201
|
transcribeModel: savedVoiceSettings?.model || validateTranscribeModel(env.VIBE_TRANSCRIBE_MODEL || DEFAULT_TRANSCRIBE_MODEL),
|
|
202
|
+
stateDir,
|
|
203
|
+
usbPort,
|
|
204
|
+
configFile: selected,
|
|
205
|
+
// workbuddy 插件的旧凭证入口,仅为兼容既有部署保留;新配置请用插件设置。
|
|
201
206
|
workbuddyToken: env.WORKBUDDY_ACCESS_TOKEN || '',
|
|
202
207
|
workbuddy: {
|
|
203
208
|
allowUnscopedDispatch: raw.workbuddy?.allowUnscopedDispatch === true,
|
|
204
209
|
},
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// 插件可用的跨平台助手(命令查找、子进程、npm)。见 plugin-runtime.mjs。
|
|
209
|
-
runtime: pluginRuntime,
|
|
210
|
+
// 插件可用的助手:跨平台助手(命令查找、子进程、npm)加上 CLI 执行
|
|
211
|
+
// 工具箱(runCli、事件解析等)。见 plugin-runtime.mjs 与 agents/toolbox.mjs。
|
|
212
|
+
runtime: Object.freeze({ ...pluginRuntime, ...cliToolbox }),
|
|
210
213
|
};
|
|
211
214
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
const VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/;
|
|
7
|
+
const ID_RE = /^[0-9a-f]{12}$/;
|
|
8
|
+
|
|
9
|
+
function emptyManifest() {
|
|
10
|
+
return { otaUrl: '', currentId: '', items: [] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readManifest(file) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return emptyManifest();
|
|
17
|
+
return {
|
|
18
|
+
otaUrl: typeof parsed.otaUrl === 'string' ? parsed.otaUrl : '',
|
|
19
|
+
currentId: typeof parsed.currentId === 'string' ? parsed.currentId : '',
|
|
20
|
+
items: Array.isArray(parsed.items) ? parsed.items.filter((item) => item && typeof item === 'object') : [],
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
return emptyManifest();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const BRIDGE_FIRMWARE_PATH = '/device/firmware/current';
|
|
28
|
+
|
|
29
|
+
export function validateOtaUrl(value) {
|
|
30
|
+
const text = String(value || '').trim();
|
|
31
|
+
if (!text) return '';
|
|
32
|
+
if (text === BRIDGE_FIRMWARE_PATH) return text;
|
|
33
|
+
let url;
|
|
34
|
+
try { url = new URL(text); } catch { throw new Error('固件地址须是 http(s) URL,或本机路径 /device/firmware/current'); }
|
|
35
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('固件地址须是 http(s) URL,或本机路径 /device/firmware/current');
|
|
36
|
+
if (url.username || url.password) throw new Error('固件地址不能带账号密码');
|
|
37
|
+
return url.toString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createFirmwareStore(directory) {
|
|
41
|
+
const root = path.resolve(directory);
|
|
42
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
43
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
44
|
+
|
|
45
|
+
const load = () => readManifest(manifestPath);
|
|
46
|
+
const save = (manifest) => {
|
|
47
|
+
const temp = `${manifestPath}.${process.pid}.tmp`;
|
|
48
|
+
fs.writeFileSync(temp, JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
|
49
|
+
fs.renameSync(temp, manifestPath);
|
|
50
|
+
};
|
|
51
|
+
const binPath = (id) => path.join(root, `${id}.bin`);
|
|
52
|
+
|
|
53
|
+
function publicItem(item) {
|
|
54
|
+
return {
|
|
55
|
+
id: item.id, version: item.version, size: item.size, sha256: item.sha256,
|
|
56
|
+
createdAt: item.createdAt, notes: item.notes || '',
|
|
57
|
+
current: item.id === load().currentId,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function currentItem(manifest = load()) {
|
|
62
|
+
return manifest.items.find((item) => item.id === manifest.currentId) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
summary() {
|
|
67
|
+
const manifest = load();
|
|
68
|
+
const current = currentItem(manifest);
|
|
69
|
+
return {
|
|
70
|
+
otaUrl: manifest.otaUrl,
|
|
71
|
+
current: current ? publicItem(current) : null,
|
|
72
|
+
items: manifest.items.map(publicItem),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
setOtaUrl(value) {
|
|
76
|
+
const manifest = load();
|
|
77
|
+
manifest.otaUrl = validateOtaUrl(value);
|
|
78
|
+
save(manifest);
|
|
79
|
+
return this.summary();
|
|
80
|
+
},
|
|
81
|
+
importBuffer({ version, bytes, notes = '' }) {
|
|
82
|
+
const name = String(version || '').trim();
|
|
83
|
+
if (!VERSION_RE.test(name)) throw new Error('版本号需为 1–32 位字母、数字、点或横线');
|
|
84
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 16 || bytes.length > MAX_BYTES) {
|
|
85
|
+
throw new Error('固件文件需在 16 字节到 8MB 之间');
|
|
86
|
+
}
|
|
87
|
+
const id = crypto.randomBytes(6).toString('hex');
|
|
88
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
89
|
+
const file = binPath(id);
|
|
90
|
+
fs.writeFileSync(file, bytes, { mode: 0o600 });
|
|
91
|
+
const manifest = load();
|
|
92
|
+
manifest.items.unshift({
|
|
93
|
+
id, version: name, size: bytes.length, sha256,
|
|
94
|
+
createdAt: new Date().toISOString(), notes: String(notes || '').slice(0, 120),
|
|
95
|
+
});
|
|
96
|
+
manifest.currentId = id;
|
|
97
|
+
save(manifest);
|
|
98
|
+
return this.summary();
|
|
99
|
+
},
|
|
100
|
+
async syncFromUrl(fetchImpl = globalThis.fetch) {
|
|
101
|
+
const manifest = load();
|
|
102
|
+
const target = validateOtaUrl(manifest.otaUrl);
|
|
103
|
+
if (!target) throw new Error('请先填写固件地址');
|
|
104
|
+
if (!target.startsWith('http')) throw new Error('本机地址表示码得来这台桥接器取包,请先上传固件');
|
|
105
|
+
const response = await fetchImpl(target);
|
|
106
|
+
if (!response.ok) throw new Error(`拉取固件失败:HTTP ${response.status}`);
|
|
107
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
108
|
+
let version = 'remote';
|
|
109
|
+
try {
|
|
110
|
+
const leaf = decodeURIComponent(new URL(target).pathname.split('/').filter(Boolean).pop() || '');
|
|
111
|
+
version = leaf.replace(/\.bin$/i, '') || version;
|
|
112
|
+
} catch { /* 用默认版本名 */ }
|
|
113
|
+
if (!VERSION_RE.test(version)) version = `remote-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}`;
|
|
114
|
+
return this.importBuffer({ version, bytes, notes: target });
|
|
115
|
+
},
|
|
116
|
+
remove(id) {
|
|
117
|
+
if (!ID_RE.test(id || '')) throw new Error('固件不存在');
|
|
118
|
+
const manifest = load();
|
|
119
|
+
const item = manifest.items.find((entry) => entry.id === id);
|
|
120
|
+
if (!item) throw new Error('固件不存在');
|
|
121
|
+
manifest.items = manifest.items.filter((entry) => entry.id !== id);
|
|
122
|
+
if (manifest.currentId === id) manifest.currentId = manifest.items[0]?.id || '';
|
|
123
|
+
save(manifest);
|
|
124
|
+
fs.rmSync(binPath(id), { force: true });
|
|
125
|
+
return this.summary();
|
|
126
|
+
},
|
|
127
|
+
setCurrent(id) {
|
|
128
|
+
if (!ID_RE.test(id || '')) throw new Error('固件不存在');
|
|
129
|
+
const manifest = load();
|
|
130
|
+
if (!manifest.items.some((entry) => entry.id === id)) throw new Error('固件不存在');
|
|
131
|
+
manifest.currentId = id;
|
|
132
|
+
save(manifest);
|
|
133
|
+
return this.summary();
|
|
134
|
+
},
|
|
135
|
+
deviceOffer() {
|
|
136
|
+
const manifest = load();
|
|
137
|
+
const current = currentItem(manifest);
|
|
138
|
+
if (!current) {
|
|
139
|
+
return { version: '', size: 0, sha256: '', url: manifest.otaUrl, path: '' };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
version: current.version,
|
|
143
|
+
size: current.size,
|
|
144
|
+
sha256: current.sha256,
|
|
145
|
+
url: manifest.otaUrl,
|
|
146
|
+
path: '/device/firmware/current',
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
readCurrent(offset, length) {
|
|
150
|
+
const item = currentItem();
|
|
151
|
+
if (!item) return null;
|
|
152
|
+
const file = binPath(item.id);
|
|
153
|
+
if (!fs.existsSync(file)) return null;
|
|
154
|
+
const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
|
|
155
|
+
const want = Number.isInteger(length) && length > 0 ? Math.min(length, 200 * 1024) : 128 * 1024;
|
|
156
|
+
const fd = fs.openSync(file, 'r');
|
|
157
|
+
try {
|
|
158
|
+
const buffer = Buffer.alloc(want);
|
|
159
|
+
const read = fs.readSync(fd, buffer, 0, want, start);
|
|
160
|
+
return { item, offset: start, bytes: buffer.subarray(0, read) };
|
|
161
|
+
} finally {
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/installed.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { BUILTIN_ROOT, packageRoots, packagesHome } from './config.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { validatePlugin } from './plugins/registry.mjs';
|
|
7
|
+
import { reloadConfiguredPlugins } from './plugins/hot-reload.mjs';
|
|
7
8
|
import { isPackageEntry, packageDirectories, readPackageSettings, writePackageSettings } from './plugin-package.mjs';
|
|
8
9
|
|
|
9
10
|
const PLUGIN_ID = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
@@ -158,7 +159,7 @@ function writeConfig(config, mutate) {
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
async function reload(config) {
|
|
161
|
-
|
|
162
|
+
await reloadConfiguredPlugins(config);
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
function covers(targetReal, candidateReal) {
|