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/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
- return { dataDir: c.dataDir, workspacePath: c.workspacePath, leadFile, fallbackLead, boardDir, teamStatusDir, wikiDir, rooms, dshMembers: members, maxRelay, botTokenEnv, botToken, ownerName: c.ownerName ?? '我' };
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
- if (body?.lead_id !== undefined && body.lead_id !== null && !hub.routableIn(roomId).some(m => m.id === body.lead_id)) {
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 (body?.lead_id !== undefined) store.setLead(roomId, body.lead_id);
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.46",
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",