cs-bots 0.9.0 → 0.9.3
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/CHANGELOG.md +12 -0
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +5 -5
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +1 -1
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +1 -1
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +1 -1
- package/package.json +9 -1
- package/src/api.js +267 -20
- package/src/board.js +94 -9
- package/src/client.js +86 -8
- package/src/room.js +111 -37
- package/src/roster.js +130 -24
- package/src/suggest.js +110 -0
- package/src/tools.js +75 -2
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
+
## [0.9.3] - 2026-11-12
|
|
17
|
+
|
|
18
|
+
建议可编辑:内容可改、等级可选(L1–L5)。
|
|
19
|
+
|
|
20
|
+
## [0.9.2] - 2026-11-12
|
|
21
|
+
|
|
22
|
+
任务栏对话合并:用户发言与执行者回复同屏。
|
|
23
|
+
|
|
24
|
+
## [0.9.1] - 2026-11-12
|
|
25
|
+
|
|
26
|
+
任务来源渠道;任务开议事厅;每轮 md 产出物;用户身份归一;清理退役 Bot。
|
|
27
|
+
|
|
16
28
|
## [0.9.0] - 2026-11-11
|
|
17
29
|
|
|
18
30
|
> 第 1 批 = 定向唤醒 / kind 字段 / attachRoom(止血)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cs-bots 架构总览(改造07 · 分层架构 + 改造08 · 人机分治)
|
|
2
2
|
|
|
3
|
-
> 版本:v0.9.
|
|
3
|
+
> 版本:v0.9.3(改造07 分层 + 改造08 需求1–5 + 改造09 Boss 体验/任务可管理性/升级接续)
|
|
4
4
|
> 日期:2026-11-10
|
|
5
5
|
> 状态:改造09 进行中 —— 套件规模 1190 项,截至 2026-11-10 有 6 项失败待修(详见 `docs/CONTRACT-升级09-Boss体验.md` §10)。改完前**不要**把本文当作「全绿」依据。
|
|
6
6
|
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
│ │
|
|
51
51
|
│ ┌──────────────────────────────────────────────────────┐ │
|
|
52
52
|
│ │ AI 智能体调用接口(机器友好、可审计) │ │
|
|
53
|
-
│ │
|
|
53
|
+
│ │ 45 个 bot_* 工具 + HTTP API │ │
|
|
54
54
|
│ └──────────────────────────────────────────────────────┘ │
|
|
55
55
|
└─────────────────────────────────────────────────────────────┘
|
|
56
56
|
```
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
| 后端运行时 | Node.js ≥22.5,ESM,零第三方运行时依赖 |
|
|
142
142
|
| HTTP API | 宿主 `webServer.register`(前缀路由 `/cs-bots/api`) |
|
|
143
143
|
| 前端 | React 18,宿主模块加载器 `window.__ModuleLoader__.load` |
|
|
144
|
-
| 模型工具 | 宿主 `tools.register`,
|
|
144
|
+
| 模型工具 | 宿主 `tools.register`,45 个 `bot_*` 工具 |
|
|
145
145
|
| 持久化 | JSONL 事件流 + 原子快照,SQLite 不引入 |
|
|
146
146
|
| 安全 | 会话鉴权围栏 + 同源校验 + 受保护区 + CSRF 防护 |
|
|
147
147
|
|
|
@@ -212,7 +212,7 @@ src/subscription.js 任务订阅 + 房间扇出(改造07 新增)
|
|
|
212
212
|
src/routing/ 复杂度路由
|
|
213
213
|
src/store.js 存储原语(jsonl + 快照 + 幂等集 + 原子写)
|
|
214
214
|
src/kernel.js 宿主桥(唯一可引用宿主包的文件)
|
|
215
|
-
src/tools.js
|
|
215
|
+
src/tools.js 45 个模型工具
|
|
216
216
|
src/index.js 装配入口
|
|
217
217
|
```
|
|
218
218
|
|
|
@@ -227,7 +227,7 @@ src/index.js 装配入口
|
|
|
227
227
|
| 房间→任务 | 无连接 | Subscription 扇出 |
|
|
228
228
|
| Bot 开工 | 无准备步骤 | prepare command |
|
|
229
229
|
| 底层可见性 | 无(黑盒) | 穿透面板(7 区块) |
|
|
230
|
-
| 工具数 | 38 个 | 41 个 | **
|
|
230
|
+
| 工具数 | 38 个 | 41 个 | **45 个**(改造09 新增 `bot_task_phase`,bots04 新增 `bot_team_route`,t-40 新增 `bot_roster_purge`、`bot_task_room`) |
|
|
231
231
|
| 测试数 | 949 项 | 1036 项 |
|
|
232
232
|
|
|
233
233
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cs-bots",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "跨团队桥 + 定向唤醒 + 升级可回滚。",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -57,5 +57,13 @@
|
|
|
57
57
|
"publishConfig": {
|
|
58
58
|
"access": "public",
|
|
59
59
|
"registry": "https://registry.npmjs.org"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@deepseek-ai/dsh": ">=0.2.2-rc.1 <0.3.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@deepseek-ai/dsh": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
60
68
|
}
|
|
61
69
|
}
|
package/src/api.js
CHANGED
|
@@ -865,6 +865,16 @@ function buildTaskAnchorText(task, notes = [], longrun = null) {
|
|
|
865
865
|
lines.push(...describeMandate(mandate))
|
|
866
866
|
}
|
|
867
867
|
|
|
868
|
+
// ── 产出物要求(2026-11-12 t-40 需求③)─────────────────────────────────
|
|
869
|
+
// Boss:「成果,任务的产出物在此,每轮会话的都应该有 md 产出物」。
|
|
870
|
+
// 只靠 `complete` 强制 artifacts 不够 —— 那是**收尾时**才校验,
|
|
871
|
+
// 执行者中途几轮可以什么都不留下,等到最后一次性补一份,过程全丢。
|
|
872
|
+
// 所以把这条要求写进**每次唤醒**都会看到的锚文本里,每轮结束都留一份 md。
|
|
873
|
+
lines.push('成果(t-40 需求③):本轮要有可点开的 md 产出物,不要只写进对话里 ——')
|
|
874
|
+
lines.push(` · 每轮结束都把结论写成 md 文件,再用 bot_task_update(action='complete', artifacts=[...]) 登记;`)
|
|
875
|
+
lines.push(' · 中途轮次同样要留文件(过程记录也算成果),别等收尾才一次性补;')
|
|
876
|
+
lines.push(' · 没写文件就当没交付 —— 对话里的内容别人打不开、也追溯不了。')
|
|
877
|
+
|
|
868
878
|
lines.push('补充说明(按时间,全部有效,不得忽略):')
|
|
869
879
|
for (const note of notes) {
|
|
870
880
|
lines.push(`- [${formatAnchorTime(note?.at)}] ${note?.text ?? ''}`)
|
|
@@ -1779,6 +1789,71 @@ async function ensureTeamForBot(services, botSessionId, by) {
|
|
|
1779
1789
|
}
|
|
1780
1790
|
}
|
|
1781
1791
|
|
|
1792
|
+
/**
|
|
1793
|
+
* 给团队确保一个议事厅并返回它(Boss 2026-11-12 需求:长任务自动建房)。
|
|
1794
|
+
*
|
|
1795
|
+
* ## 为什么要它
|
|
1796
|
+
* `ensureTeamForBot()` 只建**团队**,不建**房间**。于是长任务建完后:
|
|
1797
|
+
* - 团队有了,但成员没有地方说话;
|
|
1798
|
+
* - 结果只能靠人挨个去问,没有一处「这个长任务进展/结论」的公共出口。
|
|
1799
|
+
* Boss 原话:「以后每个长任务都自动创建房间,结果传递给房间群主」。
|
|
1800
|
+
*
|
|
1801
|
+
* ## 语义
|
|
1802
|
+
* - 团队已有房间 ⇒ 直接复用(幂等,不重复建)
|
|
1803
|
+
* - 没有 ⇒ 建 `${team.name} · 议事厅`,成员 = 负责人 + 团队现有成员 + **委托人**
|
|
1804
|
+
* - `adminId = team.leadId`(群主 = 负责人),与建团队时 `adminPolicy:'lead-only'` 一致
|
|
1805
|
+
* - 负责人已在成员里 ⇒ `mode:'admin'`,让他能改名/归档
|
|
1806
|
+
*
|
|
1807
|
+
* ## 失败不阻断
|
|
1808
|
+
* 任务和团队已经建好了,房间没建上只是少一个沟通出口 —— 返回 `{reason}` 让调用方
|
|
1809
|
+
* 如实带回给页面,**绝不静默**。
|
|
1810
|
+
*
|
|
1811
|
+
* @returns {Promise<{room?: object, created?: boolean, reason?: string}>}
|
|
1812
|
+
*/
|
|
1813
|
+
async function ensureRoomForTeam(services, team, by) {
|
|
1814
|
+
const rooms = services.room
|
|
1815
|
+
if (!team || !rooms || typeof rooms.create !== 'function') return { reason: '没有可用的房间服务或团队' }
|
|
1816
|
+
try {
|
|
1817
|
+
if (team.roomId) {
|
|
1818
|
+
const existing = rooms.get?.(team.roomId)
|
|
1819
|
+
if (existing) return { room: existing, created: false }
|
|
1820
|
+
// 团队记着 roomId 但房间已不存在(被归档/删除)⇒ 下面重建,并在结果里说明
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// 成员:负责人(群主)+ 团队现有成员 + 委托人(谁派的活谁要能看到结果)
|
|
1824
|
+
const leadId = team.leadId
|
|
1825
|
+
const memberList = [leadId, ...(Array.isArray(team.members) ? team.members : []), by]
|
|
1826
|
+
.filter((v) => typeof v === 'string' && v !== '')
|
|
1827
|
+
const members = [...new Set(memberList)]
|
|
1828
|
+
|
|
1829
|
+
const room = rooms.create({
|
|
1830
|
+
name: `${team.name} · 议事厅`,
|
|
1831
|
+
members,
|
|
1832
|
+
// ⚠️ 必须是 `everyone`,不能是 `admin`(2026-11-12 t-46 缺陷#5 实测修正)。
|
|
1833
|
+
//
|
|
1834
|
+
// 曾经的错误推理:「负责人是群主 ⇒ admin 模式(他能改名/归档)」。
|
|
1835
|
+
// 但 `admin` 模式在 `src/room.js:1754` 会把**除群主外的所有成员**挡在门外:
|
|
1836
|
+
// ROOM_POST_FORBIDDEN:成员 … 无发言权
|
|
1837
|
+
// 而这个房间的用途恰恰是「**别人**把长任务的结果回传进来」——
|
|
1838
|
+
// 执行者不是群主,于是结果一条都发不出去,需求直接落空。
|
|
1839
|
+
//
|
|
1840
|
+
// 群主/负责人的特权用 `adminId` 之外的方式表达(成员表里首位 = 负责人),
|
|
1841
|
+
// 不靠牺牲发言权来换。
|
|
1842
|
+
mode: 'everyone',
|
|
1843
|
+
purpose: `长任务「${team.name}」的议事厅:负责人主持,进展与结论回传到此`,
|
|
1844
|
+
byId: by ?? 'ui',
|
|
1845
|
+
})
|
|
1846
|
+
|
|
1847
|
+
// 把房间记到团队上(幂等;已有 roomId 的情况在这里被覆盖为新房)
|
|
1848
|
+
if (typeof services.teams?.attachRoom === 'function') {
|
|
1849
|
+
services.teams.attachRoom({ id: team.id, roomId: room.id, by: by ?? 'ui' })
|
|
1850
|
+
}
|
|
1851
|
+
return { room, created: true }
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
return { reason: `自动建房间失败:${error?.message ?? error}` }
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1782
1857
|
/**
|
|
1783
1858
|
* 投递能力预检(2026-11-09 修复「任务指派后卡在排队中、失败无声」)。
|
|
1784
1859
|
*
|
|
@@ -2198,10 +2273,29 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2198
2273
|
}
|
|
2199
2274
|
mountPhaseWrite(getServices(ctx))
|
|
2200
2275
|
|
|
2276
|
+
// ── t-40 需求②:把「给任务开议事厅」挂到 services(供 bot_task_room 工具调用)──
|
|
2277
|
+
// 与 HTTP `POST /tasks/room` 共用同一个实现,避免「页面能开、Bot 开不了」的漂移。
|
|
2278
|
+
const openTaskRoom = async (services, taskId, by) => {
|
|
2279
|
+
const task = await withService(services, 'board', (b) => b.get(taskId))
|
|
2280
|
+
if (!task) return { ok: false, reason: `任务 ${taskId} 不存在` }
|
|
2281
|
+
const who = task.assignee ?? task.ownerId ?? by
|
|
2282
|
+
const got = await ensureTeamForBot(services, who, by)
|
|
2283
|
+
if (!got?.team) return { ok: false, reason: '这个任务没有可用团队(没有负责人?)——先指派负责人再开议事厅' }
|
|
2284
|
+
const r = await ensureRoomForTeam(services, got.team, by)
|
|
2285
|
+
if (!r?.room) return { ok: false, reason: r?.reason ?? '建房失败' }
|
|
2286
|
+
return { ok: true, roomId: r.room.id, name: r.room.name, created: r.created === true, teamId: got.team.id }
|
|
2287
|
+
}
|
|
2288
|
+
const mountTaskRoom = (services) => {
|
|
2289
|
+
if (!services || typeof services !== 'object') return
|
|
2290
|
+
services.openTaskRoom = (taskId, by) => openTaskRoom(services, taskId, by)
|
|
2291
|
+
}
|
|
2292
|
+
mountTaskRoom(getServices(ctx))
|
|
2293
|
+
|
|
2201
2294
|
const handler = async (request, response) => {
|
|
2202
2295
|
const services = getServices(ctx)
|
|
2203
2296
|
// 幂等重挂:services 若在 installApi 之后才注册,这里补上(写同一个 key)
|
|
2204
2297
|
mountPhaseWrite(services)
|
|
2298
|
+
mountTaskRoom(services)
|
|
2205
2299
|
// 鉴权先于一切业务逻辑(连 URL 解析都不做,避免攻击面)
|
|
2206
2300
|
const verdict = fence.judgeHttp(request)
|
|
2207
2301
|
if (!verdict.ok) {
|
|
@@ -2271,7 +2365,8 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2271
2365
|
const source = qp(url, 'source')
|
|
2272
2366
|
const sort = qp(url, 'sort')
|
|
2273
2367
|
const byId = qp(url, 'byId')
|
|
2274
|
-
const
|
|
2368
|
+
const channel = qp(url, 'channel')
|
|
2369
|
+
const opts = { status: status ?? undefined, attention: attention || undefined, priority: priority ?? undefined, source: source ?? undefined, channel: channel ?? undefined, sort: sort ?? undefined, byId }
|
|
2275
2370
|
const tasks = await withService(services, 'board', (b) =>
|
|
2276
2371
|
onlyReady ? b.ready() : b.list(opts),
|
|
2277
2372
|
)
|
|
@@ -2325,6 +2420,29 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2325
2420
|
autoTeam = await ensureTeamForBot(services, rest.assignee, rest.byId ?? 'ui')
|
|
2326
2421
|
}
|
|
2327
2422
|
|
|
2423
|
+
// ── 长任务自动建房间(Boss 2026-11-12 需求)────────────────────────
|
|
2424
|
+
// 原话:「以后每个长任务都自动创建房间,结果传递给房间群主」。
|
|
2425
|
+
// 为什么只给长任务建:普通任务一两天就结束,建房只会留下大量死房间;
|
|
2426
|
+
// 长任务跨多次唤醒、要多个阶段交付,需要一个**公共出口**让群主看到进展与结论。
|
|
2427
|
+
// 复用 autoTeam 的团队(不另建),团队没有就现场补一个。
|
|
2428
|
+
let autoRoom
|
|
2429
|
+
if (wantsLongrun && rest.autoRoom !== false) {
|
|
2430
|
+
const by = rest.byId ?? 'ui'
|
|
2431
|
+
let team = autoTeam?.team
|
|
2432
|
+
if (!team) {
|
|
2433
|
+
autoTeam = await ensureTeamForBot(services, rest.assignee ?? by, by)
|
|
2434
|
+
team = autoTeam?.team
|
|
2435
|
+
}
|
|
2436
|
+
if (team) {
|
|
2437
|
+
const got = await ensureRoomForTeam(services, team, by)
|
|
2438
|
+
autoRoom = got.room
|
|
2439
|
+
? { roomId: got.room.id, name: got.room.name, created: got.created }
|
|
2440
|
+
: { reason: got.reason }
|
|
2441
|
+
} else {
|
|
2442
|
+
autoRoom = { reason: autoTeam?.reason ?? '没有团队可用于建房' }
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2328
2446
|
// 阶段清单落在 task-meta.jsonl(与 evolve 同一份存储、同一形状)⇒
|
|
2329
2447
|
// `buildTaskAnchorText` 自动带上,第一次唤醒就可见,不需要再转。
|
|
2330
2448
|
if (wantsLongrun) {
|
|
@@ -2338,7 +2456,9 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2338
2456
|
})
|
|
2339
2457
|
sendJson(response, 200, {
|
|
2340
2458
|
ok: true, task, phases: normalizedPhases, longrun: true, autoTeam,
|
|
2341
|
-
|
|
2459
|
+
...(autoRoom ? { autoRoom } : {}),
|
|
2460
|
+
note: '已按长任务创建;此后每次唤醒都会带上阶段清单'
|
|
2461
|
+
+ (autoRoom?.roomId ? `,进展与结论请回传到议事厅 ${autoRoom.roomId}` : ''),
|
|
2342
2462
|
})
|
|
2343
2463
|
return
|
|
2344
2464
|
}
|
|
@@ -2554,6 +2674,42 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2554
2674
|
return
|
|
2555
2675
|
}
|
|
2556
2676
|
|
|
2677
|
+
// ── 任务开议事厅(t-40 需求②)────────────────────────────────────────
|
|
2678
|
+
// Boss:「只有长任务默认建房间,其他短任务默认不建房间,防止房间爆炸,
|
|
2679
|
+
// 在任务页有『开议事厅』按钮,用户需要的时候再建」
|
|
2680
|
+
// 长任务在 /tasks 创建时已自动建;短任务走这个端点按需建,幂等(已有就复用)。
|
|
2681
|
+
if (request.method === 'POST' && path === '/tasks/room') {
|
|
2682
|
+
const body = (await readBody(request)) ?? {}
|
|
2683
|
+
const by = body.byId ?? 'ui'
|
|
2684
|
+
const taskId = String(body.id ?? '').trim()
|
|
2685
|
+
if (!taskId) { sendJson(response, 400, { ok: false, reason: 'id 必填' }); return }
|
|
2686
|
+
// 与 bot_task_room 工具共用 openTaskRoom 一份实现(不写两套)
|
|
2687
|
+
const r = await openTaskRoom(services, taskId, by)
|
|
2688
|
+
if (!r.ok) {
|
|
2689
|
+
sendJson(response, r.reason?.includes('不存在') ? 404 : 409, { ok: false, reason: r.reason })
|
|
2690
|
+
return
|
|
2691
|
+
}
|
|
2692
|
+
sendJson(response, 200, {
|
|
2693
|
+
...r,
|
|
2694
|
+
note: r.created === true ? '议事厅已建好' : '该任务已有议事厅,直接复用',
|
|
2695
|
+
})
|
|
2696
|
+
return
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// 清理已退役 Bot(t-40):retire 只是标记,这里把记录真正移出注册表。
|
|
2700
|
+
// 只允许清 retired;不碰 cwd、不杀会话。
|
|
2701
|
+
if (request.method === 'POST' && path === '/roster/purge') {
|
|
2702
|
+
const body = (await readBody(request)) ?? {}
|
|
2703
|
+
const result = await withService(services, 'roster', (r) => r.purge({
|
|
2704
|
+
ids: body.ids,
|
|
2705
|
+
allRetired: body.allRetired,
|
|
2706
|
+
reason: body.reason,
|
|
2707
|
+
byId: body.byId ?? 'ui',
|
|
2708
|
+
}))
|
|
2709
|
+
sendJson(response, 200, { ok: true, ...result })
|
|
2710
|
+
return
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2557
2713
|
// ── 房间 ──────────────────────────────────────────────────────────────
|
|
2558
2714
|
if (request.method === 'GET' && path === '/rooms') {
|
|
2559
2715
|
// includeArchived=1 时连归档房一起返回(超级管理员的"回收站"视图)
|
|
@@ -2608,11 +2764,21 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2608
2764
|
// - 房间设置 `allowBotAutoPost` 为 false 时跳过(给用户关开关的权利);
|
|
2609
2765
|
// - dedupKey 用消息 ID,天然幂等,同一消息不会重复唤醒;
|
|
2610
2766
|
// - dispatcher 缺失时降级为「只落消息不唤醒」,不抛错。
|
|
2767
|
+
//
|
|
2768
|
+
// ⚠️ 修复(2026-11-12 t-40-B):目标计算改走 room 服务的 `computeWakeTargets`。
|
|
2769
|
+
// 此前这里是裸的 `members.filter((m) => m !== skipSelf)`,**既不看
|
|
2770
|
+
// `mentionOnly`、也不排除人类 id** —— 实测 6 个房间全部设了
|
|
2771
|
+
// `mentionOnly: true`,却一次都没生效;8 成员房间一条消息 = 8 次模型调用,
|
|
2772
|
+
// 打给 `ui/user/person` 的那几次必然 agent-not-live。
|
|
2773
|
+
// 现在与 `room.relay()` 共用同一份四档规则(见 room.js computeWakeTargets)。
|
|
2611
2774
|
const room = await withService(services, 'room', (r) => r.get(message.roomId))
|
|
2612
2775
|
const senderSession = body.from ?? 'ui'
|
|
2613
|
-
const
|
|
2614
|
-
|
|
2615
|
-
|
|
2776
|
+
const decided = await withService(services, 'room', (r) =>
|
|
2777
|
+
r.computeWakeTargets(
|
|
2778
|
+
{ members: room?.members ?? [], settings: room?.settings },
|
|
2779
|
+
{ from: senderSession, text: message.text, mentions: message.mentions },
|
|
2780
|
+
))
|
|
2781
|
+
const toWake = decided?.targets ?? []
|
|
2616
2782
|
const wakeByTarget = new Map()
|
|
2617
2783
|
if (toWake.length > 0 && room?.settings?.allowBotAutoPost !== false) {
|
|
2618
2784
|
for (const target of toWake) {
|
|
@@ -4692,6 +4858,23 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
4692
4858
|
sendJson(response, 200, { ok: true, suggestion: applied })
|
|
4693
4859
|
return
|
|
4694
4860
|
}
|
|
4861
|
+
// 修改待决建议(t-40):内容 / 等级 / 类型都能改,改完仍是 pending。
|
|
4862
|
+
// Boss:「用户应该可以修改完善并确定等级范围」——只给采纳/驳回,措辞不合意就只能驳回重等。
|
|
4863
|
+
if (request.method === 'POST' && path === '/suggestions/edit') {
|
|
4864
|
+
const body = (await readBody(request)) ?? {}
|
|
4865
|
+
const item = await withService(services, 'suggest', (sg) => sg.edit({
|
|
4866
|
+
id: body.id,
|
|
4867
|
+
expectedRev: body.expectedRev,
|
|
4868
|
+
kind: body.kind,
|
|
4869
|
+
layer: body.layer,
|
|
4870
|
+
title: body.title,
|
|
4871
|
+
content: body.content,
|
|
4872
|
+
payload: body.payload,
|
|
4873
|
+
by: body.by ?? OWNER,
|
|
4874
|
+
}))
|
|
4875
|
+
sendJson(response, 200, { ok: true, suggestion: item })
|
|
4876
|
+
return
|
|
4877
|
+
}
|
|
4695
4878
|
if (request.method === 'POST' && path === '/suggestions/reject') {
|
|
4696
4879
|
const body = (await readBody(request)) ?? {}
|
|
4697
4880
|
const item = await withService(services, 'suggest', (s) =>
|
|
@@ -5231,30 +5414,94 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
5231
5414
|
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
5232
5415
|
return
|
|
5233
5416
|
}
|
|
5234
|
-
//
|
|
5417
|
+
// ══ t-40 需求(2026-11-12):任务栏要看到「用户说的话」+「Bot 的回复」═════
|
|
5418
|
+
// 原实现只读**执行者的会话**,看不到用户自己说过什么:
|
|
5419
|
+
// 用户在页面上发的指令走的是**用户自己的会话**(或只落进 task.note),
|
|
5420
|
+
// 执行者会话里只有 Bot 的输出 —— 于是任务栏永远缺一半。
|
|
5421
|
+
//
|
|
5422
|
+
// 现在改成**多会话合并成一条时间线**,每条消息标注来源:
|
|
5423
|
+
// ① 执行者会话(Bot 怎么干的、说了什么)
|
|
5424
|
+
// ② 用户会话(用户下了什么令 —— 从 task.note 的 by 反查不到会话时,
|
|
5425
|
+
// 仍用 note 行兜底,保证「用户说过的话」至少不会丢)
|
|
5426
|
+
// 合并后按时间升序,前端按 role/source 区分气泡。
|
|
5427
|
+
const HUMAN_IDS_ = new Set(['ui', 'user', 'person', 'anyone', 'system'])
|
|
5235
5428
|
const candidates = [task.assignee, task.ownerId, task.anchor?.ownerId]
|
|
5236
|
-
.filter((v) => typeof v === 'string' && v && !
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
ok: false,
|
|
5240
|
-
reason: '这个任务还没有执行者(未派活),没有对话可看',
|
|
5241
|
-
code: 'NO_EXECUTOR',
|
|
5242
|
-
})
|
|
5243
|
-
return
|
|
5244
|
-
}
|
|
5245
|
-
// 把候选 sessionId 映射到 Bot 记录(拿真名)
|
|
5429
|
+
.filter((v) => typeof v === 'string' && v && !HUMAN_IDS_.has(v))
|
|
5430
|
+
|
|
5431
|
+
// 执行者身份(拿真名用)
|
|
5246
5432
|
let bot
|
|
5247
5433
|
try {
|
|
5248
5434
|
const all = services.roster?.list?.({ includeRetired: true }) ?? []
|
|
5249
5435
|
bot = all.find((b) => candidates.includes(b.sessionId) || candidates.includes(b.id))
|
|
5250
5436
|
} catch { bot = undefined }
|
|
5251
5437
|
const sessionId = bot?.sessionId ?? candidates[0]
|
|
5252
|
-
|
|
5253
|
-
const
|
|
5438
|
+
|
|
5439
|
+
const limit = qn(url, 'limit') ?? 40
|
|
5440
|
+
/** @type {Array<object>} 合并后的时间线 */
|
|
5441
|
+
const timeline = []
|
|
5442
|
+
|
|
5443
|
+
if (sessionId) {
|
|
5444
|
+
const out = await readSessionMessages(ctx, sessionId, { limit })
|
|
5445
|
+
if (out?.ok && Array.isArray(out.messages)) {
|
|
5446
|
+
for (const m of out.messages) {
|
|
5447
|
+
timeline.push({
|
|
5448
|
+
...m,
|
|
5449
|
+
source: 'executor',
|
|
5450
|
+
speaker: bot?.name ?? sessionId,
|
|
5451
|
+
sessionId,
|
|
5452
|
+
})
|
|
5453
|
+
}
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
|
|
5457
|
+
// 用户侧:任务的补充说明(note)就是「用户在任务里说的话」的权威记录。
|
|
5458
|
+
// 它**一定存在**且一定属于这个任务,比猜会话可靠 —— 所以以它为准,
|
|
5459
|
+
// 不依赖「用户当时在哪个会话里」这种我们记录不到的信息。
|
|
5460
|
+
try {
|
|
5461
|
+
const notes = typeof readTaskNotes === 'function' ? readTaskNotes(services, id) : []
|
|
5462
|
+
for (const n of notes) {
|
|
5463
|
+
if (!n || typeof n.text !== 'string' || !n.text.trim()) continue
|
|
5464
|
+
const by = typeof n.by === 'string' ? n.by : 'user'
|
|
5465
|
+
timeline.push({
|
|
5466
|
+
role: HUMAN_IDS_.has(by) ? 'user' : 'assistant',
|
|
5467
|
+
kind: 'text',
|
|
5468
|
+
text: n.text,
|
|
5469
|
+
at: Number.isFinite(Number(n.at)) ? Number(n.at) : undefined,
|
|
5470
|
+
source: 'note',
|
|
5471
|
+
speaker: HUMAN_IDS_.has(by) ? 'Boss' : by,
|
|
5472
|
+
})
|
|
5473
|
+
}
|
|
5474
|
+
} catch { /* notes 读不到不影响主时间线 */ }
|
|
5475
|
+
|
|
5476
|
+
// 时间升序;没有时间戳的排在前面(避免被甩到最后)
|
|
5477
|
+
timeline.sort((a, b) => {
|
|
5478
|
+
const ta = Number.isFinite(a.at) ? a.at : 0
|
|
5479
|
+
const tb = Number.isFinite(b.at) ? b.at : 0
|
|
5480
|
+
return ta - tb
|
|
5481
|
+
})
|
|
5482
|
+
|
|
5483
|
+
if (timeline.length === 0) {
|
|
5484
|
+
sendJson(response, 200, {
|
|
5485
|
+
ok: false,
|
|
5486
|
+
reason: '这个任务还没有对话记录(未派活、也没有补充说明)',
|
|
5487
|
+
code: 'NO_CONVERSATION',
|
|
5488
|
+
})
|
|
5489
|
+
return
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5254
5492
|
sendJson(response, 200, {
|
|
5493
|
+
ok: true,
|
|
5255
5494
|
...(bot ? { bot: { id: bot.id, name: bot.name, label: bot.label ?? null } } : {}),
|
|
5256
|
-
sessionId,
|
|
5257
|
-
|
|
5495
|
+
sessionId: sessionId ?? null,
|
|
5496
|
+
messages: timeline,
|
|
5497
|
+
total: timeline.length,
|
|
5498
|
+
// 分开给,前端要单看某一侧时不用自己筛
|
|
5499
|
+
counts: {
|
|
5500
|
+
executor: timeline.filter((m) => m.source === 'executor').length,
|
|
5501
|
+
note: timeline.filter((m) => m.source === 'note').length,
|
|
5502
|
+
user: timeline.filter((m) => m.role === 'user').length,
|
|
5503
|
+
assistant: timeline.filter((m) => m.role === 'assistant').length,
|
|
5504
|
+
},
|
|
5258
5505
|
})
|
|
5259
5506
|
return
|
|
5260
5507
|
}
|
package/src/board.js
CHANGED
|
@@ -362,6 +362,66 @@ function normalizeRequiresPaid(value, where) {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/** labels 归一:必须是字符串数组(可选;undefined ⇒ 不写字段)。 */
|
|
365
|
+
/**
|
|
366
|
+
* 任务来源渠道(2026-11-12 t-40 需求①,「后续可以接其他任务来源」)。
|
|
367
|
+
*
|
|
368
|
+
* 为什么与 `source` 分成两个字段:
|
|
369
|
+
* - `source` 回答「**谁**下的令」:`human`(人) / `ai`(智能体)——二分的归属问题;
|
|
370
|
+
* - `channel` 回答「**从哪个入口**下的令」:UI 手工建 / 会话里产出 / 外部渠道接入……
|
|
371
|
+
* 两者正交:将来接「外部工单系统」时,它既是 `source:'external'` 也可能是人发起。
|
|
372
|
+
* 合成一个字段的话,每加一个渠道都要改 `source` 的判定,早晚把二分搞脏。
|
|
373
|
+
*
|
|
374
|
+
* 未知 channel 一律拒绝(`BOARD_BAD_CHANNEL`)而不是静默落一个拼错的值 ——
|
|
375
|
+
* 拼错的值会永久留在事件流里,之后所有按渠道统计都是错的。
|
|
376
|
+
*/
|
|
377
|
+
const TASK_CHANNELS = Object.freeze(['user', 'session', 'external'])
|
|
378
|
+
|
|
379
|
+
/** 归一 `source`:显式给了就只接受 human/ai;没给就按 byId 推断(人是 ui/user/person)。 */
|
|
380
|
+
function normalizeSource(value, byId, where) {
|
|
381
|
+
if (value === undefined || value === null || value === '') {
|
|
382
|
+
return isHumanId(byId) ? 'human' : 'ai'
|
|
383
|
+
}
|
|
384
|
+
if (value !== 'human' && value !== 'ai') {
|
|
385
|
+
const error = new Error(
|
|
386
|
+
`board.${where}:source 只能是 'human' 或 'ai',收到 ${JSON.stringify(value)}`,
|
|
387
|
+
)
|
|
388
|
+
error.status = 400
|
|
389
|
+
error.code = 'BOARD_BAD_SOURCE'
|
|
390
|
+
throw error
|
|
391
|
+
}
|
|
392
|
+
return value
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 归一 `channel`:没给就按来源推断 —— 人 → `user`,智能体 → `session`。
|
|
397
|
+
* `external` 留给「后续增加的其他渠道」显式声明(目前没有生产方,但字段与校验已就位)。
|
|
398
|
+
*/
|
|
399
|
+
function normalizeChannel(value, source, byId, where) {
|
|
400
|
+
if (value === undefined || value === null || value === '') {
|
|
401
|
+
return isHumanId(byId) || source === 'human' ? 'user' : 'session'
|
|
402
|
+
}
|
|
403
|
+
if (!TASK_CHANNELS.includes(value)) {
|
|
404
|
+
const error = new Error(
|
|
405
|
+
`board.${where}:channel 只能是 ${TASK_CHANNELS.join(' | ')},收到 ${JSON.stringify(value)}`,
|
|
406
|
+
)
|
|
407
|
+
error.status = 400
|
|
408
|
+
error.code = 'BOARD_BAD_CHANNEL'
|
|
409
|
+
throw error
|
|
410
|
+
}
|
|
411
|
+
return value
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 人类身份(与 room/teams 的 HUMAN_IDS 同口径)。
|
|
416
|
+
* 需求⑤:`ui`/`person` 都是历史写法,规范 id 统一为 `user`;这里仍全认(读侧兼容)。
|
|
417
|
+
*/
|
|
418
|
+
function isHumanId(id) {
|
|
419
|
+
return id === 'ui' || id === 'user' || id === 'person'
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 规范用户 id(写侧一律用它)。 */
|
|
423
|
+
const CANONICAL_USER = 'user'
|
|
424
|
+
|
|
365
425
|
function normalizeLabels(value, where) {
|
|
366
426
|
if (value === undefined || value === null) return undefined
|
|
367
427
|
if (!Array.isArray(value)) {
|
|
@@ -877,6 +937,9 @@ export function createBoard(ctx, config = {}) {
|
|
|
877
937
|
tasks.set(ev.taskId, t)
|
|
878
938
|
events.push(clone(ev))
|
|
879
939
|
idCounter = Math.max(idCounter, idNumber(ev.taskId))
|
|
940
|
+
// 老数据没有 channel(本字段 2026-11-12 才有):回放时按 source 补默认值,
|
|
941
|
+
// 保证「读侧永远非空」,而不是让 UI 到处判空。
|
|
942
|
+
if (t && t.channel === undefined) t.channel = t.source === 'human' ? 'user' : 'session'
|
|
880
943
|
if (t.sourceSeq) {
|
|
881
944
|
sourceSeqIndex.set(t.sourceSeq, ev.taskId)
|
|
882
945
|
idem.check(t.sourceSeq)
|
|
@@ -1002,7 +1065,7 @@ export function createBoard(ctx, config = {}) {
|
|
|
1002
1065
|
}
|
|
1003
1066
|
|
|
1004
1067
|
function list(options = {}) {
|
|
1005
|
-
const { status, attention, priority, dueAt, source, sort, triaged } = options ?? {}
|
|
1068
|
+
const { status, attention, priority, dueAt, source, channel, sort, triaged } = options ?? {}
|
|
1006
1069
|
if (status !== undefined && status !== null) {
|
|
1007
1070
|
if (!STATUSES.includes(status)) {
|
|
1008
1071
|
throw new BoardError(
|
|
@@ -1032,6 +1095,10 @@ export function createBoard(ctx, config = {}) {
|
|
|
1032
1095
|
if (source !== undefined && source !== null) {
|
|
1033
1096
|
if (t.source !== source) continue
|
|
1034
1097
|
}
|
|
1098
|
+
// 需求①:按渠道过滤(user / session / external)——「后续接其他渠道」时无需改这里
|
|
1099
|
+
if (channel !== undefined && channel !== null) {
|
|
1100
|
+
if (t.channel !== channel) continue
|
|
1101
|
+
}
|
|
1035
1102
|
if (triaged !== undefined && triaged !== null) {
|
|
1036
1103
|
const want = triaged === true || triaged === '1' || triaged === 'true'
|
|
1037
1104
|
if ((t.triaged === true) !== want) continue
|
|
@@ -1086,15 +1153,17 @@ export function createBoard(ctx, config = {}) {
|
|
|
1086
1153
|
function create(input = {}) {
|
|
1087
1154
|
const {
|
|
1088
1155
|
subject, description, blockedBy, writeScopes, priority, dueAt, byId, sourceSeq, now,
|
|
1089
|
-
expectedComplexity, requiresPaid, source,
|
|
1156
|
+
expectedComplexity, requiresPaid, source, channel,
|
|
1090
1157
|
labels, milestone, cycle, estimate, assignee, reviewer, progress, triaged,
|
|
1091
1158
|
mandate,
|
|
1092
1159
|
} = input ?? {}
|
|
1093
1160
|
const by = requireString(byId, 'byId', 'create')
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1161
|
+
// 来源判定(2026-11-12 t-40 需求①):
|
|
1162
|
+
// 显式传了 source 就按白名单采信;否则**按 byId 推断**('ui'/'user'/'person' → 人)。
|
|
1163
|
+
// 新增 `channel` 字段承载「具体渠道」,source 仍是二分的「人/智能体」——
|
|
1164
|
+
// 因为「谁下的令」与「从哪个入口下的令」是两个正交问题,混成一个字段必然打补丁。
|
|
1165
|
+
const src = normalizeSource(source, by, 'create')
|
|
1166
|
+
const chan = normalizeChannel(channel, src, by, 'create')
|
|
1098
1167
|
const subj = requireString(subject, 'subject', 'create')
|
|
1099
1168
|
const desc = normalizeDescription(description, 'create')
|
|
1100
1169
|
const deps = stringList(blockedBy, 'blockedBy', 'create')
|
|
@@ -1116,6 +1185,7 @@ export function createBoard(ctx, config = {}) {
|
|
|
1116
1185
|
const reviewerNorm = normalizeOptionalString(reviewer, 'reviewer', 'create')
|
|
1117
1186
|
const progressNorm = normalizeProgress(progress, 'create')
|
|
1118
1187
|
const triagedNorm = normalizeTriaged(triaged, 'create')
|
|
1188
|
+
// 渠道归一(需求①:为「其他渠道」预留位置)
|
|
1119
1189
|
// Mandate 授权留痕(可选;不给就是「未记录依据」,不伪造一个)
|
|
1120
1190
|
const mandateNorm = normalizeMandate(mandate, 'create')
|
|
1121
1191
|
|
|
@@ -1152,7 +1222,7 @@ export function createBoard(ctx, config = {}) {
|
|
|
1152
1222
|
/** @type {Record<string, unknown>} */
|
|
1153
1223
|
const task = {
|
|
1154
1224
|
id: taskId,
|
|
1155
|
-
rev: 1, source: src,
|
|
1225
|
+
rev: 1, source: src, channel: chan,
|
|
1156
1226
|
subject: subj,
|
|
1157
1227
|
description: desc,
|
|
1158
1228
|
status: 'pending',
|
|
@@ -1814,10 +1884,25 @@ export function createBoard(ctx, config = {}) {
|
|
|
1814
1884
|
next.rev = current.rev + 1
|
|
1815
1885
|
next.status = 'pending'
|
|
1816
1886
|
delete next.lease
|
|
1817
|
-
|
|
1887
|
+
// ⚠️ **不删 ownerId**(2026-11-12 t-40-A 修复,Boss 原话「负责人不用切换,避免内容丢失」)。
|
|
1888
|
+
// 旧行为是 `delete next.ownerId` —— 租约一过期就把「谁负责这个任务」这条事实抹掉,
|
|
1889
|
+
// 任务打回 pending 后任何 Bot 都能领走,长任务的上下文归属就此断裂。
|
|
1890
|
+
// 租约(lease)本来就该失效,但它失效**不代表负责人不再负责**:
|
|
1891
|
+
// - `lease` 是「谁现在正在动手」,过期即失效,必须清(否则任务永远不可被重新领取);
|
|
1892
|
+
// - `assignee` 是「这个活归谁管」,**与租约生命周期无关**,不该被回收动作改写。
|
|
1893
|
+
// 保留 ownerId 后,被回收的任务在板上显示「待领(原负责人 X)」,重派也仍可追溯。
|
|
1894
|
+
const keeper = current.ownerId ?? current.assignee
|
|
1895
|
+
if (typeof keeper === 'string' && keeper !== '') next.ownerId = keeper
|
|
1896
|
+
else delete next.ownerId
|
|
1897
|
+
// 回收原因留档:让人一眼看出「这条不是被释放的,是过期被回收的」
|
|
1898
|
+
next.lastLeaseExpiredAt = at
|
|
1899
|
+
next.lastLeaseExpiredOwner = current.lease.ownerId ?? null
|
|
1818
1900
|
commit(current.id, 'lease_expired', current.lease.ownerId ?? null, at, before, next)
|
|
1819
1901
|
recycled.push(current.id)
|
|
1820
|
-
info(
|
|
1902
|
+
info(
|
|
1903
|
+
`[cs-bots/board] 租约回收:${current.id} 由 ${current.lease.ownerId} 于 ${new Date(exp).toISOString()} 过期`
|
|
1904
|
+
+ ` → pending(负责人保留为 ${next.ownerId ?? '无'})`,
|
|
1905
|
+
)
|
|
1821
1906
|
}
|
|
1822
1907
|
return recycled
|
|
1823
1908
|
}
|