bothouse-dsh 0.1.46 → 0.1.47
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/client.js +89 -3
- package/collab/desk/index.html +732 -654
- package/collab/desk.mjs +52 -4
- package/collab/dsh-member.mjs +29 -4
- package/collab/page.html +11 -9
- package/collab/panel.mjs +160 -0
- package/collab/plugin.mjs +10 -4
- package/package.json +1 -1
package/collab/desk.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 事件契约见 Issue #876:`board-events.jsonl` 与 board.jsonl 同目录(BOARD_DIR),
|
|
4
4
|
// 一行一个 JSON,只追加;这里只读不改,写入归 gate 的 board.py。
|
|
5
|
-
import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
5
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
7
|
import { CollabError, publicMessage } from './store.mjs';
|
|
8
8
|
// #876 P: 回放消息的 @ 由这里解析,规则与 hub 路由同一套(mentions.mjs),页面不再自己猜文本。
|
|
9
9
|
import { mentions as mentionsOf } from './mentions.mjs';
|
|
@@ -130,8 +130,56 @@ export function deskPage() {
|
|
|
130
130
|
try { return readFileSync(new URL('./desk/index.html', import.meta.url), 'utf8'); } catch { return PLACEHOLDER_PAGE; }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
export
|
|
133
|
+
// 名牌自定义名:成员 id → 名字,存插件数据目录,刷新、换设备都还在;空名 = 恢复默认(roster.name)。
|
|
134
|
+
export const DESK_NAMES_FILE = 'desk-names.json';
|
|
135
|
+
export const DESK_NAME_MAX = 24;
|
|
136
|
+
|
|
137
|
+
export function readDeskNames(file) {
|
|
138
|
+
if (!file) return {};
|
|
139
|
+
try {
|
|
140
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
141
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
142
|
+
} catch { return {}; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 改一个成员的名牌名;name 去空白后为空就删掉,回到默认名。返回改后的全表。 */
|
|
146
|
+
export function setDeskName(file, id, name) {
|
|
147
|
+
const key = String(id ?? '').trim();
|
|
148
|
+
if (!key) throw new CollabError('invalid', 'id is required');
|
|
149
|
+
const value = String(name ?? '').replace(/\s+/g, ' ').trim();
|
|
150
|
+
if (Array.from(value).length > DESK_NAME_MAX) throw new CollabError('invalid', `name must be at most ${DESK_NAME_MAX} characters`);
|
|
151
|
+
const names = readDeskNames(file);
|
|
152
|
+
if (value) names[key] = value; else delete names[key];
|
|
153
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
154
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
155
|
+
writeFileSync(tmp, JSON.stringify(names, null, 2) + '\n');
|
|
156
|
+
renameSync(tmp, file);
|
|
157
|
+
return names;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sendJson(res, status, value) {
|
|
161
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
162
|
+
res.end(JSON.stringify(value));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function namesRoute(req, res, namesFile) {
|
|
166
|
+
if (!namesFile) return sendJson(res, 404, { error: 'desk names are not configured' });
|
|
167
|
+
if (req.method === 'GET') return sendJson(res, 200, { names: readDeskNames(namesFile) });
|
|
168
|
+
let raw = '';
|
|
169
|
+
try {
|
|
170
|
+
for await (const chunk of req) { raw += chunk; if (raw.length > 4096) return sendJson(res, 413, { error: 'body too large' }); }
|
|
171
|
+
const body = JSON.parse(raw || '{}');
|
|
172
|
+
return sendJson(res, 200, { names: setDeskName(namesFile, body?.id, body?.name) });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof CollabError) return sendJson(res, 400, { error: error.message, code: error.code });
|
|
175
|
+
if (error instanceof SyntaxError) return sendJson(res, 400, { error: 'invalid JSON body' });
|
|
176
|
+
return sendJson(res, 500, { error: 'save failed' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** /collab/desk、/collab/api/desk/replay、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
|
|
181
|
+
export function deskRoute(req, res, url, { store, boardDir, members = [], namesFile } = {}) {
|
|
182
|
+
if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/names' && (req.method === 'GET' || req.method === 'PUT')) return namesRoute(req, res, namesFile);
|
|
135
183
|
if (url.pathname.replace(/\/+$/, '') !== '/collab/desk' && url.pathname.replace(/\/+$/, '') !== '/collab/api/desk/replay') return undefined;
|
|
136
184
|
if (req.method !== 'GET') return undefined;
|
|
137
185
|
if (url.pathname.replace(/\/+$/, '') === '/collab/desk') {
|
package/collab/dsh-member.mjs
CHANGED
|
@@ -203,9 +203,34 @@ export function friendlyFailure(raw) {
|
|
|
203
203
|
if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
|
|
204
204
|
if (/login|log.?in|auth|unauthorized|401|未登录/i.test(text)) return '模型或 CLI 还没有登录,或登录已过期。请先登录后再试。';
|
|
205
205
|
if (/exit|ENOENT|spawn|没有输出文字/i.test(text)) return '本机 CLI 未能正常运行或已退出,请检查安装和登录状态。';
|
|
206
|
+
// A Host older than this plugin has no scope lease on its preset registry, so no member can be
|
|
207
|
+
// composed. Name the cause and the fix instead of showing the bare TypeError.
|
|
208
|
+
if (/agentPresets\.\w+ is not a function/i.test(text)) return '当前 DSH 宿主缺少频道插件需要的预设接口(agentPresets),成员无法启动。请把 DSH 升级到与本插件匹配的版本后重试。';
|
|
206
209
|
return `这次没能完成,请检查配置后重试。原始错误:${text}`;
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Lease one preset revision while an Agent joins it, or undefined when the Host's
|
|
214
|
+
* registry has no scope lease. `acquireScope` shipped in a later registry than
|
|
215
|
+
* `resolve`/`mount`, so a Host can serve presets without it; the lease only keeps
|
|
216
|
+
* that revision alive during assembly. Skipping it there keeps members usable
|
|
217
|
+
* instead of failing every turn with a bare "is not a function" TypeError.
|
|
218
|
+
* @param ctx - Host context carrying the preset registry.
|
|
219
|
+
* @param presetId - Preset identity the Agent joins.
|
|
220
|
+
* @returns Something with an async disposer, or undefined when unsupported.
|
|
221
|
+
*/
|
|
222
|
+
export async function presetScope(ctx, presetId) {
|
|
223
|
+
const registry = ctx?.agentPresets;
|
|
224
|
+
if (typeof registry?.acquireScope !== 'function') return undefined;
|
|
225
|
+
return await registry.acquireScope(presetId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Release a presetScope result; a Host without the lease releases nothing. */
|
|
229
|
+
export async function releasePresetScope(scope) {
|
|
230
|
+
const dispose = scope?.[Symbol.asyncDispose];
|
|
231
|
+
if (typeof dispose === 'function') await dispose();
|
|
232
|
+
}
|
|
233
|
+
|
|
209
234
|
/**
|
|
210
235
|
* Card text of one approval request, in cc-connect's permission prompt layout:
|
|
211
236
|
* the tool, what it wants to run (the call's first argument), and DSH's reason.
|
|
@@ -911,7 +936,7 @@ export class DshMembers {
|
|
|
911
936
|
async createAgent(member, room) {
|
|
912
937
|
const { ctx } = this.rt;
|
|
913
938
|
const preset = await ctx.agentPresets.resolve(member.agentPreset);
|
|
914
|
-
const scope = await ctx
|
|
939
|
+
const scope = await presetScope(ctx, preset.id);
|
|
915
940
|
const sessionId = `collab-${randomUUID()}`;
|
|
916
941
|
let handle;
|
|
917
942
|
let workspace;
|
|
@@ -932,7 +957,7 @@ export class DshMembers {
|
|
|
932
957
|
},
|
|
933
958
|
});
|
|
934
959
|
} finally {
|
|
935
|
-
await scope
|
|
960
|
+
await releasePresetScope(scope);
|
|
936
961
|
}
|
|
937
962
|
await workspace.attachSession(sessionId);
|
|
938
963
|
// setup ran before the Agent existed, so record it here: liveAgent must not hook it again.
|
|
@@ -1006,7 +1031,7 @@ export class DshMembers {
|
|
|
1006
1031
|
// Installing only the request hook left a resumed Session with just the
|
|
1007
1032
|
// built-in tools after a DSH restart.
|
|
1008
1033
|
const preset = await ctx.agentPresets.resolve(member.agentPreset);
|
|
1009
|
-
const scope = await ctx
|
|
1034
|
+
const scope = await presetScope(ctx, preset.id);
|
|
1010
1035
|
let handle;
|
|
1011
1036
|
try {
|
|
1012
1037
|
handle = await ctx.agents.resume({
|
|
@@ -1018,7 +1043,7 @@ export class DshMembers {
|
|
|
1018
1043
|
},
|
|
1019
1044
|
});
|
|
1020
1045
|
} finally {
|
|
1021
|
-
await scope
|
|
1046
|
+
await releasePresetScope(scope);
|
|
1022
1047
|
}
|
|
1023
1048
|
this.hooked.add(handle.agent);
|
|
1024
1049
|
if (member.permissionPreset !== undefined) ctx.permissionPresets.set(handle.agent.session, member.permissionPreset);
|
package/collab/page.html
CHANGED
|
@@ -505,6 +505,8 @@
|
|
|
505
505
|
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
|
506
506
|
|
|
507
507
|
<script>
|
|
508
|
+
// 经面板 /dsh/ 转发时页面挂在前缀下:接口和链接都按当前地址带上前缀,直连时前缀为空。
|
|
509
|
+
const ROOT = (location.pathname.match(/^(.*?)\/collab(?:\/|$)/) || ['', ''])[1];
|
|
508
510
|
const state = { me: 'owner', rooms: [], members: [], thinking: {}, room: null, synced: 0, mode: 'mixed' };
|
|
509
511
|
const $ = id => document.getElementById(id);
|
|
510
512
|
// crypto.randomUUID only exists in secure contexts (https/localhost); plain-http LAN/Tailscale pages fall back to getRandomValues.
|
|
@@ -527,7 +529,7 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
|
|
527
529
|
// 768px 是 U2 交接里定的窄屏阈值
|
|
528
530
|
const narrow = window.matchMedia('(max-width: 768px)');
|
|
529
531
|
|
|
530
|
-
const avatarSrc = v => !v ? '' : /^https?:/.test(v) ? v : '/collab/avatars/' + encodeURIComponent(v) + '.png';
|
|
532
|
+
const avatarSrc = v => !v ? '' : /^https?:/.test(v) ? v : ROOT + '/collab/avatars/' + encodeURIComponent(v) + '.png';
|
|
531
533
|
function avatar(id, name) {
|
|
532
534
|
const m = state.members.find(m => m.id === id);
|
|
533
535
|
const a = el('div', 'avatar', (name || id).slice(0, 1)); a.style.background = color(id);
|
|
@@ -634,7 +636,7 @@ function messageNode(m) {
|
|
|
634
636
|
const bar = el('div', 'actions');
|
|
635
637
|
for (const a of meta.actions) {
|
|
636
638
|
const b = el('button', '', a.label);
|
|
637
|
-
b.onclick = () => { bar.querySelectorAll('button').forEach(x => { x.disabled = true; }); api(
|
|
639
|
+
b.onclick = () => { bar.querySelectorAll('button').forEach(x => { x.disabled = true; }); api(`${ROOT}/collab/api/messages/${m.id}/permission`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision: a.decision }) }); };
|
|
638
640
|
bar.append(b);
|
|
639
641
|
}
|
|
640
642
|
card.append(bar);
|
|
@@ -702,10 +704,10 @@ async function openRoom(id) {
|
|
|
702
704
|
$('input').disabled = true;
|
|
703
705
|
closeDrawer();
|
|
704
706
|
try {
|
|
705
|
-
const setting = await api(
|
|
707
|
+
const setting = await api(`${ROOT}/collab/api/rooms/${id}/mode`);
|
|
706
708
|
state.mode = setting.dispatch_mode;
|
|
707
709
|
renderRooms(); renderMembers();
|
|
708
|
-
const { messages } = await api(
|
|
710
|
+
const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=100`);
|
|
709
711
|
if (state.room !== id) return;
|
|
710
712
|
const log = $('log');
|
|
711
713
|
log.querySelector('.state')?.remove();
|
|
@@ -725,7 +727,7 @@ async function openRoom(id) {
|
|
|
725
727
|
$('mode').addEventListener('change', async (e) => {
|
|
726
728
|
if (state.room == null) return;
|
|
727
729
|
try {
|
|
728
|
-
const result = await api(
|
|
730
|
+
const result = await api(`${ROOT}/collab/api/rooms/${state.room}/mode`, {
|
|
729
731
|
method: 'PATCH', headers: { 'content-type': 'application/json' },
|
|
730
732
|
body: JSON.stringify({ dispatch_mode: e.target.value }),
|
|
731
733
|
});
|
|
@@ -739,7 +741,7 @@ async function catchUp() {
|
|
|
739
741
|
const room = state.room;
|
|
740
742
|
if (room == null) return;
|
|
741
743
|
for (;;) {
|
|
742
|
-
const { messages } = await api(
|
|
744
|
+
const { messages } = await api(`${ROOT}/collab/api/rooms/${room}/messages?after=${state.synced}&limit=200`);
|
|
743
745
|
if (state.room !== room) return;
|
|
744
746
|
messages.forEach(upsert);
|
|
745
747
|
if (messages.length) state.synced = messages[messages.length - 1].id;
|
|
@@ -748,7 +750,7 @@ async function catchUp() {
|
|
|
748
750
|
}
|
|
749
751
|
|
|
750
752
|
function connect() {
|
|
751
|
-
const es = new EventSource('/collab/api/events');
|
|
753
|
+
const es = new EventSource(ROOT + '/collab/api/events');
|
|
752
754
|
es.onopen = () => catchUp().catch(console.warn);
|
|
753
755
|
es.onmessage = (e) => {
|
|
754
756
|
const ev = JSON.parse(e.data);
|
|
@@ -769,7 +771,7 @@ $('form').addEventListener('submit', async (e) => {
|
|
|
769
771
|
if (!text || state.room == null) return;
|
|
770
772
|
$('input').value = ''; $('input').style.height = 'auto';
|
|
771
773
|
try {
|
|
772
|
-
const { message } = await api(
|
|
774
|
+
const { message } = await api(`${ROOT}/collab/api/rooms/${state.room}/messages`, {
|
|
773
775
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
774
776
|
body: JSON.stringify({ text, client_key: uuid() }),
|
|
775
777
|
});
|
|
@@ -778,7 +780,7 @@ $('form').addEventListener('submit', async (e) => {
|
|
|
778
780
|
});
|
|
779
781
|
|
|
780
782
|
(async () => {
|
|
781
|
-
const s = await api('/collab/api/state');
|
|
783
|
+
const s = await api(ROOT + '/collab/api/state');
|
|
782
784
|
Object.assign(state, { me: s.me, rooms: s.rooms, members: s.members, thinking: s.thinking });
|
|
783
785
|
connect();
|
|
784
786
|
await openRoom(s.rooms[0].id);
|
package/collab/panel.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The bothouse management panel inside DSH (#105): the sidebar's "bothouse" entry frames
|
|
2
|
+
// /collab/panel/, and this owner-only route forwards it to the bridge management server.
|
|
3
|
+
//
|
|
4
|
+
// The panel API needs the management token. The browser must not hold it, so on each
|
|
5
|
+
// request the route reads [management] from the bridge config file and signs the upstream
|
|
6
|
+
// call itself. The panel page learns it is behind such a proxy from an injected
|
|
7
|
+
// <meta name="bothouse-auth" content="proxy">, and <base href> is rewritten to the mount
|
|
8
|
+
// path so its relative asset and API URLs land back here (bridge/web/src/lib/basePath.ts).
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
13
|
+
|
|
14
|
+
export const PANEL_PREFIX = '/collab/panel';
|
|
15
|
+
// 来源:bothouse 管理接口默认端口(bridge config.example.toml [management] port)
|
|
16
|
+
const DEFAULT_PORT = 9820;
|
|
17
|
+
// Request headers the panel page may pass upstream; everything else (cookies, Authorization,
|
|
18
|
+
// Origin, Host) stays on this side.
|
|
19
|
+
const PASS_REQUEST = ['accept', 'accept-language', 'content-type', 'if-none-match', 'if-modified-since', 'range'];
|
|
20
|
+
// Response headers worth keeping; Set-Cookie and CORS headers from the panel are dropped.
|
|
21
|
+
const PASS_RESPONSE = ['content-type', 'cache-control', 'etag', 'last-modified', 'content-disposition', 'content-range', 'accept-ranges'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default bridge config path: $BOTHOUSE_CONFIG, else <data dir>/config.toml.
|
|
25
|
+
* @param {NodeJS.ProcessEnv} env
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function defaultBridgeConfig(env = process.env) {
|
|
29
|
+
const explicit = String(env.BOTHOUSE_CONFIG ?? '').trim();
|
|
30
|
+
if (explicit !== '') return explicit;
|
|
31
|
+
const dataDir = String(env.BOTHOUSE_DATA_DIR ?? '').trim() || join(env.HOME || homedir(), '.bothouse');
|
|
32
|
+
return join(dataDir, 'config.toml');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Value of one TOML scalar (quoted string, number or bare word); comments stripped. */
|
|
36
|
+
function scalar(raw) {
|
|
37
|
+
const s = raw.trim();
|
|
38
|
+
const quoted = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(s);
|
|
39
|
+
if (quoted !== null) return quoted[1] !== undefined ? JSON.parse(`"${quoted[1]}"`) : quoted[2];
|
|
40
|
+
return s.replace(/\s+#.*$/, '').trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read host/port/token/enabled from the [management] table of a bridge config.
|
|
45
|
+
* Only flat `key = value` lines are needed, so a full TOML parser is not pulled in.
|
|
46
|
+
* @param {string} text - config.toml contents.
|
|
47
|
+
* @returns {{ enabled: boolean, host: string, port: number, token: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function managementSettings(text) {
|
|
50
|
+
const out = { enabled: false, host: '', port: DEFAULT_PORT, token: '' };
|
|
51
|
+
let inside = false;
|
|
52
|
+
for (const line of text.split(/\r?\n/)) {
|
|
53
|
+
const header = /^\s*\[\s*([^\]]+?)\s*\]/.exec(line);
|
|
54
|
+
if (header !== null) { inside = header[1] === 'management'; continue; }
|
|
55
|
+
if (!inside) continue;
|
|
56
|
+
const kv = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/.exec(line);
|
|
57
|
+
if (kv === null) continue;
|
|
58
|
+
const value = scalar(kv[2]);
|
|
59
|
+
if (kv[1] === 'enabled') out.enabled = value === 'true';
|
|
60
|
+
else if (kv[1] === 'host') out.host = String(value);
|
|
61
|
+
else if (kv[1] === 'port' && Number.isInteger(Number(value)) && Number(value) > 0) out.port = Number(value);
|
|
62
|
+
else if (kv[1] === 'token') out.token = String(value);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Loopback address to dial for a configured listen host. */
|
|
68
|
+
function dialHost(host) {
|
|
69
|
+
if (host === '' || host === '0.0.0.0' || host === '::' || host === '[::]') return '127.0.0.1';
|
|
70
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendJSON(res, status, body) {
|
|
74
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
75
|
+
res.end(JSON.stringify(body));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Text shown inside the frame when the panel cannot be reached. */
|
|
79
|
+
function sendNotice(res, status, reason) {
|
|
80
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
81
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>bothouse</title><body style="font:14px system-ui;padding:24px;color:#888">打不开 bothouse 管理面板:${reason
|
|
82
|
+
.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])}</body>`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Point the panel page at the mount path and mark it as proxy-signed.
|
|
87
|
+
* @param {string} html - the panel's index.html.
|
|
88
|
+
* @param {string} prefix - mount path, e.g. /collab/panel.
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function rewritePanelHTML(html, prefix = PANEL_PREFIX) {
|
|
92
|
+
const head = `<base href="${prefix}/" />\n <meta name="bothouse-auth" content="proxy" />`;
|
|
93
|
+
if (/<base\s[^>]*>/i.test(html)) return html.replace(/<base\s[^>]*>/i, head);
|
|
94
|
+
return html.replace(/<head(\s[^>]*)?>/i, m => `${m}\n ${head}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Forward /collab/panel/* to the bridge management server. Callers must have checked the owner.
|
|
99
|
+
* @returns {Promise<true> | undefined} undefined when the path is not ours.
|
|
100
|
+
*/
|
|
101
|
+
export function panelRoute(req, res, url, { bridgeConfig, fetchImpl = fetch }) {
|
|
102
|
+
if (url.pathname !== PANEL_PREFIX && !url.pathname.startsWith(`${PANEL_PREFIX}/`)) return undefined;
|
|
103
|
+
if (url.pathname === PANEL_PREFIX) {
|
|
104
|
+
res.writeHead(308, { location: `${PANEL_PREFIX}/${url.search}` });
|
|
105
|
+
res.end();
|
|
106
|
+
return Promise.resolve(true);
|
|
107
|
+
}
|
|
108
|
+
return (async () => {
|
|
109
|
+
const rest = url.pathname.slice(PANEL_PREFIX.length) || '/';
|
|
110
|
+
const isAPI = rest.startsWith('/api/');
|
|
111
|
+
const fail = (status, reason) => (isAPI ? sendJSON(res, status, { ok: false, error: reason }) : sendNotice(res, status, reason), true);
|
|
112
|
+
let settings;
|
|
113
|
+
try {
|
|
114
|
+
settings = managementSettings(readFileSync(bridgeConfig, 'utf8'));
|
|
115
|
+
} catch {
|
|
116
|
+
return fail(503, '读不到主桥配置');
|
|
117
|
+
}
|
|
118
|
+
if (!settings.enabled) return fail(503, '主桥配置里管理接口没打开');
|
|
119
|
+
const upstream = `http://${dialHost(settings.host)}:${settings.port}${rest}${url.search}`;
|
|
120
|
+
const headers = {};
|
|
121
|
+
for (const name of PASS_REQUEST) {
|
|
122
|
+
const v = req.headers[name];
|
|
123
|
+
if (typeof v === 'string') headers[name] = v;
|
|
124
|
+
}
|
|
125
|
+
if (settings.token !== '') headers.authorization = `Bearer ${settings.token}`;
|
|
126
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
127
|
+
let reply;
|
|
128
|
+
try {
|
|
129
|
+
reply = await fetchImpl(upstream, {
|
|
130
|
+
method: req.method,
|
|
131
|
+
headers,
|
|
132
|
+
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
133
|
+
duplex: hasBody ? 'half' : undefined,
|
|
134
|
+
redirect: 'manual',
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
return fail(502, '连不上主桥管理接口');
|
|
138
|
+
}
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const name of PASS_RESPONSE) {
|
|
141
|
+
const v = reply.headers.get(name);
|
|
142
|
+
if (v !== null) out[name] = v;
|
|
143
|
+
}
|
|
144
|
+
const type = reply.headers.get('content-type') ?? '';
|
|
145
|
+
if (!isAPI && type.startsWith('text/html')) {
|
|
146
|
+
const html = rewritePanelHTML(await reply.text());
|
|
147
|
+
res.writeHead(reply.status, { ...out, 'cache-control': 'no-store' });
|
|
148
|
+
res.end(html);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
res.writeHead(reply.status, out);
|
|
152
|
+
if (reply.body === null || req.method === 'HEAD') { res.end(); return true; }
|
|
153
|
+
// Stream so NDJSON/SSE answers (plugin install, logs) arrive as they are produced.
|
|
154
|
+
const body = Readable.fromWeb(reply.body);
|
|
155
|
+
body.on('error', () => res.destroy());
|
|
156
|
+
req.on('close', () => body.destroy());
|
|
157
|
+
body.pipe(res);
|
|
158
|
+
return true;
|
|
159
|
+
})();
|
|
160
|
+
}
|
package/collab/plugin.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { personaLibrary } from './personas.mjs';
|
|
|
4
4
|
// - insert:
|
|
5
5
|
// - id: conest-collab
|
|
6
6
|
// name: /abs/path/dsh/collab/plugin.mjs
|
|
7
|
-
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir? }
|
|
7
|
+
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir?, bridgeConfig? }
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
9
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
10
10
|
import { isAbsolute, join } from 'node:path';
|
|
@@ -24,8 +24,9 @@ import { LeadState } from './lead-state.mjs';
|
|
|
24
24
|
import { Attachments, INLINE_IMAGE } from './attachments.mjs';
|
|
25
25
|
import { Later, registerLater } from './later.mjs';
|
|
26
26
|
import { QuotaCooling, quotaDirectory } from './quota-cooling.mjs';
|
|
27
|
-
import { DeskEvents, deskRoute } from './desk.mjs';
|
|
27
|
+
import { DeskEvents, DESK_NAMES_FILE, deskRoute } from './desk.mjs';
|
|
28
28
|
import { wikiGalaxyRoute } from './wiki-galaxy.mjs';
|
|
29
|
+
import { defaultBridgeConfig, panelRoute } from './panel.mjs';
|
|
29
30
|
import { TeamStatus } from './team-status.mjs';
|
|
30
31
|
|
|
31
32
|
export const name = 'conest-collab';
|
|
@@ -93,7 +94,10 @@ export function resolveConfig(config, env = process.env) {
|
|
|
93
94
|
// Wiki 星河读的 wiki 目录:配置或 CONEST_WIKI_DIR,都没给就是 ~/wiki(只读)。
|
|
94
95
|
const wikiDir = String(c.wikiDir ?? env.CONEST_WIKI_DIR ?? '').trim() || (env.HOME ? join(env.HOME, 'wiki') : undefined);
|
|
95
96
|
if (wikiDir !== undefined && !isAbsolute(wikiDir)) throw Error('conest-collab: wikiDir must be an absolute path');
|
|
96
|
-
|
|
97
|
+
// #105 侧栏「bothouse」:读这份主桥配置的 [management] 段,在服务端转发管理面板(令牌不进浏览器)。
|
|
98
|
+
const bridgeConfig = String(c.bridgeConfig ?? '').trim() || defaultBridgeConfig(env);
|
|
99
|
+
if (!isAbsolute(bridgeConfig)) throw Error('conest-collab: bridgeConfig must be an absolute path');
|
|
100
|
+
return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, ownerName: c.ownerName ?? '我' };
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
/** Resolve a package from the running DSH installation, never a second copy. */
|
|
@@ -237,10 +241,12 @@ export async function apply(ctx, rawConfig) {
|
|
|
237
241
|
return refuse(req, res);
|
|
238
242
|
}
|
|
239
243
|
// #876 P: 回放里的 @ 用 hub 的成员表解析(routable = 真实角色,不含主人)。
|
|
240
|
-
const deskHandled = deskRoute(req, res, url, { store, boardDir: config.boardDir, members: hub.routable() });
|
|
244
|
+
const deskHandled = deskRoute(req, res, url, { store, boardDir: config.boardDir, members: hub.routable(), namesFile: join(config.dataDir, DESK_NAMES_FILE) });
|
|
241
245
|
if (deskHandled !== undefined) return deskHandled;
|
|
242
246
|
const galaxyHandled = wikiGalaxyRoute(req, res, url, { wikiDir: config.wikiDir });
|
|
243
247
|
if (galaxyHandled !== undefined) return galaxyHandled;
|
|
248
|
+
const panelHandled = panelRoute(req, res, url, { bridgeConfig: config.bridgeConfig });
|
|
249
|
+
if (panelHandled !== undefined) return panelHandled;
|
|
244
250
|
if (path === '/collab' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', PAGE);
|
|
245
251
|
if (path === '/collab/api/personas' && req.method === 'GET') return json(res, 200, personaLibrary(url.searchParams.get('lang') ?? 'zh'));
|
|
246
252
|
if (path === '/collab/api/state' && req.method === 'GET') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bothouse-dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.47",
|
|
4
4
|
"description": "BotHouse channels for DeepSeek Harness: Discord-style rooms where DSH models and local CLIs (Claude Code, Codex, Gemini) are members",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./collab/plugin.mjs",
|