bothouse-dsh 0.1.46 → 0.1.48
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 +110 -4
- package/collab/cloudweb.mjs +34 -1
- package/collab/desk/index.html +995 -677
- package/collab/desk.mjs +52 -4
- package/collab/dsh-member.mjs +51 -5
- package/collab/later.mjs +21 -0
- package/collab/page.html +47 -9
- package/collab/panel.mjs +160 -0
- package/collab/plugin.mjs +86 -7
- package/collab/store.mjs +8 -0
- package/package.json +1 -1
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';
|
|
@@ -14,7 +14,7 @@ import { Hub } from './hub.mjs';
|
|
|
14
14
|
import { TeamExit } from './team-exit.mjs';
|
|
15
15
|
import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
|
|
16
16
|
import { DshMembers } from './dsh-member.mjs';
|
|
17
|
-
import { Roster } from './roster.mjs';
|
|
17
|
+
import { Roster, rowToMember } from './roster.mjs';
|
|
18
18
|
import { Tasks, WORKING } from './tasks.mjs';
|
|
19
19
|
import { createCliMember } from './cli-member.mjs';
|
|
20
20
|
import { avatarImage, checkAvatar } from './avatars.mjs';
|
|
@@ -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. */
|
|
@@ -232,15 +236,19 @@ export async function apply(ctx, rawConfig) {
|
|
|
232
236
|
if (routed !== undefined) return routed;
|
|
233
237
|
const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh });
|
|
234
238
|
if (opened !== undefined) return opened;
|
|
239
|
+
const team = teamRoute(req, res, url, { store, hub, later, roster });
|
|
240
|
+
if (team !== undefined) return team;
|
|
235
241
|
if (!owner(req)) {
|
|
236
242
|
if ((path === '/collab' || path === '/collab/desk' || path === '/collab/wiki-galaxy') && req.method === 'GET') return text(res, 401, 'text/plain; charset=utf-8', '请先在同一个浏览器打开 DSH 网页登录,再回到 /collab。');
|
|
237
243
|
return refuse(req, res);
|
|
238
244
|
}
|
|
239
245
|
// #876 P: 回放里的 @ 用 hub 的成员表解析(routable = 真实角色,不含主人)。
|
|
240
|
-
const deskHandled = deskRoute(req, res, url, { store, boardDir: config.boardDir, members: hub.routable() });
|
|
246
|
+
const deskHandled = deskRoute(req, res, url, { store, boardDir: config.boardDir, members: hub.routable(), namesFile: join(config.dataDir, DESK_NAMES_FILE) });
|
|
241
247
|
if (deskHandled !== undefined) return deskHandled;
|
|
242
248
|
const galaxyHandled = wikiGalaxyRoute(req, res, url, { wikiDir: config.wikiDir });
|
|
243
249
|
if (galaxyHandled !== undefined) return galaxyHandled;
|
|
250
|
+
const panelHandled = panelRoute(req, res, url, { bridgeConfig: config.bridgeConfig });
|
|
251
|
+
if (panelHandled !== undefined) return panelHandled;
|
|
244
252
|
if (path === '/collab' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', PAGE);
|
|
245
253
|
if (path === '/collab/api/personas' && req.method === 'GET') return json(res, 200, personaLibrary(url.searchParams.get('lang') ?? 'zh'));
|
|
246
254
|
if (path === '/collab/api/state' && req.method === 'GET') {
|
|
@@ -442,7 +450,10 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
442
450
|
if (req.method === 'GET') return Promise.resolve(json(res, 200, view()));
|
|
443
451
|
if (req.method !== 'PATCH') return undefined;
|
|
444
452
|
return withBody(req, res, body => {
|
|
445
|
-
|
|
453
|
+
// The settings dialog's「未选择(按原配置)」option sends the select's empty value: clear the
|
|
454
|
+
// channel's own choice so the primary falls back to the instance-wide one, exactly like null.
|
|
455
|
+
const leadId = body?.lead_id === '' ? null : body?.lead_id;
|
|
456
|
+
if (leadId !== undefined && leadId !== null && !hub.routableIn(roomId).some(m => m.id === leadId)) {
|
|
446
457
|
throw new CollabError('invalid', '请选择本频道的主角色');
|
|
447
458
|
}
|
|
448
459
|
if (body?.workspace_path !== undefined) {
|
|
@@ -453,7 +464,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
453
464
|
if (!stat.isDirectory()) throw new CollabError('invalid', '工作目录必须是目录,不能是文件');
|
|
454
465
|
}
|
|
455
466
|
store.transaction(() => {
|
|
456
|
-
if (
|
|
467
|
+
if (leadId !== undefined) store.setLead(roomId, leadId);
|
|
457
468
|
if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
|
|
458
469
|
// #445: 6–20 only; an invalid value rolls the whole PATCH back.
|
|
459
470
|
if (body?.max_relay !== undefined) store.setMaxRelay(roomId, body.max_relay);
|
|
@@ -629,6 +640,74 @@ function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks,
|
|
|
629
640
|
});
|
|
630
641
|
}
|
|
631
642
|
|
|
643
|
+
/**
|
|
644
|
+
* 一句话拉团队 (user 11-04: DSH 用户一句话拉单模型多角色): the primary role of a channel, with its
|
|
645
|
+
* (member, room) later ticket, creates a new chat channel and its roles in one call.
|
|
646
|
+
* POST /collab/api/rooms/{id}/team { channel, members: [{ name, persona?, model? }], model? }
|
|
647
|
+
* model is a model id ("deepseek-v4.1-flash") or "provider/model"; absent = DSH's default model.
|
|
648
|
+
* The new roles join only the new channel; the caller becomes its primary role and joins it too.
|
|
649
|
+
*/
|
|
650
|
+
export function teamRoute(req, res, url, { store, hub, later, roster }) {
|
|
651
|
+
const m = /^\/collab\/api\/rooms\/(\d+)\/team\/?$/.exec(url.pathname);
|
|
652
|
+
if (m === null || req.method !== 'POST') return undefined;
|
|
653
|
+
const claims = req.headers['x-later-ticket'] === undefined ? undefined : later?.verify(req.headers['x-later-ticket']);
|
|
654
|
+
if (!claims) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
|
|
655
|
+
const roomId = Number(m[1]);
|
|
656
|
+
if (claims.room !== roomId) return Promise.resolve(json(res, 403, { error: 'ticket is for another room' }));
|
|
657
|
+
if (hub.primaryIn(roomId)?.id !== claims.member) return Promise.resolve(json(res, 403, { error: 'only the primary role builds teams' }));
|
|
658
|
+
return withBody(req, res, async body => buildTeam(body, claims.member, { store, hub, roster }));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const TEAM_AVATARS = ['orange', 'dragon', 'rainbow', 'lanxi', 'whale'];
|
|
662
|
+
const MAX_TEAM = 8;
|
|
663
|
+
|
|
664
|
+
/** Find a model by id or provider/id among DSH's routes; undefined input = DSH default (no model on the member). */
|
|
665
|
+
export async function resolveTeamModel(roster, wanted) {
|
|
666
|
+
const want = String(wanted ?? '').trim();
|
|
667
|
+
if (!want) return undefined;
|
|
668
|
+
const routes = await roster.models();
|
|
669
|
+
const slash = want.indexOf('/');
|
|
670
|
+
for (const route of routes) {
|
|
671
|
+
for (const model of route.models) {
|
|
672
|
+
const exact = slash > 0 && route.provider === want.slice(0, slash) && model.id === want.slice(slash + 1);
|
|
673
|
+
if (exact || model.id.toLowerCase() === want.toLowerCase()) return { provider: route.provider, model: model.id };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const known = routes.flatMap(r => r.models.map(x => x.id)).slice(0, 20).join(', ') || '(DSH 没报出可用模型)';
|
|
677
|
+
throw new CollabError('invalid', `模型 ${want} 在 DSH 里找不到;可用:${known}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function buildTeam(body, leadId, { store, hub, roster }) {
|
|
681
|
+
const channel = String(body?.channel ?? '').trim().replace(/^#/, '');
|
|
682
|
+
if (!channel || channel.length > 32 || /\s/.test(channel)) throw new CollabError('invalid', 'channel must be 1-32 characters without spaces');
|
|
683
|
+
const wanted = Array.isArray(body?.members) ? body.members : [];
|
|
684
|
+
if (wanted.length === 0 || wanted.length > MAX_TEAM) throw new CollabError('invalid', `members must list 1-${MAX_TEAM} roles`);
|
|
685
|
+
const names = wanted.map(x => String(x?.name ?? '').trim());
|
|
686
|
+
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');
|
|
687
|
+
const shared = await resolveTeamModel(roster, body?.model);
|
|
688
|
+
const models = [];
|
|
689
|
+
for (const x of wanted) models.push(x?.model === undefined || x?.model === '' ? shared : await resolveTeamModel(roster, x.model));
|
|
690
|
+
// Check every role before anything is written, so a bad name leaves no half-built channel behind.
|
|
691
|
+
for (const [i, x] of wanted.entries()) await roster.validate({ name: names[i], persona: String(x?.persona ?? ''), avatar: TEAM_AVATARS[i % TEAM_AVATARS.length] });
|
|
692
|
+
const room = store.createRoom(channel);
|
|
693
|
+
const created = [];
|
|
694
|
+
for (const [i, x] of wanted.entries()) {
|
|
695
|
+
const model = models[i];
|
|
696
|
+
created.push(await roster.create({ name: names[i], persona: String(x?.persona ?? ''), rooms: [room.id], avatar: TEAM_AVATARS[i % TEAM_AVATARS.length], ...(model ? { model } : {}) }));
|
|
697
|
+
}
|
|
698
|
+
const lead = store.member(leadId);
|
|
699
|
+
if (lead?.source === 'ui' && lead.rooms && lead.rooms !== '[]') {
|
|
700
|
+
await roster.update(leadId, { ...rowToMember(lead), rooms: [...JSON.parse(lead.rooms), room.id] });
|
|
701
|
+
}
|
|
702
|
+
store.setLead(room.id, leadId);
|
|
703
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
704
|
+
const label = model => (model ? model.model : 'DSH 默认模型');
|
|
705
|
+
const leadName = hub.members.get(leadId)?.name ?? leadId;
|
|
706
|
+
hub.post(room.id, { authorKind: 'system', author: 'system', name: '建团队',
|
|
707
|
+
text: `【已建团队】${leadName} 拉好了 #${channel}:${created.map((c, i) => `${c.name}(${label(models[i])})`).join('、')}。${leadName} 是本频道主角色,直接在这里说需求。` });
|
|
708
|
+
return { room, lead: leadId, members: created.map((c, i) => ({ id: c.id, name: c.name, model: label(models[i]) })) };
|
|
709
|
+
}
|
|
710
|
+
|
|
632
711
|
const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing': 404, conflict: 409, 'read-only': 403, forbidden: 403, offline: 503, 'member-busy': 409 };
|
|
633
712
|
|
|
634
713
|
/** Parse a JSON body (unless `parse` is false), run fn, and map CollabError codes to HTTP statuses. */
|
package/collab/store.mjs
CHANGED
|
@@ -121,6 +121,10 @@ export class Store {
|
|
|
121
121
|
AND draft = 1 AND deleted = 0 AND card IS NULL`),
|
|
122
122
|
authorDrafts: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
|
|
123
123
|
AND draft = 1 AND deleted = 0 AND card IS NULL ORDER BY id`),
|
|
124
|
+
// #913: every live card row one author posted, in every room. A restarted bridge
|
|
125
|
+
// holds no handle on them, so they must be found without a message window.
|
|
126
|
+
authorCards: this.db.prepare(`SELECT * FROM messages WHERE author_kind = ? AND author = ?
|
|
127
|
+
AND card IS NOT NULL AND deleted = 0 ORDER BY id`),
|
|
124
128
|
addJob: this.db.prepare(`INSERT INTO jobs (member_id, room_id, trigger_id, state, created_at)
|
|
125
129
|
VALUES (?, ?, ?, 'pending', ?) ON CONFLICT(member_id, trigger_id) DO NOTHING RETURNING *`),
|
|
126
130
|
job: this.db.prepare('SELECT * FROM jobs WHERE id = ?'),
|
|
@@ -721,6 +725,10 @@ export class Store {
|
|
|
721
725
|
// WebSocket close events may arrive after plugin teardown has closed SQLite.
|
|
722
726
|
return this.closed ? [] : this.q.authorDrafts.all(authorKind, author);
|
|
723
727
|
}
|
|
728
|
+
/** Every live progress-card row one author posted (#913), oldest first. */
|
|
729
|
+
authorCards(authorKind, author) {
|
|
730
|
+
return this.closed ? [] : this.q.authorCards.all(authorKind, author);
|
|
731
|
+
}
|
|
724
732
|
|
|
725
733
|
/** Up to `limit` messages before `beforeId` (exclusive), oldest first. */
|
|
726
734
|
page(roomId, beforeId, limit) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bothouse-dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.48",
|
|
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",
|