cs-bots 0.9.1 → 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 +8 -0
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +1 -1
- 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 +1 -1
- package/src/api.js +107 -18
- package/src/client.js +86 -8
- package/src/suggest.js +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -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
|
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -1829,9 +1829,17 @@ async function ensureRoomForTeam(services, team, by) {
|
|
|
1829
1829
|
const room = rooms.create({
|
|
1830
1830
|
name: `${team.name} · 议事厅`,
|
|
1831
1831
|
members,
|
|
1832
|
-
//
|
|
1833
|
-
|
|
1834
|
-
|
|
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',
|
|
1835
1843
|
purpose: `长任务「${team.name}」的议事厅:负责人主持,进展与结论回传到此`,
|
|
1836
1844
|
byId: by ?? 'ui',
|
|
1837
1845
|
})
|
|
@@ -4850,6 +4858,23 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
4850
4858
|
sendJson(response, 200, { ok: true, suggestion: applied })
|
|
4851
4859
|
return
|
|
4852
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
|
+
}
|
|
4853
4878
|
if (request.method === 'POST' && path === '/suggestions/reject') {
|
|
4854
4879
|
const body = (await readBody(request)) ?? {}
|
|
4855
4880
|
const item = await withService(services, 'suggest', (s) =>
|
|
@@ -5389,30 +5414,94 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
5389
5414
|
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
5390
5415
|
return
|
|
5391
5416
|
}
|
|
5392
|
-
//
|
|
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'])
|
|
5393
5428
|
const candidates = [task.assignee, task.ownerId, task.anchor?.ownerId]
|
|
5394
|
-
.filter((v) => typeof v === 'string' && v && !
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
ok: false,
|
|
5398
|
-
reason: '这个任务还没有执行者(未派活),没有对话可看',
|
|
5399
|
-
code: 'NO_EXECUTOR',
|
|
5400
|
-
})
|
|
5401
|
-
return
|
|
5402
|
-
}
|
|
5403
|
-
// 把候选 sessionId 映射到 Bot 记录(拿真名)
|
|
5429
|
+
.filter((v) => typeof v === 'string' && v && !HUMAN_IDS_.has(v))
|
|
5430
|
+
|
|
5431
|
+
// 执行者身份(拿真名用)
|
|
5404
5432
|
let bot
|
|
5405
5433
|
try {
|
|
5406
5434
|
const all = services.roster?.list?.({ includeRetired: true }) ?? []
|
|
5407
5435
|
bot = all.find((b) => candidates.includes(b.sessionId) || candidates.includes(b.id))
|
|
5408
5436
|
} catch { bot = undefined }
|
|
5409
5437
|
const sessionId = bot?.sessionId ?? candidates[0]
|
|
5410
|
-
|
|
5411
|
-
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
|
+
|
|
5412
5492
|
sendJson(response, 200, {
|
|
5493
|
+
ok: true,
|
|
5413
5494
|
...(bot ? { bot: { id: bot.id, name: bot.name, label: bot.label ?? null } } : {}),
|
|
5414
|
-
sessionId,
|
|
5415
|
-
|
|
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
|
+
},
|
|
5416
5505
|
})
|
|
5417
5506
|
return
|
|
5418
5507
|
}
|
package/src/client.js
CHANGED
|
@@ -2464,6 +2464,8 @@ window.__ModuleLoader__.load({
|
|
|
2464
2464
|
// 后端在「没有执行者」时返回 ok:false + code NO_EXECUTOR,call() 会抛错 ——
|
|
2465
2465
|
// 那不是故障,是正常空态,要按空态呈现。
|
|
2466
2466
|
const noExecutor = /NO_EXECUTOR/.test(convQ.error ?? '') || /没有执行者/.test(convQ.error ?? '')
|
|
2467
|
+
// t-40:有执行者但一条记录都没有(既没对话也没补充说明)——也是一种正常空态
|
|
2468
|
+
const noConversation = /NO_CONVERSATION/.test(convQ.error ?? '') || /还没有对话记录/.test(convQ.error ?? '')
|
|
2467
2469
|
|
|
2468
2470
|
/** 跳到执行者的会话(宿主支持就直接开,不支持则复制 ID 兜底)。 */
|
|
2469
2471
|
const gotoSession = async () => {
|
|
@@ -2482,8 +2484,13 @@ window.__ModuleLoader__.load({
|
|
|
2482
2484
|
}
|
|
2483
2485
|
|
|
2484
2486
|
const roleLabel = (m) => {
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
+
// t-40:消息来自两条来源 —— executor(执行者会话)/ note(用户在任务里的补充说明)。
|
|
2488
|
+
// 用户说的话必须一眼可辨,所以 note 来源的 user 消息额外标「任务里」。
|
|
2489
|
+
if (m.role === 'user') {
|
|
2490
|
+
if (m.source === 'note') return `${HUMAN_SELF_LABEL}(任务里)`
|
|
2491
|
+
return HUMAN_SELF_LABEL
|
|
2492
|
+
}
|
|
2493
|
+
if (m.role === 'assistant') return m.speaker && m.speaker !== bot?.name ? m.speaker : (bot?.name ?? '执行者')
|
|
2487
2494
|
if (m.role === 'tool') return m.toolName ? `工具 · ${m.toolName}` : '工具'
|
|
2488
2495
|
return m.role ?? '?'
|
|
2489
2496
|
}
|
|
@@ -2496,9 +2503,10 @@ window.__ModuleLoader__.load({
|
|
|
2496
2503
|
h(Section, {
|
|
2497
2504
|
title: '任务对话',
|
|
2498
2505
|
desc: bot
|
|
2499
|
-
? `执行者「${bot.name}
|
|
2506
|
+
? `执行者「${bot.name}」的会话 + 你在任务里说的话,按时间合并(共 ${messages.length} 条`
|
|
2507
|
+
+ `${data?.counts ? `:执行者 ${data.counts.executor} 条 / 任务说明 ${data.counts.note} 条` : ''})。`
|
|
2500
2508
|
+ '这里是只读的:要看全部或继续聊,点「去对话」。'
|
|
2501
|
-
: '任务派给 Bot
|
|
2509
|
+
: '任务派给 Bot 之后,它的执行过程和你下的指令都会出现在这里。',
|
|
2502
2510
|
}, null),
|
|
2503
2511
|
|
|
2504
2512
|
convQ.loading ? h(Skeleton, { n: 2 }) : null,
|
|
@@ -2509,7 +2517,13 @@ window.__ModuleLoader__.load({
|
|
|
2509
2517
|
title: '还没有执行者',
|
|
2510
2518
|
hint: '任务派给 Bot 之后才会有对话记录。先在「可以做什么」里派活,或者用 Bot 页的「派发」把任务交给它。',
|
|
2511
2519
|
})
|
|
2512
|
-
:
|
|
2520
|
+
: noConversation
|
|
2521
|
+
? h(Empty, {
|
|
2522
|
+
icon: '◌',
|
|
2523
|
+
title: '还没有对话记录',
|
|
2524
|
+
hint: '这个任务既没派活、也没有补充说明。派活之后执行过程会出现在这里;你也可以在下面写一条补充说明,它同样会显示在这里。',
|
|
2525
|
+
})
|
|
2526
|
+
: convQ.error
|
|
2513
2527
|
? h('div', { className: 'csb-err' }, `读不到对话:${convQ.error}`)
|
|
2514
2528
|
: null,
|
|
2515
2529
|
|
|
@@ -6411,6 +6425,38 @@ window.__ModuleLoader__.load({
|
|
|
6411
6425
|
}
|
|
6412
6426
|
}
|
|
6413
6427
|
|
|
6428
|
+
// ── 建议编辑(t-40)──
|
|
6429
|
+
// Boss:「用户应该可以修改完善并确定等级范围」。
|
|
6430
|
+
// 只给「采纳/驳回」时,措辞不合意就只能驳回重等 —— 越推越远。
|
|
6431
|
+
const [sgEdit, setSgEdit] = useState(null)
|
|
6432
|
+
const openSgEdit = (sg) => setSgEdit({
|
|
6433
|
+
id: sg.id,
|
|
6434
|
+
rev: sg.rev,
|
|
6435
|
+
kind: sg.kind,
|
|
6436
|
+
layer: sg.layer ?? sg.payload?.layer ?? '',
|
|
6437
|
+
content: sg.payload?.content ?? sg.payload?.name ?? sg.payload?.title ?? '',
|
|
6438
|
+
})
|
|
6439
|
+
const saveSgEdit = async () => {
|
|
6440
|
+
if (!sgEdit) return
|
|
6441
|
+
setErr(null)
|
|
6442
|
+
try {
|
|
6443
|
+
await call('/suggestions/edit', {
|
|
6444
|
+
method: 'POST',
|
|
6445
|
+
body: {
|
|
6446
|
+
id: sgEdit.id,
|
|
6447
|
+
expectedRev: sgEdit.rev,
|
|
6448
|
+
content: sgEdit.content,
|
|
6449
|
+
...(sgEdit.kind === 'memory' && sgEdit.layer ? { layer: sgEdit.layer } : {}),
|
|
6450
|
+
},
|
|
6451
|
+
})
|
|
6452
|
+
setOk('已保存修改,它仍是待确认状态')
|
|
6453
|
+
setSgEdit(null)
|
|
6454
|
+
reloadSug()
|
|
6455
|
+
} catch (e) {
|
|
6456
|
+
setErr(e.message)
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
|
|
6414
6460
|
const decideSuggestion = async (sg, accept) => {
|
|
6415
6461
|
setErr(null)
|
|
6416
6462
|
try {
|
|
@@ -6643,10 +6689,11 @@ window.__ModuleLoader__.load({
|
|
|
6643
6689
|
footer: [h('button', { key: 'c', className: 'csb-btn', onClick: () => setShowSug(false) }, '关闭')],
|
|
6644
6690
|
},
|
|
6645
6691
|
h('p', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } },
|
|
6646
|
-
'
|
|
6692
|
+
'这些是模型整理的「它认为该记住/该改」的条目。不点采纳不会生效;措辞或等级不合意,点「编辑」改完再采纳。'),
|
|
6647
6693
|
suggestions.length === 0
|
|
6648
6694
|
? h(Empty, { icon: '✓', title: '没有待确认的建议', hint: '有建议时这里会出现。' })
|
|
6649
|
-
: h('div',
|
|
6695
|
+
: h('div', null,
|
|
6696
|
+
h('div', { className: 'csb-list' }, suggestions.map((sg) => h('div', { key: sg.id, className: 'csb-card' },
|
|
6650
6697
|
h('div', { className: 'csb-card-main' },
|
|
6651
6698
|
h('div', { className: 'csb-card-t2' }, sg.payload?.content ?? sg.payload?.name ?? sg.payload?.title ?? '(无内容)'),
|
|
6652
6699
|
h('div', { className: 'csb-card-m' },
|
|
@@ -6655,8 +6702,39 @@ window.__ModuleLoader__.load({
|
|
|
6655
6702
|
sg.reason ? h('span', null, sg.reason) : null,
|
|
6656
6703
|
sg.confidence ? h('span', null, `把握 ${Math.round(sg.confidence * 100)}%`) : null)),
|
|
6657
6704
|
h('div', { style: { display: 'flex', gap: 6, flex: 'none' } },
|
|
6705
|
+
h('button', { className: 'csb-btn csb-btn-s', onClick: () => (sgEdit?.id === sg.id ? setSgEdit(null) : openSgEdit(sg)) }, sgEdit?.id === sg.id ? '收起' : '编辑'),
|
|
6658
6706
|
h('button', { className: 'csb-btn csb-btn-s csb-btn-p', onClick: () => decideSuggestion(sg, true) }, '采纳'),
|
|
6659
|
-
h('button', { className: 'csb-btn csb-btn-s', onClick: () => decideSuggestion(sg, false) }, '驳回'))))))
|
|
6707
|
+
h('button', { className: 'csb-btn csb-btn-s', onClick: () => decideSuggestion(sg, false) }, '驳回')))))),
|
|
6708
|
+
// 编辑面板:与列表平级,不嵌进卡片 —— 改动更小、也不会打乱既有括号结构
|
|
6709
|
+
sgEdit
|
|
6710
|
+
? h('div', { className: 'csb-card', style: { marginTop: 10, padding: 12 } },
|
|
6711
|
+
h('div', { style: { fontWeight: 600, marginBottom: 6 } }, `编辑建议 ${sgEdit.id}`),
|
|
6712
|
+
h('textarea', {
|
|
6713
|
+
className: 'csb-input',
|
|
6714
|
+
rows: 4,
|
|
6715
|
+
value: sgEdit.content,
|
|
6716
|
+
onInput: (e) => setSgEdit({ ...sgEdit, content: e.target.value }),
|
|
6717
|
+
style: { width: '100%', fontFamily: 'inherit', marginBottom: 8 },
|
|
6718
|
+
}),
|
|
6719
|
+
sgEdit.kind === 'memory'
|
|
6720
|
+
? h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
|
|
6721
|
+
h('span', { style: { fontSize: 12 } }, '等级'),
|
|
6722
|
+
h('select', {
|
|
6723
|
+
className: 'csb-input',
|
|
6724
|
+
value: sgEdit.layer,
|
|
6725
|
+
onChange: (e) => setSgEdit({ ...sgEdit, layer: e.target.value }),
|
|
6726
|
+
},
|
|
6727
|
+
h('option', { value: '' }, '(不指定)'),
|
|
6728
|
+
h('option', { value: 'L1' }, 'L1 用户偏好'),
|
|
6729
|
+
h('option', { value: 'L2' }, 'L2 团队全局'),
|
|
6730
|
+
h('option', { value: 'L3' }, 'L3 项目 key'),
|
|
6731
|
+
h('option', { value: 'L4' }, 'L4 任务笔记'),
|
|
6732
|
+
h('option', { value: 'L5' }, 'L5 日札')))
|
|
6733
|
+
: h('div', { style: { fontSize: 12, opacity: 0.7, marginBottom: 8 } }, '(该类型没有等级可选)'),
|
|
6734
|
+
h('div', { style: { display: 'flex', gap: 6 } },
|
|
6735
|
+
h('button', { className: 'csb-btn csb-btn-s csb-btn-p', onClick: saveSgEdit }, '保存修改'),
|
|
6736
|
+
h('button', { className: 'csb-btn csb-btn-s', onClick: () => setSgEdit(null) }, '取消')))
|
|
6737
|
+
: null)
|
|
6660
6738
|
: null,
|
|
6661
6739
|
|
|
6662
6740
|
// ── 注入效果 ──
|
package/src/suggest.js
CHANGED
|
@@ -1040,6 +1040,115 @@ export function createSuggest(ctx, config = {}) {
|
|
|
1040
1040
|
return decided
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* 修改一条**待决**建议:内容 / 等级(layer)/ 标题标签都可以改(2026-11-12 t-40)。
|
|
1045
|
+
*
|
|
1046
|
+
* Boss 原话:「自动整理建议中,用户只能接纳和驳回,等级没办法选择、内容没办法修改,
|
|
1047
|
+
* 用户应该可以修改完善并确定等级范围」
|
|
1048
|
+
*
|
|
1049
|
+
* 为什么必须有这个口子:模型归纳出来的文字几乎总是「意思是对的、措辞不是我的」。
|
|
1050
|
+
* 只给「采纳/驳回」两个按钮,用户遇到措辞不合意的建议只能驳回再等它重新提一遍 ——
|
|
1051
|
+
* 而驳回会让同类建议进冷却,等于把「差一点就对」的东西越推越远。
|
|
1052
|
+
*
|
|
1053
|
+
* 语义边界:
|
|
1054
|
+
* - **只能改 pending**:已采纳/已驳回是终态,改它等于篡改历史(要提新的就 propose)。
|
|
1055
|
+
* - **改完仍是 pending**:编辑不等于采纳,用户改完可以再想想。
|
|
1056
|
+
* - **等级仍走同一套白名单**:`layer` 只能是 MEMORY_LAYERS(L0 仍不可写)。
|
|
1057
|
+
* - **改类型(kind)时 payload 必须一起给**:记忆与技能的 payload 形状不同。
|
|
1058
|
+
* - **必须 CAS**:`expectedRev` 必填,防两人同时改互相覆盖。
|
|
1059
|
+
* - **审计留痕**:写 `edit` 事件,before/after 都在事件流里可查。
|
|
1060
|
+
*
|
|
1061
|
+
* @param {{ id: string, expectedRev: number, payload?: object, kind?: string,
|
|
1062
|
+
* layer?: string, title?: string, content?: string, by: string, now?: number }} input
|
|
1063
|
+
* @returns {object} 修改后的建议(rev 已 +1)
|
|
1064
|
+
*/
|
|
1065
|
+
function edit(input = {}) {
|
|
1066
|
+
assertAlive('edit')
|
|
1067
|
+
const { id, expectedRev, kind, title, content, by, now } = input ?? {}
|
|
1068
|
+
let { payload } = input ?? {}
|
|
1069
|
+
const suggestionId = requireString(id, 'id', 'edit')
|
|
1070
|
+
const author = requireString(by, 'by', 'edit')
|
|
1071
|
+
const at = normalizeNow(now, 'edit')
|
|
1072
|
+
const current = requireSuggestion(suggestionId, 'edit')
|
|
1073
|
+
|
|
1074
|
+
if (current.status !== 'pending') {
|
|
1075
|
+
throw new SuggestError(
|
|
1076
|
+
`suggest.edit:建议 [id:${suggestionId}] 已是 ${current.status}(终态不可改)—— `
|
|
1077
|
+
+ '要改请重新提一条(propose)',
|
|
1078
|
+
'SUGGEST_NOT_PENDING',
|
|
1079
|
+
{ id: suggestionId, status: current.status },
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
const rev = Number(expectedRev)
|
|
1083
|
+
if (!Number.isFinite(rev)) {
|
|
1084
|
+
throw new SuggestError(
|
|
1085
|
+
'suggest.edit:expectedRev 必填(修改是覆盖性操作,必须带版本做 CAS)',
|
|
1086
|
+
'SUGGEST_BAD_FIELD',
|
|
1087
|
+
{ id: suggestionId, field: 'expectedRev' },
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
if (rev !== current.rev) {
|
|
1091
|
+
throw new SuggestError(
|
|
1092
|
+
`suggest.edit:CAS 冲突(期望 rev=${rev},实际 rev=${current.rev})—— 有人先改过了,请刷新后重试`,
|
|
1093
|
+
'SUGGEST_REV_CONFLICT',
|
|
1094
|
+
{ id: suggestionId, expectedRev: rev, actualRev: current.rev },
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const before = clone(current)
|
|
1099
|
+
const next = clone(current)
|
|
1100
|
+
next.rev = current.rev + 1
|
|
1101
|
+
|
|
1102
|
+
const nextKind = kind === undefined || kind === null ? current.kind : requireString(kind, 'kind', 'edit')
|
|
1103
|
+
if (!KINDS.includes(nextKind)) {
|
|
1104
|
+
throw new SuggestError(
|
|
1105
|
+
`suggest.edit:kind 只能是 ${KINDS.join(' | ')},收到 ${JSON.stringify(nextKind)}`,
|
|
1106
|
+
'SUGGEST_BAD_KIND',
|
|
1107
|
+
{ id: suggestionId, kind: nextKind },
|
|
1108
|
+
)
|
|
1109
|
+
}
|
|
1110
|
+
if (nextKind !== current.kind && payload === undefined) {
|
|
1111
|
+
throw new SuggestError(
|
|
1112
|
+
`suggest.edit:改 kind(${current.kind} → ${nextKind})必须同时给 payload —— `
|
|
1113
|
+
+ '记忆与技能的 payload 形状不同,只换类型会留下形状不匹配的脏数据',
|
|
1114
|
+
'SUGGEST_KIND_REQUIRES_PAYLOAD',
|
|
1115
|
+
{ id: suggestionId, from: current.kind, to: nextKind },
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (payload === undefined && (title !== undefined || content !== undefined)) {
|
|
1120
|
+
payload = { ...(current.payload ?? {}) }
|
|
1121
|
+
if (title !== undefined) payload.title = title
|
|
1122
|
+
if (content !== undefined) payload.content = content
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (payload !== undefined) {
|
|
1126
|
+
const targetLayerForPayload = payload.layer ?? current.payload?.layer ?? null
|
|
1127
|
+
next.payload = normalizePayload(nextKind, payload, 'edit', targetLayerForPayload)
|
|
1128
|
+
}
|
|
1129
|
+
next.kind = nextKind
|
|
1130
|
+
|
|
1131
|
+
if (input.layer !== undefined && input.layer !== null && input.layer !== '') {
|
|
1132
|
+
if (!MEMORY_LAYERS.includes(input.layer)) {
|
|
1133
|
+
throw new SuggestError(
|
|
1134
|
+
`suggest.edit:layer 只能是 ${MEMORY_LAYERS.join(' | ')}(L0 人格仅人可写,不在此列),`
|
|
1135
|
+
+ `收到 ${JSON.stringify(input.layer)}`,
|
|
1136
|
+
'SUGGEST_BAD_LAYER',
|
|
1137
|
+
{ id: suggestionId, layer: input.layer },
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
next.payload = { ...(next.payload ?? {}), layer: input.layer }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
next.updatedAt = at
|
|
1144
|
+
next.updatedBy = author
|
|
1145
|
+
next.editedAt = at
|
|
1146
|
+
next.editedBy = author
|
|
1147
|
+
next.editCount = (Number(current.editCount) || 0) + 1
|
|
1148
|
+
|
|
1149
|
+
return commit('edit', at, author, before, next)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1043
1152
|
/**
|
|
1044
1153
|
* 批量采纳:**部分失败不中断**(人点「全部采纳」时,一条撞错不该让其余全不处理),
|
|
1045
1154
|
* 但失败必须逐条回报(`failed[]` 带 code + reason),绝不吞掉。
|
|
@@ -1187,6 +1296,7 @@ export function createSuggest(ctx, config = {}) {
|
|
|
1187
1296
|
getSuggestion,
|
|
1188
1297
|
accept,
|
|
1189
1298
|
reject,
|
|
1299
|
+
edit,
|
|
1190
1300
|
bulkAccept,
|
|
1191
1301
|
stats,
|
|
1192
1302
|
replay,
|