bothouse-dsh 0.1.47 → 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.
@@ -26,7 +26,7 @@ export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
26
26
  /** #597 G24: the open-workspace command, for the selected primary role only, never in bridge prompts. */
27
27
  export function workspaceHint(hub, later, memberId, roomId) {
28
28
  if (!later?.workspaceInstructions || hub?.primaryIn?.(roomId)?.id !== memberId) return '';
29
- return later.workspaceInstructions(memberId, roomId);
29
+ return later.workspaceInstructions(memberId, roomId) + (later.teamInstructions?.(memberId, roomId) ?? '');
30
30
  }
31
31
  /**
32
32
  * #597 G24: DSH members have no bridge session, so the shared line about `bothouse workspace new` /
@@ -201,6 +201,9 @@ export function friendlyFailure(raw) {
201
201
  const text = String(raw);
202
202
  if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没有配置模型的 API Key,请先去配置模型。';
203
203
  if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
204
+ // An unreachable provider is a setting or network problem. Echoing the transport wording
205
+ // ("Connection error.") behind "请检查配置" told the user nothing about which setting to fix.
206
+ if (/connection error|econnrefused|econnreset|enotfound|etimedout|eai_again|fetch failed|socket hang up|network ?(error|request failed)|timed out|timeout/i.test(text)) return '连不上模型服务:中转站地址或网络不通。请检查设置里的模型地址和 Key,或换一个中转站再试。';
204
207
  if (/login|log.?in|auth|unauthorized|401|未登录/i.test(text)) return '模型或 CLI 还没有登录,或登录已过期。请先登录后再试。';
205
208
  if (/exit|ENOENT|spawn|没有输出文字/i.test(text)) return '本机 CLI 未能正常运行或已退出,请检查安装和登录状态。';
206
209
  // A Host older than this plugin has no scope lease on its preset registry, so no member can be
@@ -863,6 +866,24 @@ export class DshMembers {
863
866
  if (this.closed) return;
864
867
  const job = this.store.job(jobId);
865
868
  if (job === undefined || !this.store.finishJob(jobId, state)) return;
869
+ // A restart is not a configuration problem: the member is healthy and only the turn it was
870
+ // running is lost, so it takes a retry button and leaves the member online. Reporting it as
871
+ // "成员出错 / 去配置模型" also cooled the provider quota for what was a process restart.
872
+ if (state === 'interrupted') {
873
+ this.hub.setError(job.member_id, undefined);
874
+ this.hub.post(job.room_id, {
875
+ authorKind: 'system', author: 'system', name: 'system', text: String(notice), clientKey: `dsh-job-${jobId}-${state}-${Date.now()}`,
876
+ card: JSON.stringify({
877
+ title: '被中断',
878
+ state: 'failed',
879
+ action: 'retry',
880
+ job_id: jobId,
881
+ member_id: job.member_id,
882
+ trigger_id: job.trigger_id,
883
+ }),
884
+ });
885
+ return;
886
+ }
866
887
  this.hub.quota?.observe(job.member_id, job.room_id, { error: String(notice) });
867
888
  const error = friendlyFailure(notice);
868
889
  this.hub.setError(job.member_id, error);
package/collab/later.mjs CHANGED
@@ -104,6 +104,18 @@ export class Later {
104
104
  const curl = `curl -sS -X POST ${quote(url)} -H 'content-type: application/json' -H ${quote(`X-Later-Ticket: ${ticket}`)} -d '{"title":"任务标题","owner":"接活人id(省略=你自己)","reason":"交接说明"}'`;
105
105
  return `\n【开工作区】整块活要交出去时,建工作区=这一条,任务和十分钟看门会自动开,不要另调 /tasks(它不认票据)。${local}或等价的接口 POST /collab/api/rooms/${room}/workspaces:\n${curl}\n一步建好工作区、开任务、挂十分钟看门并叫醒接活人;不要再在频道里 @ 同一件活。返回不是 200 就把返回原样告诉用户,不要悄悄自己做。工作区里的接活人是新会话,只看得到交接说明:里面要写清用户原话、参照的现成实现、以哪个分支为底、验收标准。\n`;
106
106
  }
107
+ /**
108
+ * 一句话拉团队 (user 11-04): how the primary role builds a channel of new roles in one call.
109
+ * Primary-only like workspaceInstructions (dsh-member.mjs workspaceHint).
110
+ */
111
+ teamInstructions(member, room) {
112
+ const quote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
113
+ const ticket = this.ticket(member, room);
114
+ const json = '{"channel":"小团队","model":"模型id(用户没说就省略=DSH默认模型)","members":[{"name":"写手","persona":"你是写手,负责写和改博客文章。"},{"name":"程序员","persona":"你是程序员,负责写代码和审代码。"}]}';
115
+ const local = this.origin ? `本机命令(改 JSON 里的内容):\n${quote(process.execPath)} ${quote(fileURLToPath(import.meta.url))} team ${quote(this.origin)} ${quote(ticket)} ${quote(json)}\n` : '';
116
+ const url = `${this.origin ?? 'http://<本机 DSH 地址>'}/collab/api/rooms/${room}/team`;
117
+ return `\n【建团队】用户要你建几个角色、拉个群/频道时,一条就建好:新频道、每个角色(名字、人设、模型),你是新频道的主角色。${local}或 POST ${url}(头 X-Later-Ticket: ${ticket},JSON 同上)。名字不带空格和 @;人设一两句写清分工;用户说了模型就填 model(如 deepseek-v4.1-flash),没说就省略。返回不是 200 就把返回原样告诉用户。建好后告诉用户频道名、每个角色和模型,问要不要丢个题目试跑。\n`;
118
+ }
107
119
  tick() {
108
120
  for (const row of this.db.prepare('SELECT * FROM member_later WHERE announced = 0').all()) {
109
121
  this.hub.post(row.room_id, { authorKind: 'system', author: 'system', name: '回查登记',
@@ -134,6 +146,15 @@ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'w
134
146
  const body = await res.text();
135
147
  if (!res.ok) { console.error(`workspace: ${res.status} ${body}`); process.exit(1); }
136
148
  console.log(body);
149
+ } else if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === 'team') {
150
+ const [origin, ticket, spec] = process.argv.slice(3);
151
+ const room = ticketClaims(ticket)?.r;
152
+ if (!origin || !Number.isSafeInteger(room) || !spec) { console.error('usage: later.mjs team <origin> <ticket> \'{"channel":"…","model":"…","members":[{"name":"…","persona":"…"}]}\''); process.exit(2); }
153
+ const res = await fetch(new URL(`/collab/api/rooms/${room}/team`, origin), { method: 'POST',
154
+ headers: { 'content-type': 'application/json', 'x-later-ticket': ticket }, body: spec });
155
+ const body = await res.text();
156
+ if (!res.ok) { console.error(`team: ${res.status} ${body}`); process.exit(1); }
157
+ console.log(body);
137
158
  } else if (process.argv[1] === fileURLToPath(import.meta.url)) {
138
159
  const [origin, ticket, delay, title, todo] = process.argv.slice(2);
139
160
  const room = ticketClaims(ticket)?.r;
package/collab/page.html CHANGED
@@ -258,6 +258,27 @@
258
258
  font-variant-numeric: tabular-nums;
259
259
  }
260
260
  .msg.cont:hover .stamp { display: block; }
261
+ /* 自己的消息:悬停时右上角出现「撤回」,撤回会连同这条消息发起的回答一起停下。 */
262
+ .msg .ops {
263
+ position: absolute;
264
+ top: 2px;
265
+ right: max(var(--pad-x), env(safe-area-inset-right));
266
+ display: none;
267
+ gap: 4px;
268
+ }
269
+ .msg:hover .ops, .msg:focus-within .ops { display: flex; }
270
+ .msg .ops button {
271
+ font: inherit;
272
+ font-size: var(--fs-xs);
273
+ line-height: 1.4;
274
+ color: var(--muted);
275
+ background: var(--surface);
276
+ border: 1px solid var(--line);
277
+ border-radius: var(--r-sm);
278
+ padding: 1px 6px;
279
+ cursor: pointer;
280
+ }
281
+ .msg .ops button:hover { color: var(--text-strong); background: var(--hover); }
261
282
 
262
283
  .avatar {
263
284
  width: var(--avatar);
@@ -644,6 +665,21 @@ function messageNode(m) {
644
665
  col.append(card);
645
666
  } else col.append(el('div', 'body', m.text));
646
667
  row.append(col);
668
+ // 自己发的普通消息:悬停出现「撤回」。撤回后后端广播 deleted,这一行随之消失;
669
+ // 这条消息触发的回答也会一起停下(后端 recall 会取消对应 turn)。
670
+ if (!system && !m.card && m.author === state.me) {
671
+ const ops = el('div', 'ops');
672
+ const b = el('button', '', '撤回');
673
+ b.type = 'button';
674
+ b.title = '撤回这条消息(正在进行的回答也会停下)';
675
+ b.onclick = async () => {
676
+ if (!confirm('撤回这条消息?正在进行的回答也会停下。')) return;
677
+ try { await api(`${ROOT}/collab/api/messages/${m.id}`, { method: 'DELETE' }); }
678
+ catch (err) { alert('撤回失败:' + err.message); }
679
+ };
680
+ ops.append(b);
681
+ row.append(ops);
682
+ }
647
683
  return row;
648
684
  }
649
685
 
package/collab/plugin.mjs CHANGED
@@ -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';
@@ -236,6 +236,8 @@ export async function apply(ctx, rawConfig) {
236
236
  if (routed !== undefined) return routed;
237
237
  const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh });
238
238
  if (opened !== undefined) return opened;
239
+ const team = teamRoute(req, res, url, { store, hub, later, roster });
240
+ if (team !== undefined) return team;
239
241
  if (!owner(req)) {
240
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。');
241
243
  return refuse(req, res);
@@ -448,7 +450,10 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
448
450
  if (req.method === 'GET') return Promise.resolve(json(res, 200, view()));
449
451
  if (req.method !== 'PATCH') return undefined;
450
452
  return withBody(req, res, body => {
451
- 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)) {
452
457
  throw new CollabError('invalid', '请选择本频道的主角色');
453
458
  }
454
459
  if (body?.workspace_path !== undefined) {
@@ -459,7 +464,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
459
464
  if (!stat.isDirectory()) throw new CollabError('invalid', '工作目录必须是目录,不能是文件');
460
465
  }
461
466
  store.transaction(() => {
462
- if (body?.lead_id !== undefined) store.setLead(roomId, body.lead_id);
467
+ if (leadId !== undefined) store.setLead(roomId, leadId);
463
468
  if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
464
469
  // #445: 6–20 only; an invalid value rolls the whole PATCH back.
465
470
  if (body?.max_relay !== undefined) store.setMaxRelay(roomId, body.max_relay);
@@ -635,6 +640,74 @@ function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks,
635
640
  });
636
641
  }
637
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
+
638
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 };
639
712
 
640
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.47",
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",