bothouse-dsh 0.1.47 → 0.1.49
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 +3411 -398
- package/collab/bot-port.mjs +30 -0
- package/collab/cli-member.mjs +3 -0
- package/collab/clis.mjs +11 -4
- package/collab/cloudweb.mjs +106 -17
- package/collab/desk/index.html +271 -31
- package/collab/dsh-member.mjs +47 -7
- package/collab/gate-context.mjs +80 -6
- package/collab/hub.mjs +42 -1
- package/collab/later.mjs +21 -0
- package/collab/memory-sources.mjs +159 -0
- package/collab/page.html +461 -6
- package/collab/pairing.mjs +674 -0
- package/collab/panel.mjs +10 -1
- package/collab/plugin.mjs +160 -24
- package/collab/roster.mjs +27 -6
- package/collab/store.mjs +48 -1
- package/collab/tasks.mjs +192 -16
- package/collab/wiki-galaxy.mjs +2 -1
- package/package.json +1 -1
package/collab/panel.mjs
CHANGED
|
@@ -89,7 +89,16 @@ function sendNotice(res, status, reason) {
|
|
|
89
89
|
* @returns {string}
|
|
90
90
|
*/
|
|
91
91
|
export function rewritePanelHTML(html, prefix = PANEL_PREFIX) {
|
|
92
|
-
|
|
92
|
+
// The page may itself sit under another proxy's prefix: the bridge management server mounts
|
|
93
|
+
// the whole DSH site at /dsh/ (core/management_dsh_proxy.go) and strips it before DSH sees the
|
|
94
|
+
// request, so this route cannot know it. A fixed <base href="/collab/panel/"> then sends the
|
|
95
|
+
// panel's scripts to the management server's own /collab/panel/..., which answers with its
|
|
96
|
+
// index.html, and the frame stays blank. The inline script moves <base> to wherever the page
|
|
97
|
+
// was actually loaded from, before any asset URL is resolved (it runs ahead of the panel's
|
|
98
|
+
// module scripts, and basePath.ts reads document.baseURI only when they run).
|
|
99
|
+
const mount = JSON.stringify(`${prefix}/`).replace(/</g, '\\u003c');
|
|
100
|
+
const fix = `<script>(()=>{const m=${mount},p=location.pathname,i=p.indexOf(m);if(i>0)document.querySelector("base").setAttribute("href",p.slice(0,i)+m)})()</script>`;
|
|
101
|
+
const head = `<base href="${prefix}/" />${fix}\n <meta name="bothouse-auth" content="proxy" />`;
|
|
93
102
|
if (/<base\s[^>]*>/i.test(html)) return html.replace(/<base\s[^>]*>/i, head);
|
|
94
103
|
return html.replace(/<head(\s[^>]*)?>/i, m => `${m}\n ${head}`);
|
|
95
104
|
}
|
package/collab/plugin.mjs
CHANGED
|
@@ -4,19 +4,21 @@ 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?, bridgeConfig? }
|
|
7
|
+
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, botPort?, 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';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { Store, CollabError, publicMessage } from './store.mjs';
|
|
13
|
+
import { startBotPort } from './bot-port.mjs';
|
|
13
14
|
import { Hub } from './hub.mjs';
|
|
14
15
|
import { TeamExit } from './team-exit.mjs';
|
|
15
16
|
import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
|
|
16
17
|
import { DshMembers } from './dsh-member.mjs';
|
|
17
|
-
import { Roster } from './roster.mjs';
|
|
18
|
+
import { Roster, rowToMember } from './roster.mjs';
|
|
18
19
|
import { Tasks, WORKING } from './tasks.mjs';
|
|
19
20
|
import { createCliMember } from './cli-member.mjs';
|
|
21
|
+
import { MemorySources } from './memory-sources.mjs';
|
|
20
22
|
import { avatarImage, checkAvatar } from './avatars.mjs';
|
|
21
23
|
import { AvatarUploads } from './avatar-uploads.mjs';
|
|
22
24
|
import { handler as clisHandler } from './clis.mjs';
|
|
@@ -28,6 +30,8 @@ import { DeskEvents, DESK_NAMES_FILE, deskRoute } from './desk.mjs';
|
|
|
28
30
|
import { wikiGalaxyRoute } from './wiki-galaxy.mjs';
|
|
29
31
|
import { defaultBridgeConfig, panelRoute } from './panel.mjs';
|
|
30
32
|
import { TeamStatus } from './team-status.mjs';
|
|
33
|
+
import { ALIVE_EVERY_MS, PairClient, PairHost, PairListener, PAIR_PORT, REMOTE_OWNER, hostHandler, isLoopback, pairApiRoute, remoteProxyRoute, tailnetAddress } from './pairing.mjs';
|
|
34
|
+
import { homedir, hostname } from 'node:os';
|
|
31
35
|
|
|
32
36
|
export const name = 'conest-collab';
|
|
33
37
|
export const inject = [
|
|
@@ -36,6 +40,8 @@ export const inject = [
|
|
|
36
40
|
];
|
|
37
41
|
|
|
38
42
|
const PAGE = readFileSync(new URL('./page.html', import.meta.url), 'utf8');
|
|
43
|
+
// #131: 「其他电脑」page — pair this DSH with another one by a one-time number.
|
|
44
|
+
const DEVICES_PAGE = readFileSync(new URL('./devices.html', import.meta.url), 'utf8');
|
|
39
45
|
const MAX_BODY = 256 * 1024;
|
|
40
46
|
// #144: a message POST may carry base64 attachments. Same cap as the bot WebSocket
|
|
41
47
|
// maxPayload below (8 MiB), so a message that is accepted here still fits in the frame
|
|
@@ -79,7 +85,11 @@ export function resolveConfig(config, env = process.env) {
|
|
|
79
85
|
if (!Number.isSafeInteger(maxRelay) || maxRelay < 0) throw Error('conest-collab: maxRelay must be a non-negative integer');
|
|
80
86
|
const botTokenEnv = c.botTokenEnv ?? 'CONEST_COLLAB_BOT_TOKEN';
|
|
81
87
|
const botToken = env[botTokenEnv] ?? '';
|
|
82
|
-
|
|
88
|
+
// #142 桌面版宿主端口每次随机:给一个固定端口另开 bot 接入(bot-port.mjs)。配置或 CONEST_COLLAB_BOT_PORT,都没给就不开。
|
|
89
|
+
const botPortRaw = c.botPort ?? (String(env.CONEST_COLLAB_BOT_PORT ?? '').trim() || undefined);
|
|
90
|
+
const botPort = botPortRaw === undefined ? undefined : Number(botPortRaw);
|
|
91
|
+
if (botPort !== undefined && !(Number.isSafeInteger(botPort) && botPort > 0 && botPort < 65536)) throw Error('conest-collab: botPort must be a TCP port number');
|
|
92
|
+
const leadFile = c.leadFile ?? join(env.BOTHOUSE_DATA_DIR ?? join(env.HOME || homedir(), '.bothouse'), 'lead.json');
|
|
83
93
|
if (!isAbsolute(leadFile)) throw Error('conest-collab: leadFile must be an absolute path');
|
|
84
94
|
const fallbackLead = String(c.fallbackLead ?? env.CONEST_FALLBACK_LEAD ?? '').trim();
|
|
85
95
|
// #876: board events sit next to board.jsonl; BOARD_DIR is the same switch gate/board.py reads,
|
|
@@ -97,7 +107,7 @@ export function resolveConfig(config, env = process.env) {
|
|
|
97
107
|
// #105 侧栏「bothouse」:读这份主桥配置的 [management] 段,在服务端转发管理面板(令牌不进浏览器)。
|
|
98
108
|
const bridgeConfig = String(c.bridgeConfig ?? '').trim() || defaultBridgeConfig(env);
|
|
99
109
|
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 ?? '我' };
|
|
110
|
+
return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, bridgeConfig, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, botPort, ownerName: c.ownerName ?? '我' };
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/** Resolve a package from the running DSH installation, never a second copy. */
|
|
@@ -132,6 +142,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
132
142
|
store.migrateMemberRooms();
|
|
133
143
|
const leadState = new LeadState(config.leadFile, ctx.logger);
|
|
134
144
|
const hub = new Hub(store, ctx.logger, leadState, config.fallbackLead);
|
|
145
|
+
hub.memorySources = new MemorySources({ logger: ctx.logger }); // #1020 回复的记忆来源
|
|
135
146
|
hub.workspacePath = config.workspacePath;
|
|
136
147
|
hub.teamExit = new TeamExit(hub);
|
|
137
148
|
hub.setMember({ id: OWNER, name: config.ownerName, kind: 'human', online: true });
|
|
@@ -179,24 +190,47 @@ export async function apply(ctx, rawConfig) {
|
|
|
179
190
|
teamStatus?.start();
|
|
180
191
|
if (!config.botToken) ctx.logger.warn(`conest-collab: ${config.botTokenEnv} is unset; cc-connect bots cannot connect`);
|
|
181
192
|
|
|
182
|
-
|
|
193
|
+
// #131: a request the tailnet pairing listener authenticated with a device credential acts as
|
|
194
|
+
// the owner; everything else still needs this DSH's own sign-in.
|
|
195
|
+
const owner = req => req[REMOTE_OWNER] === true || ctx.connection.requestRejection(req) === undefined;
|
|
196
|
+
// Test-only overrides (same-machine two-instance check): address and port of the pairing listener.
|
|
197
|
+
const pairAddr = String(process.env.CONEST_COLLAB_PAIR_ADDR ?? '').trim();
|
|
198
|
+
const pairPort = Number(process.env.CONEST_COLLAB_PAIR_PORT) || PAIR_PORT;
|
|
199
|
+
const pairHost = new PairHost({ file: join(config.dataDir, 'pair-devices.json') });
|
|
200
|
+
const pairClient = new PairClient({ file: join(config.dataDir, 'pair-links.json'), port: pairPort });
|
|
201
|
+
const pairListener = new PairListener({
|
|
202
|
+
host: pairHost, port: pairPort, logger: ctx.logger,
|
|
203
|
+
address: pairAddr === '' ? () => tailnetAddress() : async () => pairAddr,
|
|
204
|
+
handler: hostHandler({ host: pairHost, name: hostname(), route: (req, res) => browserRoute(req, res), allowLoopback: isLoopback(pairAddr) }),
|
|
205
|
+
});
|
|
206
|
+
if (pairHost.list().length > 0) pairListener.ensure().catch(err => ctx.logger.warn('conest-collab: pairing listener: %s', err.message));
|
|
207
|
+
// 来源:码有效期按分钟计(pairing.mjs CODE_TTL_MS),每分钟收一次没人用的配对监听。
|
|
208
|
+
const pairSweep = setInterval(() => { pairListener.settle().catch(() => {}); }, 60_000);
|
|
209
|
+
pairSweep.unref?.();
|
|
210
|
+
// 「直到我断开」: drop links whose DSH window stopped beating (same cadence as the beat).
|
|
211
|
+
const aliveSweep = setInterval(() => { pairClient.sweep().catch(() => {}); }, ALIVE_EVERY_MS);
|
|
212
|
+
aliveSweep.unref?.();
|
|
183
213
|
const wss = new WebSocketServer({ noServer: true, maxPayload: BOT_WS_MAX_PAYLOAD });
|
|
184
214
|
const streams = new Set();
|
|
185
215
|
|
|
186
216
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab', handler: (req, res) => browserRoute(req, res) }), 'conest-collab: /collab');
|
|
187
217
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/rooms', handler: (req, res) => historyRoute(req, res) }), 'conest-collab: /api/rooms');
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
const botUpgrade = (req, socket, head) => {
|
|
219
|
+
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
220
|
+
later.origin ??= localOrigin(req);
|
|
221
|
+
if (!gateway.authorized(req, url)) {
|
|
222
|
+
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
wss.handleUpgrade(req, socket, head, ws => gateway.accept(ws, url));
|
|
226
|
+
};
|
|
227
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({ path: WS_PATH, handler: botUpgrade }), 'conest-collab: cloud-web upgrade');
|
|
228
|
+
if (config.botPort !== undefined && config.botToken) {
|
|
229
|
+
const listening = startBotPort({ port: config.botPort, path: WS_PATH, upgrade: botUpgrade, history: (req, res) => historyRoute(req, res), logger: ctx.logger })
|
|
230
|
+
.then(l => { ctx.logger.info('conest-collab: bot port 127.0.0.1:%d', l.port); return l; },
|
|
231
|
+
err => { ctx.logger.warn('conest-collab: bot port %d not opened: %s', config.botPort, err.message); return undefined; });
|
|
232
|
+
ctx.effect(() => async () => { (await listening)?.close(); }, 'conest-collab: bot port');
|
|
233
|
+
}
|
|
200
234
|
ctx.effect(() => async () => {
|
|
201
235
|
teamStatus?.stop();
|
|
202
236
|
desk.stop();
|
|
@@ -204,6 +238,9 @@ export async function apply(ctx, rawConfig) {
|
|
|
204
238
|
later.close();
|
|
205
239
|
tasks.close();
|
|
206
240
|
gateway.closeAll();
|
|
241
|
+
clearInterval(pairSweep);
|
|
242
|
+
clearInterval(aliveSweep);
|
|
243
|
+
await pairListener.close();
|
|
207
244
|
for (const end of streams) end();
|
|
208
245
|
wss.close();
|
|
209
246
|
// Drain DSH jobs first: a job still awaiting its Agent must not touch a closed store.
|
|
@@ -229,13 +266,16 @@ export async function apply(ctx, rawConfig) {
|
|
|
229
266
|
async function browserRoute(req, res) {
|
|
230
267
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
231
268
|
const path = url.pathname.replace(/\/+$/, '');
|
|
232
|
-
|
|
269
|
+
// A paired device reaches us on the tailnet listener; ticket URLs stay on this machine's own origin.
|
|
270
|
+
if (!req[REMOTE_OWNER]) later.origin ??= localOrigin(req);
|
|
233
271
|
const scheduled = laterRoute(req, res, url, { hub, gateway, owner, later });
|
|
234
272
|
if (scheduled !== undefined) return scheduled;
|
|
235
273
|
const routed = tasksRoute(req, res, url, { tasks, gateway, owner });
|
|
236
274
|
if (routed !== undefined) return routed;
|
|
237
275
|
const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh });
|
|
238
276
|
if (opened !== undefined) return opened;
|
|
277
|
+
const team = teamRoute(req, res, url, { store, hub, later, roster });
|
|
278
|
+
if (team !== undefined) return team;
|
|
239
279
|
if (!owner(req)) {
|
|
240
280
|
if ((path === '/collab' || path === '/collab/desk' || path === '/collab/wiki-galaxy') && req.method === 'GET') return text(res, 401, 'text/plain; charset=utf-8', '请先在同一个浏览器打开 DSH 网页登录,再回到 /collab。');
|
|
241
281
|
return refuse(req, res);
|
|
@@ -247,6 +287,14 @@ export async function apply(ctx, rawConfig) {
|
|
|
247
287
|
if (galaxyHandled !== undefined) return galaxyHandled;
|
|
248
288
|
const panelHandled = panelRoute(req, res, url, { bridgeConfig: config.bridgeConfig });
|
|
249
289
|
if (panelHandled !== undefined) return panelHandled;
|
|
290
|
+
// #131: pairing (this machine lets others in) and remote links (this machine drives others).
|
|
291
|
+
if (!req[REMOTE_OWNER]) {
|
|
292
|
+
if (path === '/collab/devices' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', DEVICES_PAGE);
|
|
293
|
+
const paired = pairApiRoute(req, res, url, { host: pairHost, client: pairClient, listener: pairListener });
|
|
294
|
+
if (paired !== undefined) return paired;
|
|
295
|
+
const remote = remoteProxyRoute(req, res, url, { client: pairClient });
|
|
296
|
+
if (remote !== undefined) return remote;
|
|
297
|
+
}
|
|
250
298
|
if (path === '/collab' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', PAGE);
|
|
251
299
|
if (path === '/collab/api/personas' && req.method === 'GET') return json(res, 200, personaLibrary(url.searchParams.get('lang') ?? 'zh'));
|
|
252
300
|
if (path === '/collab/api/state' && req.method === 'GET') {
|
|
@@ -254,7 +302,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
254
302
|
effective_lead_id: hub.primaryMember()?.id ?? null,
|
|
255
303
|
rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.primaryIn(r.id)?.id ?? null,
|
|
256
304
|
workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
|
|
257
|
-
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]]))
|
|
305
|
+
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
|
|
306
|
+
queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])) });
|
|
258
307
|
}
|
|
259
308
|
if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
|
|
260
309
|
if (path === '/collab/api/lead' && req.method === 'GET') {
|
|
@@ -407,6 +456,17 @@ export async function apply(ctx, rawConfig) {
|
|
|
407
456
|
return { room };
|
|
408
457
|
});
|
|
409
458
|
}
|
|
459
|
+
// #1023: open (or reopen) the private chat with one agent member.
|
|
460
|
+
if (path === '/collab/api/dms' && req.method === 'POST') {
|
|
461
|
+
return withBody(req, res, (body) => {
|
|
462
|
+
const id = String(body?.member ?? '');
|
|
463
|
+
const member = hub.members.get(id);
|
|
464
|
+
if (member === undefined || member.kind === 'human') throw new CollabError('invalid', 'member must be an agent in this channel list');
|
|
465
|
+
const room = store.openDm(id, member.name);
|
|
466
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
467
|
+
return { room };
|
|
468
|
+
});
|
|
469
|
+
}
|
|
410
470
|
if (path === '/collab/api/members' && req.method === 'POST') {
|
|
411
471
|
return withBody(req, res, async body => ({ member: await roster.create(body) }));
|
|
412
472
|
}
|
|
@@ -448,7 +508,10 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
448
508
|
if (req.method === 'GET') return Promise.resolve(json(res, 200, view()));
|
|
449
509
|
if (req.method !== 'PATCH') return undefined;
|
|
450
510
|
return withBody(req, res, body => {
|
|
451
|
-
|
|
511
|
+
// The settings dialog's「未选择(按原配置)」option sends the select's empty value: clear the
|
|
512
|
+
// channel's own choice so the primary falls back to the instance-wide one, exactly like null.
|
|
513
|
+
const leadId = body?.lead_id === '' ? null : body?.lead_id;
|
|
514
|
+
if (leadId !== undefined && leadId !== null && !hub.routableIn(roomId).some(m => m.id === leadId)) {
|
|
452
515
|
throw new CollabError('invalid', '请选择本频道的主角色');
|
|
453
516
|
}
|
|
454
517
|
if (body?.workspace_path !== undefined) {
|
|
@@ -459,7 +522,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
459
522
|
if (!stat.isDirectory()) throw new CollabError('invalid', '工作目录必须是目录,不能是文件');
|
|
460
523
|
}
|
|
461
524
|
store.transaction(() => {
|
|
462
|
-
if (
|
|
525
|
+
if (leadId !== undefined) store.setLead(roomId, leadId);
|
|
463
526
|
if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
|
|
464
527
|
// #445: 6–20 only; an invalid value rolls the whole PATCH back.
|
|
465
528
|
if (body?.max_relay !== undefined) store.setMaxRelay(roomId, body.max_relay);
|
|
@@ -635,6 +698,74 @@ function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks,
|
|
|
635
698
|
});
|
|
636
699
|
}
|
|
637
700
|
|
|
701
|
+
/**
|
|
702
|
+
* 一句话拉团队 (user 11-04: DSH 用户一句话拉单模型多角色): the primary role of a channel, with its
|
|
703
|
+
* (member, room) later ticket, creates a new chat channel and its roles in one call.
|
|
704
|
+
* POST /collab/api/rooms/{id}/team { channel, members: [{ name, persona?, model? }], model? }
|
|
705
|
+
* model is a model id ("deepseek-v4.1-flash") or "provider/model"; absent = DSH's default model.
|
|
706
|
+
* The new roles join only the new channel; the caller becomes its primary role and joins it too.
|
|
707
|
+
*/
|
|
708
|
+
export function teamRoute(req, res, url, { store, hub, later, roster }) {
|
|
709
|
+
const m = /^\/collab\/api\/rooms\/(\d+)\/team\/?$/.exec(url.pathname);
|
|
710
|
+
if (m === null || req.method !== 'POST') return undefined;
|
|
711
|
+
const claims = req.headers['x-later-ticket'] === undefined ? undefined : later?.verify(req.headers['x-later-ticket']);
|
|
712
|
+
if (!claims) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
|
|
713
|
+
const roomId = Number(m[1]);
|
|
714
|
+
if (claims.room !== roomId) return Promise.resolve(json(res, 403, { error: 'ticket is for another room' }));
|
|
715
|
+
if (hub.primaryIn(roomId)?.id !== claims.member) return Promise.resolve(json(res, 403, { error: 'only the primary role builds teams' }));
|
|
716
|
+
return withBody(req, res, async body => buildTeam(body, claims.member, { store, hub, roster }));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const TEAM_AVATARS = ['orange', 'dragon', 'rainbow', 'lanxi', 'whale'];
|
|
720
|
+
const MAX_TEAM = 8;
|
|
721
|
+
|
|
722
|
+
/** Find a model by id or provider/id among DSH's routes; undefined input = DSH default (no model on the member). */
|
|
723
|
+
export async function resolveTeamModel(roster, wanted) {
|
|
724
|
+
const want = String(wanted ?? '').trim();
|
|
725
|
+
if (!want) return undefined;
|
|
726
|
+
const routes = await roster.models();
|
|
727
|
+
const slash = want.indexOf('/');
|
|
728
|
+
for (const route of routes) {
|
|
729
|
+
for (const model of route.models) {
|
|
730
|
+
const exact = slash > 0 && route.provider === want.slice(0, slash) && model.id === want.slice(slash + 1);
|
|
731
|
+
if (exact || model.id.toLowerCase() === want.toLowerCase()) return { provider: route.provider, model: model.id };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const known = routes.flatMap(r => r.models.map(x => x.id)).slice(0, 20).join(', ') || '(DSH 没报出可用模型)';
|
|
735
|
+
throw new CollabError('invalid', `模型 ${want} 在 DSH 里找不到;可用:${known}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export async function buildTeam(body, leadId, { store, hub, roster }) {
|
|
739
|
+
const channel = String(body?.channel ?? '').trim().replace(/^#/, '');
|
|
740
|
+
if (!channel || channel.length > 32 || /\s/.test(channel)) throw new CollabError('invalid', 'channel must be 1-32 characters without spaces');
|
|
741
|
+
const wanted = Array.isArray(body?.members) ? body.members : [];
|
|
742
|
+
if (wanted.length === 0 || wanted.length > MAX_TEAM) throw new CollabError('invalid', `members must list 1-${MAX_TEAM} roles`);
|
|
743
|
+
const names = wanted.map(x => String(x?.name ?? '').trim());
|
|
744
|
+
if (names.some(n => !n) || new Set(names.map(n => n.toLowerCase())).size !== names.length) throw new CollabError('invalid', 'every role needs a distinct name');
|
|
745
|
+
const shared = await resolveTeamModel(roster, body?.model);
|
|
746
|
+
const models = [];
|
|
747
|
+
for (const x of wanted) models.push(x?.model === undefined || x?.model === '' ? shared : await resolveTeamModel(roster, x.model));
|
|
748
|
+
// Check every role before anything is written, so a bad name leaves no half-built channel behind.
|
|
749
|
+
for (const [i, x] of wanted.entries()) await roster.validate({ name: names[i], persona: String(x?.persona ?? ''), avatar: TEAM_AVATARS[i % TEAM_AVATARS.length] });
|
|
750
|
+
const room = store.createRoom(channel);
|
|
751
|
+
const created = [];
|
|
752
|
+
for (const [i, x] of wanted.entries()) {
|
|
753
|
+
const model = models[i];
|
|
754
|
+
created.push(await roster.create({ name: names[i], persona: String(x?.persona ?? ''), rooms: [room.id], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length], ...(model ? { model } : {}) }));
|
|
755
|
+
}
|
|
756
|
+
const lead = store.member(leadId);
|
|
757
|
+
if (lead?.source === 'ui' && lead.rooms && lead.rooms !== '[]') {
|
|
758
|
+
await roster.update(leadId, { ...rowToMember(lead), rooms: [...JSON.parse(lead.rooms), room.id] });
|
|
759
|
+
}
|
|
760
|
+
store.setLead(room.id, leadId);
|
|
761
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
762
|
+
const label = model => (model ? model.model : 'DSH 默认模型');
|
|
763
|
+
const leadName = hub.members.get(leadId)?.name ?? leadId;
|
|
764
|
+
hub.post(room.id, { authorKind: 'system', author: 'system', name: '建团队',
|
|
765
|
+
text: `【已建团队】${leadName} 拉好了 #${channel}:${created.map((c, i) => `${c.name}(${label(models[i])})`).join('、')}。${leadName} 是本频道主角色,直接在这里说需求。` });
|
|
766
|
+
return { room, lead: leadId, members: created.map((c, i) => ({ id: c.id, name: c.name, model: label(models[i]) })) };
|
|
767
|
+
}
|
|
768
|
+
|
|
638
769
|
const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing': 404, conflict: 409, 'read-only': 403, forbidden: 403, offline: 503, 'member-busy': 409 };
|
|
639
770
|
|
|
640
771
|
/** Parse a JSON body (unless `parse` is false), run fn, and map CollabError codes to HTTP statuses. */
|
|
@@ -665,9 +796,14 @@ export function parseRange(header, size) {
|
|
|
665
796
|
|
|
666
797
|
/** Serves a stored attachment; honours single byte ranges so iOS Safari can play audio. */
|
|
667
798
|
export function sendAttachment(req, res, row, bytes) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
799
|
+
// #1023: a delivered web page (`?view=page`) opens inline for the delivery card's frame. The CSP
|
|
800
|
+
// sandbox gives it an opaque origin even when opened in its own tab, so its scripts never act
|
|
801
|
+
// with the owner's sign-in on this origin.
|
|
802
|
+
const page = /[?&]view=page(?:&|$)/.test(req.url ?? '') && (/^text\/html\b/i.test(row.mime) || /\.html?$/i.test(row.name));
|
|
803
|
+
const headers = { 'content-type': page ? 'text/html; charset=utf-8' : row.mime, 'accept-ranges': 'bytes',
|
|
804
|
+
'content-disposition': `${page || INLINE_IMAGE.test(row.mime) ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
|
|
805
|
+
'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=86400',
|
|
806
|
+
...(page ? { 'content-security-policy': 'sandbox allow-scripts allow-forms allow-popups allow-modals' } : {}) };
|
|
671
807
|
const range = parseRange(req.headers?.range, bytes.length);
|
|
672
808
|
if (range === null) {
|
|
673
809
|
res.writeHead(416, { ...headers, 'content-range': `bytes */${bytes.length}`, 'content-length': 0 });
|
package/collab/roster.mjs
CHANGED
|
@@ -64,6 +64,11 @@ export class Roster {
|
|
|
64
64
|
* as UI members, once: names already in the channel are skipped, then the file becomes *.imported.json with
|
|
65
65
|
* each role's member id (the new one, or the one already holding the name), so bothouse names the role's wiki
|
|
66
66
|
* after the id the member really has (bridge core/setup_dsh_wiki.go). Returns the names added.
|
|
67
|
+
* A default-named role (name_customized false) reuses a DSH member a former run made for it — same CLI,
|
|
68
|
+
* still automatic, named the role's name or name-N — so re-running the wizard does not add Codex-2, Codex-3…
|
|
69
|
+
* (11-05 Windows report); the one named exactly the role's name goes first, then the oldest. Each member
|
|
70
|
+
* serves one role per run, so two default Codex roles still get two members. The imported entry carries the
|
|
71
|
+
* member's real name, which the bridge writes into gate-team.json and lead.json.
|
|
67
72
|
*/
|
|
68
73
|
async importWizard(path, logger) {
|
|
69
74
|
if (!existsSync(path)) return [];
|
|
@@ -76,19 +81,22 @@ export class Roster {
|
|
|
76
81
|
}
|
|
77
82
|
const added = [];
|
|
78
83
|
const imported = [];
|
|
84
|
+
const claimed = new Set();
|
|
79
85
|
for (const m of Array.isArray(members) ? members : []) {
|
|
80
86
|
const name = String(m?.name ?? '').trim();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (same !== undefined
|
|
85
|
-
|
|
87
|
+
// Custom and legacy names stay idempotent by name (a re-import must not duplicate them).
|
|
88
|
+
const same = m?.name_customized === false ? this.reusableWizardMember(name, m?.cli, claimed)
|
|
89
|
+
: this.hub.memberList().find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
90
|
+
if (same !== undefined) {
|
|
91
|
+
claimed.add(same.id);
|
|
92
|
+
imported.push({ ...m, name: same.name, id: same.id });
|
|
86
93
|
continue;
|
|
87
94
|
}
|
|
88
95
|
try {
|
|
89
96
|
const avatar = m?.avatar && ['orange', 'whale', 'dragon', 'rainbow', 'lanxi'].includes(m.avatar) ? m.avatar : undefined;
|
|
90
97
|
const member = await this.create({ name, persona: m?.persona ?? '', ...(m?.cli ? { cli: m.cli } : {}), ...(m?.name_customized === undefined ? {} : { nameCustomized: Boolean(m.name_customized) }), ...(avatar ? { avatar } : {}) });
|
|
91
|
-
|
|
98
|
+
claimed.add(member.id);
|
|
99
|
+
imported.push({ ...m, name: member.name, id: member.id });
|
|
92
100
|
added.push(member.name);
|
|
93
101
|
} catch (error) {
|
|
94
102
|
imported.push(m);
|
|
@@ -100,6 +108,19 @@ export class Roster {
|
|
|
100
108
|
return added;
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
/** A UI member an earlier wizard run made for the default-named role `name` (see importWizard), or undefined. */
|
|
112
|
+
reusableWizardMember(name, cli, claimed) {
|
|
113
|
+
const stem = name.toLowerCase();
|
|
114
|
+
// The exact name is the same role whatever its flag says: members seeded on the plugin's first start
|
|
115
|
+
// (seedDefaults) and those made before name_customized was stored carry none, and skipping them
|
|
116
|
+
// made every re-run add 鲸鱼娘-2 / DSH-2. A suffixed name (Codex-2) is reused only when it is marked automatic.
|
|
117
|
+
const fits = this.store.members().filter(r => r.source === 'ui' && !claimed.has(r.id) && (r.cli ?? '') === (cli ?? '')
|
|
118
|
+
&& (r.name.toLowerCase() === stem
|
|
119
|
+
|| (r.name_customized != null && !Boolean(r.name_customized) && r.name.toLowerCase().replace(/-\d+$/, '') === stem)));
|
|
120
|
+
const pick = fits.find(r => r.name.toLowerCase() === stem) ?? fits[0];
|
|
121
|
+
return pick === undefined ? undefined : rowToMember(pick);
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
/**
|
|
104
125
|
* Plugin-only installs (`dsh plugin add bothouse-dsh`) have no setup wizard: on the very first start,
|
|
105
126
|
* with no member ever stored, add one preset role (鲸鱼娘) as a DSH member on DSH's own default model, so
|
package/collab/store.mjs
CHANGED
|
@@ -56,6 +56,10 @@ const COLUMNS = [
|
|
|
56
56
|
['rooms', 'claim_seq', 'INTEGER NOT NULL DEFAULT 0'],
|
|
57
57
|
['rooms', 'workspace_path', 'TEXT'], // Optional chat directory; workspace rooms inherit their current parent.
|
|
58
58
|
['messages', 'edited_at', 'REAL'], // v11 (#148): when the owner last edited their own message; NULL if never.
|
|
59
|
+
// #1020 (no version bump, so an older build still opens the file): JSON list of the memory entries a reply used ({id, ref, page, title}); NULL when none.
|
|
60
|
+
['messages', 'memories', 'TEXT'],
|
|
61
|
+
// #1023 (no version bump): a private chat is a chat room with exactly one agent member; NULL for every other room.
|
|
62
|
+
['rooms', 'dm_member', 'TEXT'],
|
|
59
63
|
// #597 G34: when a role last changed the row in place (a streamed draft growing, a progress card
|
|
60
64
|
// edited, the draft committed); NULL if never. The task watch counts it as progress (Tasks.progressed).
|
|
61
65
|
['messages', 'changed_at', 'REAL'],
|
|
@@ -90,6 +94,7 @@ export class Store {
|
|
|
90
94
|
this.q = {
|
|
91
95
|
room: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms WHERE id = ?'),
|
|
92
96
|
rooms: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms ORDER BY id'),
|
|
97
|
+
dms: this.db.prepare('SELECT id, dm_member FROM rooms WHERE dm_member IS NOT NULL'),
|
|
93
98
|
children: this.db.prepare("SELECT id, name FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id"),
|
|
94
99
|
// A new workspace is claimed with no task yet (claim_task 0): its first task is the handoff.
|
|
95
100
|
createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge, claim_at)
|
|
@@ -104,6 +109,7 @@ export class Store {
|
|
|
104
109
|
byId: this.db.prepare('SELECT * FROM messages WHERE id = ? AND deleted = 0'),
|
|
105
110
|
edit: this.db.prepare('UPDATE messages SET text = ?, card = ?, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
106
111
|
commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, attachments = ?, draft = 0, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
|
|
112
|
+
setMemories: this.db.prepare('UPDATE messages SET memories = ? WHERE id = ? RETURNING *'),
|
|
107
113
|
editUser: this.db.prepare(`UPDATE messages SET text = ?, edited_at = ? WHERE id = ? AND author_kind = 'user' AND author = ?
|
|
108
114
|
AND deleted = 0 AND draft = 0 AND card IS NULL RETURNING *`),
|
|
109
115
|
del: this.db.prepare('UPDATE messages SET deleted = 1 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
@@ -121,6 +127,10 @@ export class Store {
|
|
|
121
127
|
AND draft = 1 AND deleted = 0 AND card IS NULL`),
|
|
122
128
|
authorDrafts: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
|
|
123
129
|
AND draft = 1 AND deleted = 0 AND card IS NULL ORDER BY id`),
|
|
130
|
+
// #913: every live card row one author posted, in every room. A restarted bridge
|
|
131
|
+
// holds no handle on them, so they must be found without a message window.
|
|
132
|
+
authorCards: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
|
|
133
|
+
AND card IS NOT NULL AND deleted = 0 ORDER BY id`),
|
|
124
134
|
addJob: this.db.prepare(`INSERT INTO jobs (member_id, room_id, trigger_id, state, created_at)
|
|
125
135
|
VALUES (?, ?, ?, 'pending', ?) ON CONFLICT(member_id, trigger_id) DO NOTHING RETURNING *`),
|
|
126
136
|
job: this.db.prepare('SELECT * FROM jobs WHERE id = ?'),
|
|
@@ -382,6 +392,24 @@ export class Store {
|
|
|
382
392
|
this.ensureRoomSettings(room.id);
|
|
383
393
|
return room;
|
|
384
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* #1023: the private chat with one agent member — its existing room, or a new chat room whose
|
|
397
|
+
* only agent is that member (Hub.membersInRoom). Same room every time, so the session carries on.
|
|
398
|
+
*/
|
|
399
|
+
openDm(memberId, name) {
|
|
400
|
+
if (typeof memberId !== 'string' || memberId === '') throw new CollabError('invalid', 'member id required');
|
|
401
|
+
const room = this.transaction(() => {
|
|
402
|
+
const found = this.db.prepare('SELECT id FROM rooms WHERE dm_member = ? ORDER BY id LIMIT 1').get(memberId);
|
|
403
|
+
if (found !== undefined) return { ...this.q.room.get(found.id), dm_member: memberId };
|
|
404
|
+
const created = this.q.createRoom.get(String(name || memberId).replace(/\s+/g, '').slice(0, 32) || memberId);
|
|
405
|
+
this.db.prepare('UPDATE rooms SET dm_member = ? WHERE id = ?').run(memberId, created.id);
|
|
406
|
+
return { ...created, dm_member: memberId };
|
|
407
|
+
});
|
|
408
|
+
this.ensureRoomSettings(room.id);
|
|
409
|
+
return room;
|
|
410
|
+
}
|
|
411
|
+
/** #1023: the agent member a private chat room is with; undefined for every other room. */
|
|
412
|
+
dmMember(roomId) { return this.db.prepare('SELECT dm_member FROM rooms WHERE id = ?').get(roomId)?.dm_member ?? undefined; }
|
|
385
413
|
/**
|
|
386
414
|
* Give a chat channel its next workspace: the lowest-numbered free 工作区<k> of the shared
|
|
387
415
|
* pool (released by any chat channel after its last task) if there is one, else a new
|
|
@@ -623,7 +651,11 @@ export class Store {
|
|
|
623
651
|
return saved || this.q.workspacePath.get(roomId)?.path || fallback;
|
|
624
652
|
}
|
|
625
653
|
room(id) { return this.q.room.get(id); }
|
|
626
|
-
rooms() {
|
|
654
|
+
rooms() {
|
|
655
|
+
// #1023: only a private chat carries dm_member, so every other room keeps its old shape.
|
|
656
|
+
const dms = new Map(this.q.dms.all().map(r => [r.id, r.dm_member]));
|
|
657
|
+
return this.q.rooms.all().map(r => (dms.has(r.id) ? { ...r, dm_member: dms.get(r.id) } : r));
|
|
658
|
+
}
|
|
627
659
|
/** Return stale, empty claims to the pool without taking bridge or paused work. */
|
|
628
660
|
releaseIdleWorkspaces() {
|
|
629
661
|
const idle = this.db.prepare(`SELECT id FROM rooms WHERE kind = 'workspace' AND free = 0
|
|
@@ -672,6 +704,9 @@ export class Store {
|
|
|
672
704
|
|
|
673
705
|
message(id) { return this.q.byId.get(id); }
|
|
674
706
|
|
|
707
|
+
/** #1020: record the memory entries a reply used; returns the updated row. */
|
|
708
|
+
setMemories(id, items) { return this.q.setMemories.get(items?.length ? JSON.stringify(items) : null, id); }
|
|
709
|
+
|
|
675
710
|
/**
|
|
676
711
|
* What a reply quotes: undefined for a plain message; `{ id, deleted: true }`
|
|
677
712
|
* when the original is gone (no author or text leaks); else id, author, and
|
|
@@ -721,6 +756,10 @@ export class Store {
|
|
|
721
756
|
// WebSocket close events may arrive after plugin teardown has closed SQLite.
|
|
722
757
|
return this.closed ? [] : this.q.authorDrafts.all(authorKind, author);
|
|
723
758
|
}
|
|
759
|
+
/** Every live progress-card row one author posted (#913), oldest first. */
|
|
760
|
+
authorCards(authorKind, author) {
|
|
761
|
+
return this.closed ? [] : this.q.authorCards.all(authorKind, author);
|
|
762
|
+
}
|
|
724
763
|
|
|
725
764
|
/** Up to `limit` messages before `beforeId` (exclusive), oldest first. */
|
|
726
765
|
page(roomId, beforeId, limit) {
|
|
@@ -794,9 +833,17 @@ export function publicMessage(row, store) {
|
|
|
794
833
|
if (quote !== undefined) out.reply_to = quote;
|
|
795
834
|
const attachments = messageAttachments(row);
|
|
796
835
|
if (attachments.length) out.attachments = attachments;
|
|
836
|
+
const memories = messageMemories(row);
|
|
837
|
+
if (memories.length) out.memories = memories;
|
|
797
838
|
return out;
|
|
798
839
|
}
|
|
799
840
|
|
|
841
|
+
/** #1020: the memory entries a reply used ([] when none or unreadable). */
|
|
842
|
+
export function messageMemories(row) {
|
|
843
|
+
if (!row?.memories) return [];
|
|
844
|
+
try { const list = JSON.parse(row.memories); return Array.isArray(list) ? list : []; } catch { return []; }
|
|
845
|
+
}
|
|
846
|
+
|
|
800
847
|
/** #144: the uploaded files a message carries ([] when none or unreadable). */
|
|
801
848
|
export function messageAttachments(row) {
|
|
802
849
|
if (!row?.attachments) return [];
|