bothouse-dsh 0.1.59 → 0.1.61
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/README.en.md +5 -3
- package/README.md +5 -3
- package/collab/cloudweb.mjs +1 -1
- package/collab/desk/index.html +13 -3
- package/collab/desk.mjs +18 -1
- package/collab/dsh-member.mjs +8 -1
- package/collab/hub.mjs +17 -4
- package/collab/page.html +70 -4
- package/collab/plugin.mjs +2 -2
- package/collab/quota-cooling.mjs +4 -4
- package/collab/roster.mjs +7 -3
- package/collab/store.mjs +12 -5
- package/collab/team-mode-rules.md +2 -2
- package/node_modules/bothouse-dsh-channels/client.js +138 -102
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -6,14 +6,16 @@ BotHouse channels inside DeepSeek Harness (DSH): a Discord-style channels page w
|
|
|
6
6
|
|
|
7
7
|
## Install (pick one)
|
|
8
8
|
|
|
9
|
-
1. **Easiest**: tell DSH "install the plugin bothouse-dsh".
|
|
10
|
-
2. **From the UI**: DSH sidebar "Plugins" → "Add plugin", enter `bothouse-dsh
|
|
9
|
+
1. **Easiest**: tell DSH "install the plugin bothouse-dsh, pinned to the newest version on npm".
|
|
10
|
+
2. **From the UI**: DSH sidebar "Plugins" → "Add plugin", enter `bothouse-dsh@<newest version on npm>`, click "Install"; then switch bothouse on under "Installed plugins" — "Channels" appears in the sidebar right away, no restart.
|
|
11
11
|
3. **From a terminal**:
|
|
12
12
|
|
|
13
13
|
~~~sh
|
|
14
|
-
dsh plugin --profile web add bothouse-dsh
|
|
14
|
+
dsh plugin --profile web add "bothouse-dsh@$(npm view bothouse-dsh version)"
|
|
15
15
|
~~~
|
|
16
16
|
|
|
17
|
+
Pin the version: DSH installs plugins with pnpm, and pnpm's supply-chain policy skips very fresh releases, so plain `bothouse-dsh` or `bothouse-dsh@latest` can install a version from days ago. An exact version goes through at once (pnpm records it as an exclusion). Measured 2026-11-08 with pnpm 12.16.1: `bothouse-dsh` resolved to 0.1.57, then the newest release older than a day — 0.1.59 was 19 hours old and 0.1.60 about 30 minutes; `bothouse-dsh@0.1.60` resolved to 0.1.60.
|
|
18
|
+
|
|
17
19
|
Asked through DSH, "Channels" appears right after the install; installed from the command line while DSH is running, restart DSH once. Then click "Channels" in the sidebar. Team desk, bothouse panel and other computers start off; switch the one you want on under "Plugins → bothouse" and it appears in the sidebar right away. #general already has Whale Girl, running on the model you configured in DSH; nothing else to fill in. @ her to get a reply; tell her "hand this to a workspace: …" and she opens a workspace, does the work and reports back. For more characters or models, use "Add member".
|
|
18
20
|
|
|
19
21
|
If the terminal install says `pnpm was not found`, run `npm i -g pnpm` first.
|
package/README.md
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
## 安装(三选一)
|
|
8
8
|
|
|
9
|
-
1. **最省事**:在 DSH 里对它说「帮我装插件 bothouse-dsh
|
|
10
|
-
2. **点界面**:DSH 侧栏「插件」→「添加插件」,填 `bothouse-dsh
|
|
9
|
+
1. **最省事**:在 DSH 里对它说「帮我装插件 bothouse-dsh,钉到 npm 上最新的版本」。
|
|
10
|
+
2. **点界面**:DSH 侧栏「插件」→「添加插件」,填 `bothouse-dsh@<npm 上最新的版本号>`,点「安装」;装完在「已安装插件」里把 bothouse 的开关打开,左侧马上出现「频道」,不用重启。
|
|
11
11
|
3. **命令行**:
|
|
12
12
|
|
|
13
13
|
~~~sh
|
|
14
|
-
dsh plugin --profile web add bothouse-dsh
|
|
14
|
+
dsh plugin --profile web add "bothouse-dsh@$(npm view bothouse-dsh version)"
|
|
15
15
|
~~~
|
|
16
16
|
|
|
17
|
+
钉版本:DSH 用 pnpm 装插件,pnpm 的供应链策略会跳过刚发不久的版本,所以写 `bothouse-dsh` 或 `bothouse-dsh@latest` 可能装到几天前的版本;写准版本号会当场放行(pnpm 记一条例外)。2026-11-08 用 pnpm 12.16.1 实测:`bothouse-dsh` 解析到 0.1.57——当时一天前发的那个,0.1.59 才 19 小时、0.1.60 才半小时;写 `bothouse-dsh@0.1.60` 就解析到 0.1.60。
|
|
18
|
+
|
|
17
19
|
对 DSH 说的装法装完左侧直接出现「频道」;命令行装时 DSH 若正开着,重启一次。然后点左侧「频道」。团队工作台、bothouse 面板、其他电脑默认不在左侧,要用就到「插件 → bothouse」里打开对应那一项,左侧马上出现。#general 里已经有鲸鱼娘,用的是你在 DSH 里配好的那个模型,不用再填任何东西。@鲸鱼娘 就回话;对她说「交给工作区做:……」,她会开工作区干完再交回。想要多角色、多模型,再点「添加成员」。
|
|
18
20
|
|
|
19
21
|
命令行装时提示 `pnpm was not found`:先 `npm i -g pnpm` 再装。
|
package/collab/cloudweb.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const BARE_MENTION = '(只 @ 了你,没写内容:看上面的频道消息
|
|
|
55
55
|
export const PROGRESS_PREFIX = '__cc_connect_progress_card_v1__:';
|
|
56
56
|
// cancelled (#913): a card whose turn was cut without an outcome — a restarted bridge, a
|
|
57
57
|
// cancelled DSH job. page.html styles it like any other card state.
|
|
58
|
-
const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消', aborted: '已打断' };
|
|
58
|
+
const CARD_STATES = { running: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消', aborted: '已打断', stopped: '已停止' };
|
|
59
59
|
const RESERVED_IDS = new Set(['user', 'system']);
|
|
60
60
|
const MAX_NAME = 64;
|
|
61
61
|
|
package/collab/desk/index.html
CHANGED
|
@@ -1360,7 +1360,7 @@ function dispatchBeamOnce(id, from, to) {
|
|
|
1360
1360
|
beam(from, bodyRef(to.id, id), 'assign');
|
|
1361
1361
|
}
|
|
1362
1362
|
|
|
1363
|
-
function applyTask(ev, animate = true) {
|
|
1363
|
+
function applyTask(ev, animate = true, quiet = false) {
|
|
1364
1364
|
// 实时流上 tasks.mjs 也发 `{type:'task', task:{…}, chat_id, active}`(给聊天面板用的任务对象),
|
|
1365
1365
|
// 那不是板事件的形态(id/kind/from/to/title,由 gate 的 board.py 写进 board-events.jsonl)。
|
|
1366
1366
|
// 两种都照画,任务动作会多出一行「— → —」的空记录,实时就和回放对不上了。
|
|
@@ -1419,7 +1419,7 @@ function applyTask(ev, animate = true) {
|
|
|
1419
1419
|
}
|
|
1420
1420
|
forgetDispatch(id, S.dispatched);
|
|
1421
1421
|
}
|
|
1422
|
-
addLog(kind, ts, fromName, toName, header, note);
|
|
1422
|
+
if (!quiet) addLog(kind, ts, fromName, toName, header, note);
|
|
1423
1423
|
paintAll(animate);
|
|
1424
1424
|
}
|
|
1425
1425
|
|
|
@@ -1502,6 +1502,12 @@ async function loadState() {
|
|
|
1502
1502
|
renderSeats();
|
|
1503
1503
|
}
|
|
1504
1504
|
|
|
1505
|
+
// 刷新/第一次打开时进行中的任务要从板上补回来,否则工位和「进行中」要等下一个事件才对。
|
|
1506
|
+
async function loadDeskState() {
|
|
1507
|
+
const data = await api(ROOT + '/collab/api/desk/state');
|
|
1508
|
+
for (const ev of data.tasks ?? []) applyTask({ ...ev, type: 'task' }, false, true);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1505
1511
|
function connectLive() {
|
|
1506
1512
|
disconnectLive();
|
|
1507
1513
|
const es = new EventSource(ROOT + '/collab/api/events');
|
|
@@ -1554,11 +1560,15 @@ async function goLive() {
|
|
|
1554
1560
|
setConn('wait', '连接中');
|
|
1555
1561
|
try {
|
|
1556
1562
|
await loadState();
|
|
1557
|
-
connectLive();
|
|
1558
1563
|
} catch (error) {
|
|
1559
1564
|
setConn('err', '连接失败');
|
|
1560
1565
|
toast(`加载失败:${error.message}`);
|
|
1566
|
+
return;
|
|
1561
1567
|
}
|
|
1568
|
+
try {
|
|
1569
|
+
await loadDeskState();
|
|
1570
|
+
} catch { /* 进行中的任务补不回来,不影响实时事件 */ }
|
|
1571
|
+
connectLive();
|
|
1562
1572
|
}
|
|
1563
1573
|
const R = (S.replay = { events: [], idx: 0, playing: false, speed: 4, timer: null, loaded: false });
|
|
1564
1574
|
|
package/collab/desk.mjs
CHANGED
|
@@ -125,6 +125,19 @@ export function deskReplay({ store, boardDir, from, to, members = [] }) {
|
|
|
125
125
|
return { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString(), events: kept.map(m => m.event) };
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* 现在还在进行中的任务:按任务 id 取最后一条板事件,最后一条是 created/assigned 就算在做。
|
|
130
|
+
* 页面刷新或第一次打开时用它把工位上的任务和「进行中」计数补回来,不必等下一个事件。
|
|
131
|
+
*/
|
|
132
|
+
export function deskActiveTasks({ boardDir }) {
|
|
133
|
+
const last = new Map();
|
|
134
|
+
for (const entry of readBoardEvents(boardDir)) {
|
|
135
|
+
const id = String(entry.id ?? '').trim();
|
|
136
|
+
if (id && entry.kind) last.set(id, entry);
|
|
137
|
+
}
|
|
138
|
+
return [...last.values()].filter(entry => entry.kind === 'created' || entry.kind === 'assigned');
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
/** 桌面页:C 路提供的 dsh/collab/desk/index.html;还没落地时给一个能看事件流的占位页。 */
|
|
129
142
|
export function deskPage() {
|
|
130
143
|
try { return readFileSync(new URL('./desk/index.html', import.meta.url), 'utf8'); } catch { return PLACEHOLDER_PAGE; }
|
|
@@ -177,9 +190,13 @@ async function namesRoute(req, res, namesFile) {
|
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
/** /collab/desk、/collab/api/desk/replay、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
|
|
193
|
+
/** /collab/desk、/collab/api/desk/replay、/collab/api/desk/state、/collab/api/desk/names;返回 true(或 Promise)表示已处理,未匹配返回 undefined。 */
|
|
181
194
|
export function deskRoute(req, res, url, { store, boardDir, members = [], namesFile } = {}) {
|
|
182
195
|
if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/names' && (req.method === 'GET' || req.method === 'PUT')) return namesRoute(req, res, namesFile);
|
|
196
|
+
if (url.pathname.replace(/\/+$/, '') === '/collab/api/desk/state' && req.method === 'GET') {
|
|
197
|
+
sendJson(res, 200, { tasks: deskActiveTasks({ boardDir }) });
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
183
200
|
if (url.pathname.replace(/\/+$/, '') !== '/collab/desk' && url.pathname.replace(/\/+$/, '') !== '/collab/api/desk/replay') return undefined;
|
|
184
201
|
if (req.method !== 'GET') return undefined;
|
|
185
202
|
if (url.pathname.replace(/\/+$/, '') === '/collab/desk') {
|
package/collab/dsh-member.mjs
CHANGED
|
@@ -308,6 +308,7 @@ export class TurnProgress {
|
|
|
308
308
|
this.id = undefined;
|
|
309
309
|
this.queued = false;
|
|
310
310
|
this.ended = false;
|
|
311
|
+
this.interrupted = false;
|
|
311
312
|
}
|
|
312
313
|
|
|
313
314
|
call(callId, name, args) {
|
|
@@ -346,10 +347,15 @@ export class TurnProgress {
|
|
|
346
347
|
end(state) {
|
|
347
348
|
if (this.ended) return;
|
|
348
349
|
this.ended = true;
|
|
350
|
+
// The owner withdrew the trigger, so this turn was stopped by hand, not broken: its card says 已停止.
|
|
351
|
+
if (this.interrupted) state = 'stopped';
|
|
349
352
|
// Only a turn that had events has a card to close.
|
|
350
353
|
if (this.steps > 0 || this.items.length > 0) this.write(state);
|
|
351
354
|
}
|
|
352
355
|
|
|
356
|
+
/** The owner withdrew the trigger message (#148): the card must not read as a failure. */
|
|
357
|
+
interrupt() { this.interrupted = true; }
|
|
358
|
+
|
|
353
359
|
write(state) {
|
|
354
360
|
const body = progressCardOf({ agent: this.member.name, state, steps: this.steps, elapsed_sec: Math.floor((this.now() - this.started) / 1000), items: this.items });
|
|
355
361
|
if (this.id === undefined) {
|
|
@@ -517,6 +523,7 @@ export class DshMembers {
|
|
|
517
523
|
this.approvals.expireWhere(p => p.meta?.sessionId === job.session_id);
|
|
518
524
|
if (w.turn === undefined && agent.inbox.remove(job.input_id)) continue;
|
|
519
525
|
this.watching.delete(job.session_id);
|
|
526
|
+
w.progress?.interrupt?.();
|
|
520
527
|
w.resolve(INTERRUPTED);
|
|
521
528
|
agent.cancel({ kind: 'user' }, { keepInbox: true });
|
|
522
529
|
stopped.push(job.member_id);
|
|
@@ -597,7 +604,7 @@ export class DshMembers {
|
|
|
597
604
|
const answered = this.approvals.answerByText(msg);
|
|
598
605
|
const hit = mentions(msg.text, this.hub.routableIn(msg.room_id));
|
|
599
606
|
const only = this.hub.replyRoute(msg);
|
|
600
|
-
const leadID = this.hub.
|
|
607
|
+
const leadID = this.hub.defaultReplyIn(msg.room_id)?.id;
|
|
601
608
|
const leadTarget = msg.author_kind === 'user' && leadID !== undefined && target === undefined && only === null && hit.size === 0;
|
|
602
609
|
const visible = new Set(this.hub.routableIn(msg.room_id).map(m => m.id));
|
|
603
610
|
const targets = [...this.config.keys()].filter(id => !this.hub.quota?.cooling(id) && visible.has(id) && id !== msg.author && !answered.has(id) && (!target || target === id)
|
package/collab/hub.mjs
CHANGED
|
@@ -48,9 +48,8 @@ export class Hub {
|
|
|
48
48
|
return member !== undefined && member.kind !== 'human' ? member : undefined;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Channel
|
|
51
|
+
/** Channel primary for delegation; unset channels retain the selected global role. */
|
|
52
52
|
primaryIn(roomId) {
|
|
53
|
-
// #1023: in a private chat the one agent answers every owner message, @ or not.
|
|
54
53
|
const dm = this.store.dmMember?.(roomId);
|
|
55
54
|
if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
|
|
56
55
|
const id = this.store.leadId(roomId);
|
|
@@ -58,7 +57,18 @@ export class Hub {
|
|
|
58
57
|
return member && this.routableIn(roomId).some(m => m.id === member.id) ? member : undefined;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
/**
|
|
60
|
+
/** Default replies are channel-local and do not change delegation authority. */
|
|
61
|
+
defaultReplyIn(roomId) {
|
|
62
|
+
// #1023: in a private chat the one agent answers every owner message, @ or not.
|
|
63
|
+
const dm = this.store.dmMember?.(roomId);
|
|
64
|
+
if (dm !== undefined) return this.routableIn(roomId).find(m => m.id === dm);
|
|
65
|
+
const id = this.store.leadId(roomId);
|
|
66
|
+
const members = this.routableIn(roomId);
|
|
67
|
+
if (id === null) return members.length === 1 ? members[0] : undefined;
|
|
68
|
+
return members.find(member => member.id === id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** UI-only fallback; message routing uses defaultReplyIn(), delegation uses primaryIn(). */
|
|
62
72
|
primaryMember() {
|
|
63
73
|
const fallback = this.fallbackLead === '' ? undefined : this.members.get(this.fallbackLead);
|
|
64
74
|
return this.selectedPrimaryMember() ?? (fallback?.kind !== 'human' ? fallback : undefined);
|
|
@@ -68,6 +78,8 @@ export class Hub {
|
|
|
68
78
|
if (member.avatar?.startsWith('upload:')) member.avatarVersion = Math.random().toString(36).slice(2);
|
|
69
79
|
this.members.set(member.id, member);
|
|
70
80
|
this.emit({ type: 'members', members: this.memberView() });
|
|
81
|
+
// Membership determines an unset channel's effective primary, including its workspaces.
|
|
82
|
+
this.emit({ type: 'rooms', rooms: this.store.roomsView() });
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
removeMember(id) {
|
|
@@ -76,6 +88,7 @@ export class Hub {
|
|
|
76
88
|
for (const room of this.thinking.keys()) this.setThinking(room, id, false);
|
|
77
89
|
for (const room of [...this.queued.keys()]) this.setQueue(room, id, []);
|
|
78
90
|
this.emit({ type: 'members', members: this.memberView() });
|
|
91
|
+
this.emit({ type: 'rooms', rooms: this.store.roomsView() });
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
setOnline(id, online) {
|
|
@@ -283,7 +296,7 @@ export class Hub {
|
|
|
283
296
|
|
|
284
297
|
emit(event) {
|
|
285
298
|
if (event.type === 'rooms') event = { ...event, rooms: event.rooms.map(room => ({ ...room,
|
|
286
|
-
lead_id: this.store.leadId(room.id), effective_lead_id: this.
|
|
299
|
+
lead_id: this.store.leadId(room.id), effective_lead_id: this.defaultReplyIn(room.id)?.id ?? null,
|
|
287
300
|
workspace_path: this.store.workspacePath(room.id, this.workspacePath),
|
|
288
301
|
})) };
|
|
289
302
|
for (const s of this.subscribers) {
|
package/collab/page.html
CHANGED
|
@@ -247,6 +247,8 @@
|
|
|
247
247
|
被 40px 的头像列宽挤成一字一行(#112)。 */
|
|
248
248
|
.msg.cont .avatar { visibility: hidden; }
|
|
249
249
|
.msg.cont .head { display: none; }
|
|
250
|
+
/* 正文列固定落在第二列,即使头像被隐藏也不会顶到第一列。 */
|
|
251
|
+
.msg > :not(.avatar) { grid-column: 2; min-width: 0; }
|
|
250
252
|
.msg .stamp {
|
|
251
253
|
display: none;
|
|
252
254
|
position: absolute;
|
|
@@ -330,12 +332,14 @@
|
|
|
330
332
|
.card[data-state="completed"] { --card-accent: var(--ok); --card-tint: rgba(35, 165, 90, .16); --card-text: #7ee2a8; }
|
|
331
333
|
.card[data-state="failed"] { --card-accent: var(--danger); --card-tint: rgba(242, 63, 67, .16); --card-text: #ff9a9c; }
|
|
332
334
|
.card[data-state="cancelled"] { --card-accent: var(--muted); --card-tint: rgba(127, 127, 127, .14); --card-text: var(--muted); }
|
|
335
|
+
.card[data-state="stopped"] { --card-accent: var(--muted); --card-tint: rgba(127, 127, 127, .14); --card-text: var(--muted); }
|
|
333
336
|
@media (prefers-color-scheme: light) {
|
|
334
337
|
.card { --card-text: #3b46b8; --card-tint: rgba(74, 85, 214, .12); }
|
|
335
338
|
.card[data-state="running"] { --card-text: #6d4a00; --card-tint: rgba(240, 178, 50, .18); }
|
|
336
339
|
.card[data-state="completed"] { --card-text: #146c3a; --card-tint: rgba(26, 127, 67, .12); }
|
|
337
340
|
.card[data-state="failed"] { --card-text: #a52327; --card-tint: rgba(194, 43, 47, .12); }
|
|
338
341
|
.card[data-state="cancelled"] { --card-text: #545861; --card-tint: rgba(84, 88, 97, .12); }
|
|
342
|
+
.card[data-state="stopped"] { --card-text: #545861; --card-tint: rgba(84, 88, 97, .12); }
|
|
339
343
|
}
|
|
340
344
|
.card-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
|
341
345
|
.card .title { font-weight: 600; font-size: var(--fs-sm); color: var(--text-strong); }
|
|
@@ -650,7 +654,7 @@ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls)
|
|
|
650
654
|
const palette = ['#4F46E5', '#B45309', '#0F766E']; // taste-skill-ignore 靛蓝为 design-spec 唯一强调色(用户标杆),按 SKILL.md 已有品牌条款保留
|
|
651
655
|
const color = id => palette[[...id].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % palette.length];
|
|
652
656
|
const kindLabel = { dsh: 'DSH', bot: 'Agent', human: '' };
|
|
653
|
-
const cardStateLabel = { pending: '待确认', running: '进行中', completed: '已完成', failed: '失败', cancelled: '已取消' };
|
|
657
|
+
const cardStateLabel = { pending: '待确认', running: '进行中', completed: '已完成', failed: '失败', cancelled: '已取消', stopped: '已停止', retried: '已重试' };
|
|
654
658
|
// 5 分钟:同一人连续发言合并头像与昵称的窗口,沿用 Discord 的习惯(交接要求「按同一人连续发言分组」)
|
|
655
659
|
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
|
656
660
|
// 768px 是 U2 交接里定的窄屏阈值
|
|
@@ -1026,6 +1030,26 @@ function messageNode(m) {
|
|
|
1026
1030
|
}
|
|
1027
1031
|
card.append(bar);
|
|
1028
1032
|
}
|
|
1033
|
+
// 与原生频道页共用重试接口;后端把已受理的失败卡更新成 retried。
|
|
1034
|
+
if (meta.state === 'failed') {
|
|
1035
|
+
const bar = el('div', 'actions');
|
|
1036
|
+
const retry = el('button', '', '重试');
|
|
1037
|
+
retry.type = 'button';
|
|
1038
|
+
retry.onclick = async () => {
|
|
1039
|
+
retry.disabled = true;
|
|
1040
|
+
retry.textContent = '重试中…';
|
|
1041
|
+
try {
|
|
1042
|
+
await api(`${ROOT}/collab/api/messages/${m.id}/retry`, { method: 'POST' });
|
|
1043
|
+
retry.textContent = '已重试';
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
retry.disabled = false;
|
|
1046
|
+
retry.textContent = '重试';
|
|
1047
|
+
alert('重试失败:' + err.message);
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
bar.append(retry);
|
|
1051
|
+
card.append(bar);
|
|
1052
|
+
}
|
|
1029
1053
|
col.append(card);
|
|
1030
1054
|
} else if (m.text) col.append(renderBody(m.text, 'body'));
|
|
1031
1055
|
if (Array.isArray(m.attachments) && m.attachments.length) col.append(attachmentList(m.attachments));
|
|
@@ -1131,6 +1155,9 @@ function upsert(m) {
|
|
|
1131
1155
|
const after = [...log.children].find(n => n.dataset.id != null && Number(n.dataset.id) > m.id);
|
|
1132
1156
|
log.insertBefore(messageNode(m), after || null);
|
|
1133
1157
|
}
|
|
1158
|
+
// 「还没有消息」只是打开空频道时的占位:第一条消息(本地发的、SSE 推来的、重连补来的)一到就得收起,
|
|
1159
|
+
// 否则它会一直压在真实消息上面,直到手动切换频道或刷新。
|
|
1160
|
+
log.querySelector('.state.empty')?.remove();
|
|
1134
1161
|
regroup();
|
|
1135
1162
|
followBottom(log, stick);
|
|
1136
1163
|
}
|
|
@@ -1151,7 +1178,43 @@ function showState(kind, title, detail, retry) {
|
|
|
1151
1178
|
return box;
|
|
1152
1179
|
}
|
|
1153
1180
|
|
|
1181
|
+
// 来源:openRoom 原有每次读取 100 条;向上分页沿用同一页大小。
|
|
1182
|
+
const HISTORY_LIMIT = 100;
|
|
1183
|
+
let historyPage;
|
|
1184
|
+
|
|
1185
|
+
async function loadOlder() {
|
|
1186
|
+
const page = historyPage;
|
|
1187
|
+
if (!page || page.loading || !page.more) return;
|
|
1188
|
+
page.loading = true;
|
|
1189
|
+
const log = $('log');
|
|
1190
|
+
log.querySelector('.history-error')?.remove();
|
|
1191
|
+
try {
|
|
1192
|
+
const { messages } = await api(`${ROOT}/collab/api/rooms/${page.room}/messages?before=${page.before}&limit=${HISTORY_LIMIT}`);
|
|
1193
|
+
if (historyPage !== page) return;
|
|
1194
|
+
const oldHeight = log.scrollHeight;
|
|
1195
|
+
const oldTop = log.scrollTop;
|
|
1196
|
+
messages.forEach(upsert);
|
|
1197
|
+
if (messages.length) page.before = messages[0].id;
|
|
1198
|
+
page.more = messages.length === HISTORY_LIMIT;
|
|
1199
|
+
log.scrollTop = oldTop + log.scrollHeight - oldHeight;
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
if (historyPage !== page) return;
|
|
1202
|
+
const retry = el('button', 'btn history-error', '历史加载失败,点击重试');
|
|
1203
|
+
retry.type = 'button';
|
|
1204
|
+
retry.title = err.message;
|
|
1205
|
+
retry.onclick = () => loadOlder();
|
|
1206
|
+
log.prepend(retry);
|
|
1207
|
+
} finally {
|
|
1208
|
+
page.loading = false;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
$('log').addEventListener('scroll', () => {
|
|
1213
|
+
if ($('log').scrollTop === 0) void loadOlder();
|
|
1214
|
+
}, { passive: true });
|
|
1215
|
+
|
|
1154
1216
|
async function openRoom(id) {
|
|
1217
|
+
const page = historyPage = { room: id, before: undefined, more: false, loading: true };
|
|
1155
1218
|
state.room = id; state.synced = 0;
|
|
1156
1219
|
$('log').replaceChildren(showState('loading', '正在加载消息…'));
|
|
1157
1220
|
$('log').setAttribute('aria-busy', 'true');
|
|
@@ -1161,11 +1224,14 @@ async function openRoom(id) {
|
|
|
1161
1224
|
const setting = await api(`${ROOT}/collab/api/rooms/${id}/mode`);
|
|
1162
1225
|
state.mode = setting.dispatch_mode;
|
|
1163
1226
|
renderRooms(); renderMembers();
|
|
1164
|
-
const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit
|
|
1165
|
-
if (
|
|
1227
|
+
const { messages } = await api(`${ROOT}/collab/api/rooms/${id}/messages?limit=${HISTORY_LIMIT}`);
|
|
1228
|
+
if (historyPage !== page) return;
|
|
1166
1229
|
const log = $('log');
|
|
1167
1230
|
log.querySelector('.state')?.remove();
|
|
1168
1231
|
messages.forEach(upsert);
|
|
1232
|
+
page.before = messages[0]?.id;
|
|
1233
|
+
page.more = messages.length === HISTORY_LIMIT;
|
|
1234
|
+
page.loading = false;
|
|
1169
1235
|
state.synced = messages.length ? messages[messages.length - 1].id : 0;
|
|
1170
1236
|
if (!log.querySelector('.msg')) log.append(showState('empty', '还没有消息', ''));
|
|
1171
1237
|
$('log').setAttribute('aria-busy', 'false');
|
|
@@ -1173,7 +1239,7 @@ async function openRoom(id) {
|
|
|
1173
1239
|
stuck = true;
|
|
1174
1240
|
$('log').scrollTop = $('log').scrollHeight;
|
|
1175
1241
|
} catch (err) {
|
|
1176
|
-
if (
|
|
1242
|
+
if (historyPage !== page) return;
|
|
1177
1243
|
$('log').replaceChildren(showState('error', '加载失败', err.message, () => openRoom(id)));
|
|
1178
1244
|
$('log').setAttribute('aria-busy', 'false');
|
|
1179
1245
|
}
|
package/collab/plugin.mjs
CHANGED
|
@@ -346,7 +346,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
346
346
|
if (path === '/collab/api/state' && req.method === 'GET') {
|
|
347
347
|
return json(res, 200, { me: OWNER, lead_id: hub.selectedPrimaryMember()?.id ?? null,
|
|
348
348
|
effective_lead_id: hub.primaryMember()?.id ?? null,
|
|
349
|
-
rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.
|
|
349
|
+
rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.defaultReplyIn(r.id)?.id ?? null,
|
|
350
350
|
workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
|
|
351
351
|
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])),
|
|
352
352
|
queued: Object.fromEntries([...hub.queued.keys()].map(r => [r, hub.queueView(r)])),
|
|
@@ -573,7 +573,7 @@ export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePa
|
|
|
573
573
|
if (match === null) return undefined;
|
|
574
574
|
const roomId = Number(match[1]);
|
|
575
575
|
if (store.room(roomId) === undefined) return Promise.resolve(json(res, 404, { error: '频道不存在' }));
|
|
576
|
-
const view = () => ({ lead_id: store.leadId(roomId), effective_lead_id: hub.
|
|
576
|
+
const view = () => ({ lead_id: store.leadId(roomId), effective_lead_id: hub.defaultReplyIn(roomId)?.id ?? null,
|
|
577
577
|
workspace_path: store.workspacePath(roomId, workspacePath), max_relay: store.relaySetting(roomId).max_relay });
|
|
578
578
|
if (match[2] === 'renew' && req.method === 'POST') {
|
|
579
579
|
return withBody(req, res, async () => ({ members: await dsh.renewRoom(roomId) }), false);
|
package/collab/quota-cooling.mjs
CHANGED
|
@@ -108,7 +108,7 @@ export class QuotaCooling {
|
|
|
108
108
|
if (!quota.cooling_until && !state.until) continue;
|
|
109
109
|
if (!this.cooling(member.id)) {
|
|
110
110
|
for (const [room, lead] of Object.entries(state.leads ?? {})) {
|
|
111
|
-
if (this.hub.
|
|
111
|
+
if (this.hub.defaultReplyIn(Number(room))?.id === lead.to) {
|
|
112
112
|
this.hub.store.setLead(Number(room), member.id);
|
|
113
113
|
this.notice(Number(room), `${member.id}-${state.until}-restore-${room}`, `${member.name} 额度恢复,主角色已切回。`);
|
|
114
114
|
}
|
|
@@ -124,7 +124,7 @@ export class QuotaCooling {
|
|
|
124
124
|
const key = `${member.id}-${state.until}`;
|
|
125
125
|
const reserved = new Set(Object.values(state.transfers).filter(t => !t.woken).map(t => t.to));
|
|
126
126
|
for (const room of this.hub.store.rooms().filter(r => r.kind !== 'workspace')) {
|
|
127
|
-
if (state.leads[room.id] || this.hub.
|
|
127
|
+
if (state.leads[room.id] || this.hub.defaultReplyIn(room.id)?.id !== member.id) continue;
|
|
128
128
|
const target = this.candidate(room.id, '', member.id, reserved);
|
|
129
129
|
if (!target) continue;
|
|
130
130
|
state.leads[room.id] = { to: target.id, woken: false };
|
|
@@ -135,8 +135,8 @@ export class QuotaCooling {
|
|
|
135
135
|
for (const [room, lead] of Object.entries(state.leads)) {
|
|
136
136
|
if (lead.woken || this.cooling(lead.to)) continue;
|
|
137
137
|
// Replay a crash between persisting intent and changing the selection.
|
|
138
|
-
if (this.hub.
|
|
139
|
-
if (this.hub.
|
|
138
|
+
if (this.hub.defaultReplyIn(Number(room))?.id === member.id) this.hub.store.setLead(Number(room), lead.to);
|
|
139
|
+
if (this.hub.defaultReplyIn(Number(room))?.id !== lead.to) continue;
|
|
140
140
|
const msg = this.notice(Number(room), `${key}-lead-${room}`, `${member.name} 额度冷却,主角色交给 ${this.tasks.nameOf(lead.to)}。请接着推进未完成任务。`);
|
|
141
141
|
if (this.wake(lead.to, msg)) { lead.woken = true; this.save(member.id, state, true); }
|
|
142
142
|
}
|
package/collab/roster.mjs
CHANGED
|
@@ -208,7 +208,7 @@ export class Roster {
|
|
|
208
208
|
this.avatars?.remove(id);
|
|
209
209
|
this.store.removeMember(id);
|
|
210
210
|
// #176: a dead id must not linger as a channel's primary role.
|
|
211
|
-
for (const roomId of this.store.clearLeadOf(id)) this.hub.emit({ type: 'room-settings', room_id: this.store.settingsRoom(roomId), lead_id: null, effective_lead_id: this.hub.
|
|
211
|
+
for (const roomId of this.store.clearLeadOf(id)) this.hub.emit({ type: 'room-settings', room_id: this.store.settingsRoom(roomId), lead_id: null, effective_lead_id: this.hub.defaultReplyIn(roomId)?.id ?? null });
|
|
212
212
|
this.dsh.dropMember(id);
|
|
213
213
|
}
|
|
214
214
|
|
|
@@ -248,11 +248,15 @@ export class Roster {
|
|
|
248
248
|
throw new CollabError('invalid', `name must be 1-${MAX_NAME} characters without spaces (except Claude Code), @ or <>`);
|
|
249
249
|
}
|
|
250
250
|
const rooms = input?.rooms ?? [];
|
|
251
|
-
|
|
251
|
+
// A private chat holds exactly its one member (hub.membersInRoom), so listing it here would
|
|
252
|
+
// store a membership the room never honours: the extra member shows nowhere, cannot be
|
|
253
|
+
// dispatched to, and the add-member dialog is the only thing that creates it.
|
|
254
|
+
if (!Array.isArray(rooms) || rooms.some(id => !Number.isSafeInteger(id) || this.store.room(id)?.kind !== 'chat' || this.store.dmMember(id) !== undefined)) {
|
|
252
255
|
throw new CollabError('invalid', 'rooms must contain existing chat channel ids');
|
|
253
256
|
}
|
|
254
257
|
const clash = this.hub.memberList().find(m => m.id !== selfId && (!rooms.length || !m.rooms?.length || rooms.some(id => m.rooms.includes(id))) && (m.name.toLowerCase() === name.toLowerCase() || m.id.toLowerCase() === name.toLowerCase()));
|
|
255
|
-
|
|
258
|
+
// #366: 这条会原样显示在成员对话框的错误条里,和同页的成员名校验一样说中文。
|
|
259
|
+
if (clash !== undefined) throw new CollabError('conflict', `名字 ${name} 已被 ${clash.name} 使用`);
|
|
256
260
|
const persona = String(input?.persona ?? '');
|
|
257
261
|
if (persona.length > MAX_PERSONA) throw new CollabError('invalid', `persona exceeds ${MAX_PERSONA} characters`);
|
|
258
262
|
let avatar;
|
package/collab/store.mjs
CHANGED
|
@@ -393,19 +393,21 @@ export class Store {
|
|
|
393
393
|
* workspace is addressed, so a duplicate or a workspace-shaped name is refused with 'conflict'.
|
|
394
394
|
*/
|
|
395
395
|
checkRoomName(name, selfId) {
|
|
396
|
-
|
|
396
|
+
// #366: 这两句会原样显示在客户端改名/新建对话框的错误条里,因此和同页其它校验一样说中文。
|
|
397
|
+
if (new RegExp(`^${WORKSPACE_PREFIX}\\d+$`).test(name)) throw new CollabError('conflict', `${name} 是工作区保留名,换个名字`);
|
|
397
398
|
// A private chat is named after its member and listed with its avatar, so it does not clash.
|
|
398
399
|
const clash = this.rooms().find(r => r.id !== selfId && r.kind === 'chat' && r.dm_member == null && r.name.toLowerCase() === name.toLowerCase());
|
|
399
|
-
if (clash !== undefined) throw new CollabError('conflict',
|
|
400
|
+
if (clash !== undefined) throw new CollabError('conflict', `频道名 ${name} 已被占用`);
|
|
400
401
|
}
|
|
401
402
|
/** Rename the channel without changing any room, message or session identity. */
|
|
402
403
|
renameRoom(id, name) {
|
|
403
404
|
if (typeof name !== 'string' || !name || name.length > 32 || /\s/.test(name)) {
|
|
404
|
-
throw new CollabError('invalid', '
|
|
405
|
+
throw new CollabError('invalid', '频道名 1–32 个字符,且不能有空格');
|
|
405
406
|
}
|
|
406
407
|
const room = this.room(id);
|
|
407
|
-
if (room === undefined) throw new CollabError('room-missing',
|
|
408
|
-
|
|
408
|
+
if (room === undefined) throw new CollabError('room-missing', `频道 ${id} 不存在`);
|
|
409
|
+
// 工作区名是系统分配的(工作区<n>),改名会和一个地址撞名,所以服务端直接拒绝。
|
|
410
|
+
if (room.kind === 'workspace') throw new CollabError('invalid', '工作区名称由系统分配,不能修改');
|
|
409
411
|
this.checkRoomName(name, id);
|
|
410
412
|
return this.db.prepare('UPDATE rooms SET name = ? WHERE id = ? RETURNING id, name, kind, parent_id').get(name, id);
|
|
411
413
|
}
|
|
@@ -878,6 +880,11 @@ export function publicMessage(row, store) {
|
|
|
878
880
|
id: row.id, room_id: row.room_id, author_kind: row.author_kind, author: row.author, name: row.name,
|
|
879
881
|
text: row.text, created_at: row.created_at, card: row.card, from_turn: row.from_turn,
|
|
880
882
|
...(row.edited_at != null ? { edited_at: row.edited_at } : {}),
|
|
883
|
+
// #1355: one message keeps its id for life; revision moves on every in-place edit or
|
|
884
|
+
// commit (changed_at), so a consumer can tell an update from a new message. `draft`
|
|
885
|
+
// marks a streamed reply that has not settled yet.
|
|
886
|
+
revision: row.changed_at ?? row.created_at,
|
|
887
|
+
...(row.draft ? { draft: true } : {}),
|
|
881
888
|
};
|
|
882
889
|
const quote = store?.quote(row);
|
|
883
890
|
if (quote !== undefined) out.reply_to = quote;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[团队模式规矩 — 每个角色都适用]
|
|
2
|
-
-
|
|
2
|
+
- 频道分两种:聊天频道(用户聊天、看结果)和它下面的工作区(干活)。
|
|
3
3
|
- 多步骤的活先开公开任务卡(先发 --kind start);闲聊、问答不开。
|
|
4
4
|
- 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(至少写「用户原话」「总目标」「范围」三节和参照实现,范围每项写一行「改:…」;被退回按列出的一次补齐),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
|
|
5
5
|
- 聊天频道里对真人说人话:讲场景、短段落,默认不用表格、不贴代码,细节进子话题。
|
|
6
6
|
- 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
|
|
7
7
|
- 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。截图用 ws-up --image 推;文字进度不推。
|
|
8
8
|
- 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。
|
|
9
|
-
-
|
|
9
|
+
- 聊天频道共用工作区1..n,各同时只归一个,挑空的,都占着就新建;用户要新频道用 `bothouse channel create <名字>`,不自己调 Discord API。
|
|
10
10
|
- 收口(每小时合结果、发汇报)时,没收完、这一小时也没在动的活由收口的人当场暂停(`board pause <编号>`):它让出工作区,那里没别的活就放回池子、清空会话;恢复时重新交活,先用空着的工作区。
|
|
11
11
|
- 会缩小或改变用户原话范围的决定(砍功能、换方向等),以及用户原话里没有、你拿不准的设定,不要自己定:在工作区用 [ask] 转到聊天频道问用户,等答复再做;等的时候先做不受影响的部分。主角色拿不准也一样,在聊天频道直接问用户。
|
|
12
12
|
- 转话必须指定哪个工作区,不默认发给最后一个。
|