dsh-collab-workbench 0.0.0-stage → 0.1.2
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.
Potentially problematic release.
This version of dsh-collab-workbench might be problematic. Click here for more details.
- package/LICENSE +202 -0
- package/README.md +112 -2
- package/client.js +2136 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +1 -0
- package/index.js +86 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +60 -3
- package/scripts/build-approvals.mjs +15 -0
- package/scripts/check.mjs +17 -0
- package/src/client/approval-ui.js +117 -0
- package/src/client/update-ui.js +73 -0
- package/src/host/agents.js +235 -0
- package/src/host/api.js +264 -0
- package/src/host/approvals-fold.js +34 -0
- package/src/host/approvals.js +36 -0
- package/src/host/channels.js +509 -0
- package/src/host/delivery.js +95 -0
- package/src/host/execution-engine.js +417 -0
- package/src/host/execution-tools.js +65 -0
- package/src/host/execution.js +274 -0
- package/src/host/files.js +89 -0
- package/src/host/groups.js +501 -0
- package/src/host/ledger.js +177 -0
- package/src/host/memory.js +175 -0
- package/src/host/policy.js +43 -0
- package/src/host/tasks.js +117 -0
- package/src/host/tools.js +491 -0
- package/src/host/update.js +135 -0
- package/src/host/verify.js +44 -0
- package/src/host/work-item-sync.js +101 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-collab-workbench 协作工作台插件
|
|
2
|
+
# 发布名 dsh-collab-workbench(原名 dsh-workbench 在 npm 已被他人占用)。
|
|
3
|
+
# 运行时标识、RPC 端点与数据目录沿用 dsh-workbench,历史数据不受影响。
|
|
4
|
+
# 完全自研,不依赖 ds-bot。挂载 host 半区;浏览器半区经 dsh.client manifest + ./client 导出发现。
|
|
5
|
+
- insert:
|
|
6
|
+
- id: workbench
|
|
7
|
+
name: dsh-collab-workbench
|
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect x="5" y="10" width="54" height="44" rx="12" fill="#2563eb"/><path d="M21 19v-2a5 5 0 0 1 5-5h12a5 5 0 0 1 5 5v2M7 29h50" fill="none" stroke="#dbeafe" stroke-width="4"/><path d="m19 40 7 7 17-17" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// dsh-workbench: 协作工作台(自研实现,不依赖 ds-bot)。
|
|
2
|
+
// 本插件以角色(Agent Session)与群组为中心,提供角色管理、群组管理、
|
|
3
|
+
// 任务推进、群聊界面,并完全杜绝一对一私聊。
|
|
4
|
+
//
|
|
5
|
+
// 设计说明(参考 ds-bot 的机制,但代码独立编写):
|
|
6
|
+
// - 每个角色 = 一个由 sessionController.create 创建的根 Session;
|
|
7
|
+
// - 群组消息流 = 自研 room 结构 + createUserMessage + agent.followup/steer;
|
|
8
|
+
// - 台账 = JSON 文件持久化于 $DSH_HOME/dsh-workbench/。
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import * as ledger from './src/host/ledger.js'
|
|
12
|
+
import * as agents from './src/host/agents.js'
|
|
13
|
+
import * as groups from './src/host/groups.js'
|
|
14
|
+
import * as channels from './src/host/channels.js'
|
|
15
|
+
import * as tasks from './src/host/tasks.js'
|
|
16
|
+
import * as files from './src/host/files.js'
|
|
17
|
+
import * as policy from './src/host/policy.js'
|
|
18
|
+
import * as memory from './src/host/memory.js'
|
|
19
|
+
import * as api from './src/host/api.js'
|
|
20
|
+
import * as update from './src/host/update.js'
|
|
21
|
+
import * as tools from './src/host/tools.js'
|
|
22
|
+
import * as executionTools from './src/host/execution-tools.js'
|
|
23
|
+
import * as execution from './src/host/execution.js'
|
|
24
|
+
import * as approvals from './src/host/approvals.js'
|
|
25
|
+
import * as delivery from './src/host/delivery.js'
|
|
26
|
+
import * as workItemSync from './src/host/work-item-sync.js'
|
|
27
|
+
import * as verify from './src/host/verify.js'
|
|
28
|
+
|
|
29
|
+
export const name = 'dsh-workbench'
|
|
30
|
+
|
|
31
|
+
// P1 最小依赖:会话创建/解析、会话标题、工具注册。
|
|
32
|
+
// 后续 P3/P4 会补充 storage 等。
|
|
33
|
+
export const inject = [
|
|
34
|
+
'sessionController',
|
|
35
|
+
'sessionProjections',
|
|
36
|
+
'sessions',
|
|
37
|
+
'sessionTitle',
|
|
38
|
+
'workspaceRegistry',
|
|
39
|
+
'tools',
|
|
40
|
+
'llm',
|
|
41
|
+
'connection',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
export function apply(ctx, config = {}) {
|
|
45
|
+
const home = config.home ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'dsh-workbench')
|
|
46
|
+
const rt = {
|
|
47
|
+
ctx,
|
|
48
|
+
config,
|
|
49
|
+
home,
|
|
50
|
+
statePath: join(home, 'state.json'),
|
|
51
|
+
rolesPath: join(home, 'roles.json'),
|
|
52
|
+
groupsPath: join(home, 'groups.json'),
|
|
53
|
+
tasksPath: join(home, 'tasks.json'),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// P1:台账模块(roles/groups/tasks 三个 JSON 文件的读写)。
|
|
57
|
+
ledger.install(rt)
|
|
58
|
+
// P1:角色 Session 创建(最小版:create + resolveAgent + 命名)。
|
|
59
|
+
agents.install(rt)
|
|
60
|
+
// P8:常驻铁律提示(角色职责中注入工作台规范)。
|
|
61
|
+
policy.install(rt)
|
|
62
|
+
// P3:群组管理(命名校验 + 建群 + 同名目录脚手架 + 改名同步)。
|
|
63
|
+
groups.install(rt)
|
|
64
|
+
// P4:群消息流与 @ 投递(唯一沟通通道,杜绝单发消息)。
|
|
65
|
+
channels.install(rt)
|
|
66
|
+
// P5:任务管理(多工程师指派 + 优先级 + 自动 @ 投递)。
|
|
67
|
+
tasks.install(rt)
|
|
68
|
+
// P9:群附件(+ 号上传 → 群目录「附件/」→ 消息文件卡片)。
|
|
69
|
+
files.install(rt)
|
|
70
|
+
// P1:记忆(个人+团队,工具+RPC)。
|
|
71
|
+
memory.install(rt)
|
|
72
|
+
execution.install(rt)
|
|
73
|
+
delivery.install(rt)
|
|
74
|
+
workItemSync.install(rt)
|
|
75
|
+
approvals.install(rt)
|
|
76
|
+
update.install(rt)
|
|
77
|
+
// P7:RPC API(/api/dsh-workbench,供 client UI 调用)。
|
|
78
|
+
api.install(rt)
|
|
79
|
+
// P3:工作台工具注册(角色/群组 CRUD 等)。
|
|
80
|
+
tools.install(rt)
|
|
81
|
+
executionTools.install(rt)
|
|
82
|
+
// P1:验证工具(dwb_verify / dwb_create_role),确认插件可加载、台账可读写、角色可创建。
|
|
83
|
+
verify.install(rt)
|
|
84
|
+
|
|
85
|
+
rt.startExecution()
|
|
86
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"title":"Collaboration Workbench","description":"Role and project collaboration with automatic task execution, native permission approvals in group chat and restart recovery."}}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"title":"协作工作台","description":"角色与项目群协作,任务自动执行、群内原生权限审批与中断恢复。"}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-collab-workbench",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "DeepSeek Harness 多 Agent 协作工作台:自定义角色、项目群聊、任务自动调度、群内权限审批与执行恢复",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"deepseek",
|
|
10
|
+
"workbench",
|
|
11
|
+
"collaboration"
|
|
12
|
+
],
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./client": "./client.js",
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./locale/*.json": "./locale/*.json",
|
|
21
|
+
"./icon": "./icon.svg"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"src/host/",
|
|
26
|
+
"src/client/approval-ui.js",
|
|
27
|
+
"src/client/update-ui.js",
|
|
28
|
+
"client.js",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"README.md",
|
|
31
|
+
"locale/*.json",
|
|
32
|
+
"icon.svg",
|
|
33
|
+
"scripts/*.mjs"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node scripts/build-approvals.mjs && node scripts/check.mjs",
|
|
37
|
+
"check": "node scripts/check.mjs",
|
|
38
|
+
"test": "node --import ./test/register.mjs --test \"test/*.test.js\"",
|
|
39
|
+
"test:native": "python3 test/native-e2e.py"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"zod": "^4.4.3",
|
|
43
|
+
"@deepseek-ai/cordis": "*",
|
|
44
|
+
"@deepseek-ai/dsh-api-session-controller": "*",
|
|
45
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
46
|
+
"@deepseek-ai/dsh-session": "*",
|
|
47
|
+
"@deepseek-ai/dsh-session-title": "*",
|
|
48
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
49
|
+
"@deepseek-ai/dsh-workspace": "*"
|
|
50
|
+
},
|
|
51
|
+
"dsh": {
|
|
52
|
+
"client": {
|
|
53
|
+
"platform": "web",
|
|
54
|
+
"inject": ["@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-approval", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-workspace"]
|
|
55
|
+
},
|
|
56
|
+
"bundle": {
|
|
57
|
+
"patch": "./cordis.patch.yml"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"esbuild": "^0.28.2"
|
|
62
|
+
}
|
|
6
63
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {readFile,writeFile} from 'node:fs/promises'
|
|
2
|
+
const target=new URL('../client.js',import.meta.url)
|
|
3
|
+
let client=await readFile(target,'utf8')
|
|
4
|
+
const source=(await readFile(new URL('../src/client/approval-ui.js',import.meta.url),'utf8')).replace(/^export /gm,'')
|
|
5
|
+
const start='// BEGIN WORKBENCH APPROVAL UI',end='// END WORKBENCH APPROVAL UI'
|
|
6
|
+
const block=`${start}\n${source}\n${end}\n`
|
|
7
|
+
if(client.includes(start))client=client.slice(0,client.indexOf(start))+block+client.slice(client.indexOf(end)+end.length+1)
|
|
8
|
+
else client=client.replace('// src/client/main.jsx',block+'\n// src/client/main.jsx')
|
|
9
|
+
const updateSource=(await readFile(new URL('../src/client/update-ui.js',import.meta.url),'utf8')).replace(/^export /gm,'')
|
|
10
|
+
const updateStart='// BEGIN WORKBENCH UPDATE UI',updateEnd='// END WORKBENCH UPDATE UI'
|
|
11
|
+
const updateBlock=`${updateStart}\n${updateSource}\n${updateEnd}\n`
|
|
12
|
+
if(client.includes(updateStart))client=client.slice(0,client.indexOf(updateStart))+updateBlock+client.slice(client.indexOf(updateEnd)+updateEnd.length+1)
|
|
13
|
+
else client=client.replace('// src/client/main.jsx',updateBlock+'\n// src/client/main.jsx')
|
|
14
|
+
await writeFile(target,client)
|
|
15
|
+
console.log('Updated embedded approval and update UI (preserved existing workbench Client)')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {readFile,readdir,stat} from 'node:fs/promises'
|
|
2
|
+
import {spawnSync} from 'node:child_process'
|
|
3
|
+
import {fileURLToPath} from 'node:url'
|
|
4
|
+
import {resolve} from 'node:path'
|
|
5
|
+
const root=fileURLToPath(new URL('../',import.meta.url))
|
|
6
|
+
const pkg=JSON.parse(await readFile(resolve(root,'package.json'),'utf8'))
|
|
7
|
+
for(const file of ['index.js','client.js','src/client/update-ui.js',...(await readdir(resolve(root,'src/host'))).filter(f=>f.endsWith('.js')).map(f=>'src/host/'+f)]){
|
|
8
|
+
const result=spawnSync(process.execPath,['--check',resolve(root,file)],{encoding:'utf8'})
|
|
9
|
+
if(result.status!==0)throw new Error(file+'\n'+result.stderr)
|
|
10
|
+
}
|
|
11
|
+
for(const locale of ['en','zh']){
|
|
12
|
+
const doc=JSON.parse(await readFile(resolve(root,'locale',locale+'.json'),'utf8'))
|
|
13
|
+
if(!doc.meta?.title||!doc.meta?.description)throw new Error('Missing locale metadata')
|
|
14
|
+
}
|
|
15
|
+
if(pkg.exports['./icon']!=='./icon.svg'||(await stat(resolve(root,'icon.svg'))).size>256*1024)throw new Error('Invalid icon export')
|
|
16
|
+
for(const file of ['src/host/execution.js','src/host/execution-engine.js','src/host/execution-tools.js'])await stat(resolve(root,file))
|
|
17
|
+
console.log(`PASS ${pkg.name}@${pkg.version}: JavaScript syntax, locales, icon exports and execution modules`)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// This source is embedded into the existing Client factory by scripts/build-approvals.mjs.
|
|
2
|
+
// Never serialize a pending interaction or expose its answer as a Host endpoint/tool.
|
|
3
|
+
export function matchApproval(item, pending) {
|
|
4
|
+
return !!item && item.outcome === 'pending' && !!item.callId &&
|
|
5
|
+
pending?.kind === 'approval' && pending.sessionId === item.sessionId &&
|
|
6
|
+
pending.callId === item.callId && pending.toolName === item.toolName &&
|
|
7
|
+
(pending.reason ?? '') === (item.reason ?? '') && pending.answerable === true &&
|
|
8
|
+
typeof pending.answer === 'function'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Outcome/decidedAt never participate in position or identity. Stable sort keeps
|
|
12
|
+
// source order for equal timestamps, including messages from the same millisecond.
|
|
13
|
+
export function approvalTimeline(lines = [], items = []) {
|
|
14
|
+
return [...lines.map(line=>({kind:'message',time:line.time,line})),
|
|
15
|
+
...items.map(item=>({kind:'approval',time:item.askedAt,item}))]
|
|
16
|
+
.sort((a,b)=>(Number(a.time)||0)-(Number(b.time)||0))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createApprovalUI(React, ctx, call) {
|
|
20
|
+
const h = React.createElement
|
|
21
|
+
const NS = 'dwbApprovals'
|
|
22
|
+
const zh = {title:'等待权限审批',allow:'允许一次',reject:'拒绝',open:'打开原会话',reason:'申请原因',command:'操作内容',permission:'申请权限',pending:'等待审批',sending:'已提交,等待宿主确认',expired:'已取消或过期',allowed:'已允许一次',rejected:'已拒绝',unavailable:'审批不可用',connecting:'正在连接原生审批…',history:'审批记录',error:'审批状态读取失败',unknown:'尚未找到可操作的原生请求,请打开原会话查看',task:'任务',scope:'仅授权本次操作,不修改长期权限',details:'权限审批详情'}
|
|
23
|
+
const en = {title:'Permission approval required',allow:'Allow once',reject:'Reject',open:'Open original session',reason:'Reason',command:'Operation',permission:'Requested access',pending:'Waiting for approval',sending:'Submitted; awaiting Host confirmation',expired:'Cancelled or expired',allowed:'Allowed once',rejected:'Rejected',unavailable:'Approval unavailable',connecting:'Connecting to native approval…',history:'Approval history',error:'Failed to read approval state',unknown:'No actionable native request; open its original session',task:'Task',scope:'Authorizes this operation only; does not change policy',details:'Permission approval details'}
|
|
24
|
+
ctx.effect(()=>ctx.locale.register(NS,{zh,en}),'workbench: approval locales')
|
|
25
|
+
const t = ctx.locale.bind(NS)
|
|
26
|
+
const locks = new WeakSet()
|
|
27
|
+
// Retain native requester scopes for the plugin lifetime, not the visible chat's
|
|
28
|
+
// lifetime: switching groups/tabs must not withdraw an unanswered approval.
|
|
29
|
+
const retained = new Map(), watched = new Map()
|
|
30
|
+
let active = true, checking = false
|
|
31
|
+
const updateRetentions = (groupId, ids) => {
|
|
32
|
+
if(!active)return
|
|
33
|
+
watched.set(groupId,new Set(ids ?? []))
|
|
34
|
+
const keep=new Set([...watched.values()].flatMap(ids=>[...ids]))
|
|
35
|
+
for(const [id,ref] of retained)if(!keep.has(id)){ref.release();retained.delete(id)}
|
|
36
|
+
for(const id of keep)if(!retained.has(id)){
|
|
37
|
+
const ref=ctx.sessions.retain(id,{source:'controllerOperation'})
|
|
38
|
+
retained.set(id,ref);ref.ready.catch(()=>{})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
ctx.effect(()=>{
|
|
42
|
+
const check=async()=>{
|
|
43
|
+
if(!active||checking||!watched.size)return
|
|
44
|
+
checking=true
|
|
45
|
+
try {
|
|
46
|
+
const listing=await call('groups.list',{})
|
|
47
|
+
if(!active)return
|
|
48
|
+
const present=new Set((listing.groups??[]).filter(g=>g.status==='active').map(g=>g.id))
|
|
49
|
+
for(const id of [...watched.keys()]){
|
|
50
|
+
if(!present.has(id)){updateRetentions(id,[]);watched.delete(id);continue}
|
|
51
|
+
try {const data=await call('approvals.list',{groupId:id});updateRetentions(id,data.sessions)}catch{/* transport owns cancellation; never auto-answer */}
|
|
52
|
+
}
|
|
53
|
+
}catch{/* keep scopes through transient transport failures */}finally{checking=false}
|
|
54
|
+
}
|
|
55
|
+
const timer=setInterval(check,2000);timer.unref?.()
|
|
56
|
+
return()=>{active=false;clearInterval(timer);for(const ref of retained.values())ref.release();retained.clear();watched.clear()}
|
|
57
|
+
},'workbench: approval requester lifetimes')
|
|
58
|
+
const css = `.dwb-approvals{padding:12px 16px;display:flex;flex-direction:column;gap:10px;min-width:0}.dwb-approval{border:1px solid var(--dsw-alias-state-warn-secondary);border-radius:12px;background:var(--dsw-specific-input-major);overflow:hidden;color:var(--dsw-alias-label-primary)}.dwb-approval-head{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary);padding:10px 16px;font-size:13px;line-height:18px;display:flex;gap:8px;flex-wrap:wrap}.dwb-approval-body{padding:12px 16px 0;font-size:13px;line-height:20px}.dwb-approval-body p{margin:0 0 8px}.dwb-approval-command{white-space:pre-wrap;overflow-wrap:anywhere;max-height:200px;overflow:auto;font-family:var(--ds-font-family-code);font-size:12px;color:var(--dsw-alias-label-secondary)}.dwb-approval-note{color:var(--dsw-alias-label-tertiary)}.dwb-approval-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;padding:12px 16px}.dwb-approval button{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:7px 12px;font-size:13px;cursor:pointer;min-height:34px}.dwb-approval button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}.dwb-approval button:focus-visible{outline:2px solid var(--dsw-alias-state-info-primary);outline-offset:2px}.dwb-approval button:disabled{opacity:.5;cursor:default}.dwb-approval .dwb-approval-allow{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-secondary);font-weight:600}.dwb-approval .dwb-approval-reject:hover:not(:disabled){color:var(--dsw-alias-state-error-primary)}.dwb-approval-error{color:var(--dsw-alias-state-error-primary);padding:8px 16px;font-size:13px}`
|
|
59
|
+
function Card({item, pending, refresh}) {
|
|
60
|
+
const [sending,setSending]=React.useState(false),[error,setError]=React.useState(null)
|
|
61
|
+
const available=matchApproval(item,pending)
|
|
62
|
+
const answer=async outcome=>{
|
|
63
|
+
if(!matchApproval(item,pending)||locks.has(pending))return
|
|
64
|
+
locks.add(pending);setSending(true);setError(null)
|
|
65
|
+
try {await pending.answer(outcome);refresh()}
|
|
66
|
+
catch(e){setError(String(e?.message??e));if(pending.answerable){locks.delete(pending);setSending(false)}}
|
|
67
|
+
}
|
|
68
|
+
const status=item.outcome==='pending'?(sending?t('sending'):available?t('pending'):item.live?t('connecting'):t('expired')):
|
|
69
|
+
t({'allowed-once':'allowed',rejected:'rejected',cancelled:'expired',expired:'expired',unavailable:'unavailable'}[item.outcome]??'expired')
|
|
70
|
+
return h('section',{className:'dwb-approval','aria-label':t('details'),'data-approval-id':item.id},
|
|
71
|
+
h('div',{className:'dwb-approval-head'},h('strong',null,item.outcome==='pending'?t('title'):t('history')),h('span',null,item.roleName),item.taskId?h('span',null,`${t('task')} ${item.taskId}`):null),
|
|
72
|
+
h('div',{className:'dwb-approval-body'},item.taskTitle?h('p',null,item.taskTitle):null,
|
|
73
|
+
h('p',null,`${item.toolName} · ${status}`),item.reason?h('p',null,`${t('reason')}:${item.reason}`):null,
|
|
74
|
+
item.permission?h('p',null,`${t('permission')}:${item.permission}`):null,
|
|
75
|
+
item.command?h('pre',{className:'dwb-approval-command'},item.command):null,
|
|
76
|
+
h('p',{className:'dwb-approval-note'},item.outcome==='pending'?t('scope'):status),
|
|
77
|
+
item.outcome==='pending'&&!available?h('p',{className:'dwb-approval-note'},t('unknown')):null),
|
|
78
|
+
error?h('div',{className:'dwb-approval-error',role:'alert'},error):null,
|
|
79
|
+
h('div',{className:'dwb-approval-actions'},
|
|
80
|
+
h('button',{type:'button',onClick:()=>ctx.uiWorkspace.openSession(item.sessionId)},t('open')),
|
|
81
|
+
item.outcome==='pending'?h(React.Fragment,null,
|
|
82
|
+
h('button',{type:'button',className:'dwb-approval-reject',disabled:!available||sending,onClick:()=>answer('rejected')},t('reject')),
|
|
83
|
+
h('button',{type:'button',className:'dwb-approval-allow',disabled:!available||sending,onClick:()=>answer('allowed-once')},t('allow'))):null))
|
|
84
|
+
}
|
|
85
|
+
return function GroupApprovals({groupId,useSessionStatus,lines=[],renderMessage}) {
|
|
86
|
+
const [snapshot,setSnapshot]=React.useState({groupId,items:[],sessions:[],errors:[]}),[error,setError]=React.useState(null)
|
|
87
|
+
const refs=React.useRef(retained),generation=React.useRef(0),refreshRef=React.useRef(()=>{})
|
|
88
|
+
const statuses=useSessionStatus(map=>map)
|
|
89
|
+
const [,localeTick]=React.useState(0)
|
|
90
|
+
React.useEffect(()=>ctx.locale.subscribe(()=>localeTick(n=>n+1)),[])
|
|
91
|
+
React.useEffect(()=>{
|
|
92
|
+
const gen=++generation.current;let dead=false,busy=false
|
|
93
|
+
const refresh=async()=>{
|
|
94
|
+
if(dead||busy)return;busy=true
|
|
95
|
+
try{
|
|
96
|
+
const data=await call('approvals.list',{groupId})
|
|
97
|
+
if(dead||generation.current!==gen)return
|
|
98
|
+
setSnapshot({...data,groupId});setError(data.errors?.length?t('error'):null)
|
|
99
|
+
updateRetentions(groupId,data.sessions)
|
|
100
|
+
}catch(e){if(!dead)setError(String(e?.message??e))}finally{busy=false}
|
|
101
|
+
}
|
|
102
|
+
refreshRef.current=refresh;refresh();const timer=setInterval(refresh,1200)
|
|
103
|
+
return()=>{dead=true;++generation.current;clearInterval(timer)}
|
|
104
|
+
},[groupId])
|
|
105
|
+
// Never show or answer a previous group's snapshot while its replacement loads.
|
|
106
|
+
const items=snapshot.groupId===groupId?snapshot.items:[]
|
|
107
|
+
const visibleError=snapshot.groupId===groupId?error:null
|
|
108
|
+
if(!items.length&&!visibleError&&!renderMessage)return null
|
|
109
|
+
const timeline=approvalTimeline(lines,items)
|
|
110
|
+
return h(React.Fragment,null,h('style',null,css),visibleError?h('div',{className:'dwb-approval-error',role:'alert'},visibleError):null,
|
|
111
|
+
...timeline.map((entry,index)=>{
|
|
112
|
+
if(entry.kind==='message')return renderMessage?.(entry.line,timeline[index-1]?.kind==='message'?timeline[index-1].line:null)
|
|
113
|
+
const item=entry.item
|
|
114
|
+
return h('div',{className:'dwb-approvals',key:`approval:${item.sessionId}:${item.id}`},h(Card,{item,pending:items.filter(a=>a.outcome==='pending'&&a.sessionId===item.sessionId&&a.callId===item.callId).length===1?statuses?.get(item.sessionId)?.pendingInteraction:null,refresh:()=>refreshRef.current()}))
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Update controls for the existing Workbench panel; embedded into client.js at build time.
|
|
2
|
+
// All styles live in the components that render them, so no other page owns their CSS.
|
|
3
|
+
export function createUpdateUI(React, ctx, call) {
|
|
4
|
+
const h = React.createElement
|
|
5
|
+
const NS = 'dwbUpdates'
|
|
6
|
+
const zh = {
|
|
7
|
+
about:'关于', cloud:'有新版本', title:'协作工作台', current:'当前版本', latest:'最新版本', check:'检查更新', checking:'正在检查…', update:'更新', updating:'正在安装…', restart:'更新已安装,请重启 DSH 后使用', ready:'发现新版本', currentState:'已是最新版本', local:'当前从本地文件安装,请从原安装来源更新。', other:'当前安装来源不支持在线更新。', pending:'尚未检查更新', failed:'检查或安装失败', confirm:'确认安装协作工作台 {version}?安装后需要重启 DSH。', release:'版本信息', inaccessible:'此版本暂不提供更新检查。', error:'无法检查更新,请检查网络后重试。'
|
|
8
|
+
}
|
|
9
|
+
const en = {
|
|
10
|
+
about:'About', cloud:'Update available', title:'Collaboration Workbench', current:'Installed version', latest:'Latest version', check:'Check for updates', checking:'Checking…', update:'Update', updating:'Installing…', restart:'Update installed. Restart DSH to use it.', ready:'New version available', currentState:'Up to date', local:'Installed from a local file; update it from the original source.', other:'Updates are not available for this install source.', pending:'Not checked yet', failed:'Update check or install failed', confirm:'Install Collaboration Workbench {version}? Restart DSH afterwards.', release:'Release information', inaccessible:'Update checks are unavailable in this version.', error:'Could not check for updates. Check your network and try again.'
|
|
11
|
+
}
|
|
12
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'collaboration workbench: update locales')
|
|
13
|
+
const t = ctx.locale.bind(NS)
|
|
14
|
+
const css = `.dwb-update-cloud{width:52px;min-height:42px;padding:5px 0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:2px}.dwb-update-cloud:hover{background:var(--dsw-alias-bg-layer-2)}.dwb-update-cloud:focus-visible,.dwb-update-about button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.dwb-update-cloud span{font-size:10px}.dwb-update-about{flex:1;min-width:0;overflow:auto;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);padding:56px 24px 24px}.dwb-update-card{max-width:640px;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}.dwb-update-about h2{font-size:18px;margin:0 0 18px}.dwb-update-about dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:10px 20px;margin:0 0 18px}.dwb-update-about dt{color:var(--dsw-alias-label-secondary)}.dwb-update-about dd{margin:0;overflow-wrap:anywhere}.dwb-update-state{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary);margin:0 0 16px}.dwb-update-error{color:var(--dsw-alias-state-error-primary)}.dwb-update-actions{display:flex;gap:10px;flex-wrap:wrap}.dwb-update-about button{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:7px 12px;min-height:34px;cursor:pointer}.dwb-update-about button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}.dwb-update-about button:disabled{opacity:.5;cursor:default}.dwb-update-about button.dwb-update-primary{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}.dwb-update-about button.dwb-update-primary:hover:not(:disabled){filter:brightness(1.06)}`
|
|
15
|
+
function useUpdates() {
|
|
16
|
+
const [status, setStatus] = React.useState({ phase:'idle', source:'unknown', current:null, latest:null, available:false })
|
|
17
|
+
const [error, setError] = React.useState(null)
|
|
18
|
+
const [busy, setBusy] = React.useState(false)
|
|
19
|
+
const refresh = React.useCallback(async (fresh = false) => {
|
|
20
|
+
setBusy(true)
|
|
21
|
+
setError(null)
|
|
22
|
+
try {
|
|
23
|
+
const value = await call('updates.check', { fresh })
|
|
24
|
+
setStatus(value)
|
|
25
|
+
return value
|
|
26
|
+
} catch (failure) {
|
|
27
|
+
setError(String(failure?.message ?? failure))
|
|
28
|
+
return null
|
|
29
|
+
} finally { setBusy(false) }
|
|
30
|
+
}, [])
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
let active = true
|
|
33
|
+
const check = async () => {
|
|
34
|
+
try { const value = await call('updates.check', { fresh:false }); if (active) setStatus(value) }
|
|
35
|
+
catch (failure) { if (active) setError(String(failure?.message ?? failure)) }
|
|
36
|
+
}
|
|
37
|
+
void check()
|
|
38
|
+
const timer = setInterval(check, status.phase === 'installing' ? 2_000 : 60_000)
|
|
39
|
+
return () => { active = false; clearInterval(timer) }
|
|
40
|
+
}, [status.phase])
|
|
41
|
+
const install = React.useCallback(async () => {
|
|
42
|
+
if (!status.available || busy || !window.confirm(t('confirm').replace('{version}', status.latest))) return
|
|
43
|
+
setBusy(true)
|
|
44
|
+
setError(null)
|
|
45
|
+
try { setStatus(await call('updates.install')) }
|
|
46
|
+
catch (failure) { setError(String(failure?.message ?? failure)) }
|
|
47
|
+
finally { setBusy(false) }
|
|
48
|
+
}, [status.available, status.latest, busy])
|
|
49
|
+
return {status, error, busy, refresh, install}
|
|
50
|
+
}
|
|
51
|
+
function UpdateCloud({ onClick }) {
|
|
52
|
+
return h(React.Fragment, null, h('style', null, css),
|
|
53
|
+
h('button', { type:'button', className:'dwb-update-cloud', title:t('cloud'), 'aria-label':t('cloud'), onClick },
|
|
54
|
+
h('svg', {width:18,height:18,viewBox:'0 0 24 24',fill:'none',stroke:'currentColor',strokeWidth:1.8,'aria-hidden':true},
|
|
55
|
+
h('path',{d:'M7 18h11a4 4 0 0 0 .2-8A6.5 6.5 0 0 0 5.6 9 4.5 4.5 0 0 0 7 18Z'}),
|
|
56
|
+
h('path',{d:'m12 15 0-6m-2.5 2.5L12 9l2.5 2.5'})),
|
|
57
|
+
h('span',null,t('update'))))
|
|
58
|
+
}
|
|
59
|
+
function UpdateAbout({ status, error, busy, refresh, install }) {
|
|
60
|
+
const state = status.phase === 'installed' ? t('restart') : status.phase === 'installing' ? t('updating') : error || status.phase === 'failed' ? t('failed') : status.source === 'local' ? t('local') : status.source !== 'registry' ? t('other') : status.available ? t('ready') : status.checkedAt ? status.latest ? t('currentState') : t('error') : t('pending')
|
|
61
|
+
const canUpdate = status.available && status.phase !== 'installing' && status.phase !== 'installed'
|
|
62
|
+
return h('section', {className:'dwb-update-about', 'aria-label':t('title')},
|
|
63
|
+
h('style',null,css),
|
|
64
|
+
h('div',{className:'dwb-update-card'},
|
|
65
|
+
h('h2',null,t('title')),
|
|
66
|
+
h('dl',null,h('dt',null,t('current')),h('dd',null,status.current ?? '—'),h('dt',null,t('latest')),h('dd',null,status.latest ?? '—')),
|
|
67
|
+
h('p',{className:`dwb-update-state ${error || status.phase === 'failed' ? 'dwb-update-error' : ''}`,role:error || status.phase === 'failed' ? 'alert':undefined}, state, error ? `: ${error}` : status.phase === 'failed' && status.error ? `: ${status.error}` : ''),
|
|
68
|
+
h('div',{className:'dwb-update-actions'},
|
|
69
|
+
h('button',{type:'button',onClick:()=>refresh(true),disabled:busy || status.phase === 'installing'},busy?t('checking'):t('check')),
|
|
70
|
+
canUpdate ? h('button',{type:'button',className:'dwb-update-primary',onClick:install,disabled:busy},t('update')) : null)))
|
|
71
|
+
}
|
|
72
|
+
return {useUpdates, UpdateCloud, UpdateAbout, t}
|
|
73
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// agents.js —— 人员(Agent Session)管理。
|
|
2
|
+
// 每名人员 = 一个根 Session(sessionController.create + 命名 + 可选模型选择);
|
|
3
|
+
// 职责描述作为会话首条上下文消息注入(并在每次职责更新时追加最新版)。
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
6
|
+
|
|
7
|
+
// 内置预设角色模板(规格 R-1:五类)。
|
|
8
|
+
export const PRESET_ROLES = [
|
|
9
|
+
{ type: '研发负责人', defaultInstructions: '接收需求、分解任务、协调进度、验收交付。' },
|
|
10
|
+
{ type: '架构师', defaultInstructions: '技术方案设计、架构评审。' },
|
|
11
|
+
{ type: '高级工程师', defaultInstructions: '代码实现、联调、自测。' },
|
|
12
|
+
{ type: '测试工程师', defaultInstructions: '测试用例设计、质量验证。' },
|
|
13
|
+
{ type: '产品经理', defaultInstructions: '需求澄清、验收标准定义、价值把关。' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
// 角色名合法性:不允许空白、斜杠、反斜杠、控制字符。
|
|
17
|
+
const NAME_BAD = /[\/\\\u0000-\u001f]/
|
|
18
|
+
const GROUP_SUFFIX = '沟通群' // 保留:群组命名规范在 groups.js
|
|
19
|
+
|
|
20
|
+
export function install(rt) {
|
|
21
|
+
const { ctx } = rt
|
|
22
|
+
|
|
23
|
+
// 确保团队工作区存在(无目录群的兜底)。
|
|
24
|
+
// ⚠️ 必须按插件专属路径查找/创建——旧实现取 list[0](注册表第一个工作区),
|
|
25
|
+
// 会把无目录群的成员会话建进任意用户工作区(2026-10-16 复现:建进了
|
|
26
|
+
// 残留的僵尸工作区)。
|
|
27
|
+
rt.ensureWorkspace = async () => {
|
|
28
|
+
const { workspaceRegistry } = ctx
|
|
29
|
+
const path = join(rt.home, 'workspace')
|
|
30
|
+
try {
|
|
31
|
+
const existing = await workspaceRegistry.resolveByPath?.(path)
|
|
32
|
+
if (existing) return existing.id
|
|
33
|
+
} catch { /* 查询失败走创建 */ }
|
|
34
|
+
// 真实 registry.create 要求目录已存在——先建目录再注册。
|
|
35
|
+
const { mkdir } = await import('node:fs/promises')
|
|
36
|
+
await mkdir(path, { recursive: true }).catch(() => {})
|
|
37
|
+
const workspace = await workspaceRegistry.create(path, 'DSH Workbench')
|
|
38
|
+
return workspace.id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 解析 Session 对应的 Agent。
|
|
42
|
+
rt.resolveAgent = async (sessionId) => {
|
|
43
|
+
const resolved = await ctx.sessionController.resolveAgent(sessionId)
|
|
44
|
+
if (resolved && 'error' in resolved) {
|
|
45
|
+
throw new Error(resolved.error?.message ?? 'Session unavailable')
|
|
46
|
+
}
|
|
47
|
+
return resolved.agent
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 为人员创建根 Session:create → resolveAgent → 命名 →(可选)选择模型。
|
|
51
|
+
// model 形如 "provider/model";选择失败不阻断创建。
|
|
52
|
+
rt.createRoleSession = async (roleName, model) => {
|
|
53
|
+
const workspaceId = await rt.ensureWorkspace()
|
|
54
|
+
const { sessionId } = await ctx.sessionController.create({ workspaceId })
|
|
55
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
56
|
+
await ctx.sessionTitle.rename(agent.session, roleName)
|
|
57
|
+
if (model) await rt.applySessionModel(sessionId, model)
|
|
58
|
+
return { sessionId, agent }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 给已有 Session 应用模型选择("provider/model")。
|
|
62
|
+
rt.applySessionModel = async (sessionId, model) => {
|
|
63
|
+
const sel = parseModel(model)
|
|
64
|
+
if (!sel) return false
|
|
65
|
+
try {
|
|
66
|
+
await ctx.sessionController.selectModel({ sessionId, ...sel })
|
|
67
|
+
return true
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.warn('[dsh-workbench] selectModel failed:', model, error?.message ?? error)
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 职责变更后:同步到该人员所在的每个群会话(带最新职责)。
|
|
75
|
+
rt.syncDutyToGroups = async (role) => {
|
|
76
|
+
for (const group of rt.listGroups() ?? []) {
|
|
77
|
+
const entry = (group.sessions ?? {})[role.id]
|
|
78
|
+
if (!entry?.id) continue
|
|
79
|
+
try {
|
|
80
|
+
const agent = await rt.resolveAgent(entry.id)
|
|
81
|
+
agent.followup(createUserMessage({
|
|
82
|
+
content: [{ type: 'text', text: `【职责已更新】你的最新职责:${role.instructions || '(未指定,按群组安排)'}(你的 roleId 仍是 ${role.id})` }],
|
|
83
|
+
source: { kind: 'dsh-workbench', form: 'duty' },
|
|
84
|
+
}))
|
|
85
|
+
await ctx.sessions.flush(agent.session)
|
|
86
|
+
} catch { /* 单个群会话失败不影响其他 */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 给人员 Session 追加一条职责上下文消息(首条注入 & 更新时刷新)。
|
|
91
|
+
rt.sendRoleContextMessage = async (role, { initial = false } = {}) => {
|
|
92
|
+
if (!role?.sessionId) return false
|
|
93
|
+
try {
|
|
94
|
+
const agent = await rt.resolveAgent(role.sessionId)
|
|
95
|
+
const head = initial ? '【你的身份与职责(常驻指令)】' : '【职责已更新】'
|
|
96
|
+
const lines = [
|
|
97
|
+
head,
|
|
98
|
+
`你是协作工作台中的人员「${role.name}」。`,
|
|
99
|
+
role.instructions ? `你的职责:${role.instructions}` : '(未指定具体职责,按群组安排工作)',
|
|
100
|
+
'工作规范:所有沟通只发生在群组中,用 @人员名 点名指定回答者;不存在一对一私聊;收到消息后把结论回复到群组。',
|
|
101
|
+
]
|
|
102
|
+
const message = createUserMessage({
|
|
103
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
104
|
+
source: { kind: 'dsh-workbench', form: 'duty' },
|
|
105
|
+
})
|
|
106
|
+
agent.followup(message)
|
|
107
|
+
await ctx.sessions.flush(agent.session)
|
|
108
|
+
return true
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.warn('[dsh-workbench] sendRoleContextMessage failed:', error?.message ?? error)
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 名字清洗与校验。
|
|
116
|
+
const cleanName = (raw) => String(raw ?? '').trim()
|
|
117
|
+
rt.validateRoleName = (raw) => {
|
|
118
|
+
const name = cleanName(raw)
|
|
119
|
+
if (!name) throw new Error('角色名称不能为空')
|
|
120
|
+
if (NAME_BAD.test(name)) throw new Error('角色名称不能包含 /、\\ 或控制字符')
|
|
121
|
+
return name
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 预设类型查询。
|
|
125
|
+
rt.presetRole = (type) => PRESET_ROLES.find(item => item.type === type)
|
|
126
|
+
|
|
127
|
+
// ---- 新建 ----
|
|
128
|
+
// name 角色名;type 预设类型或自定义类型;instructions 职责描述;model 可选模型。
|
|
129
|
+
rt.createRole = async ({ name: rawName, type, instructions, model }) => {
|
|
130
|
+
const name = rt.validateRoleName(rawName)
|
|
131
|
+
if (rt.listRoles().some(item => item.name === name)) {
|
|
132
|
+
throw new Error(`角色「${name}」已存在`)
|
|
133
|
+
}
|
|
134
|
+
const preset = rt.presetRole(type)
|
|
135
|
+
const role = {
|
|
136
|
+
id: rt.nextId('role'),
|
|
137
|
+
name,
|
|
138
|
+
type: preset ? preset.type : (type ?? '自定义'),
|
|
139
|
+
// 职责只存纯净内容:用户留空则回填预设默认职责(|| 使空串也回填)。
|
|
140
|
+
instructions: (String(instructions ?? '').trim()) || preset?.defaultInstructions || '',
|
|
141
|
+
model: model ?? null,
|
|
142
|
+
sessionId: null, // 惰性创建:P2 起 Session 在首次启用/加入群组时创建
|
|
143
|
+
status: 'active',
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
updatedAt: Date.now(),
|
|
146
|
+
}
|
|
147
|
+
// V5:不再创建根会话(职责注入改为群会话种子消息),只在台账登记。
|
|
148
|
+
// 根会话会在 DSH 侧栏产生多余条目;群会话按需创建且带标题。
|
|
149
|
+
rt.addRole(role)
|
|
150
|
+
return role
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- 列出 / 查询 ----
|
|
154
|
+
rt.listRoles = () => rt.state.roles.items ?? []
|
|
155
|
+
rt.getRole = (id) => rt.state.roles.items.find(item => item.id === id)
|
|
156
|
+
rt.findRoleByName = (name) => rt.state.roles.items.find(item => item.name === name)
|
|
157
|
+
|
|
158
|
+
// ---- 编辑 ----
|
|
159
|
+
rt.updateRole = (id, patch) => {
|
|
160
|
+
for (const key of Object.keys(patch)) if (!['name','type','instructions','model'].includes(key)) throw new Error(`不允许修改角色字段 ${key}`)
|
|
161
|
+
const current = rt.getRole(id)
|
|
162
|
+
if (!current) throw new Error(`角色 ${id} 不存在`)
|
|
163
|
+
if (patch.name !== undefined) {
|
|
164
|
+
const newName = rt.validateRoleName(patch.name)
|
|
165
|
+
const dup = rt.state.roles.items.find(item => item.id !== id && item.name === newName)
|
|
166
|
+
if (dup) throw new Error(`角色「${newName}」已存在`)
|
|
167
|
+
patch.name = newName
|
|
168
|
+
}
|
|
169
|
+
const updated = rt.updateRoleRecord(id, {
|
|
170
|
+
...patch,
|
|
171
|
+
updatedAt: Date.now(),
|
|
172
|
+
})
|
|
173
|
+
return updated
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- 停用 / 启用 ----
|
|
177
|
+
rt.setRoleStatus = async (id, status) => {
|
|
178
|
+
if (!['active','disabled'].includes(status)) throw new Error('角色状态必须是 active/disabled')
|
|
179
|
+
const current = rt.getRole(id)
|
|
180
|
+
if (!current) throw new Error(`角色 ${id} 不存在`)
|
|
181
|
+
const updated = rt.updateRoleRecord(id, { status, updatedAt: Date.now() })
|
|
182
|
+
if (status === 'disabled') {
|
|
183
|
+
await rt.pauseRoleExecution?.(null, id)
|
|
184
|
+
for (const group of rt.listGroups()) if (group.members.includes(id)) await rt.stopPerson?.(group.id,id)
|
|
185
|
+
}
|
|
186
|
+
await rt.saveAll?.()
|
|
187
|
+
return updated
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- 删除 ----
|
|
191
|
+
// 有群组/任务关联时返回拒绝原因(由调用方决定是否强制)。
|
|
192
|
+
rt.deleteRole = (id) => {
|
|
193
|
+
const current = rt.getRole(id)
|
|
194
|
+
if (!current) throw new Error(`角色 ${id} 不存在`)
|
|
195
|
+
const groupRef = (rt.listGroups() ?? []).find(g => (g.members ?? []).includes(id))
|
|
196
|
+
const taskRef = (rt.listTasks() ?? []).find(t => (t.assignees ?? []).includes(id))
|
|
197
|
+
if (groupRef) return { refused: true, reason: `角色仍在群组「${groupRef.name}」中` }
|
|
198
|
+
if (taskRef) return { refused: true, reason: `角色仍被任务「${taskRef.title}」指派` }
|
|
199
|
+
const ok = rt.removeRole(id)
|
|
200
|
+
return { refused: !ok, reason: ok ? undefined : '角色不存在' }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
rt.forceDeleteRole = async id => {
|
|
204
|
+
const role = rt.getRole(id)
|
|
205
|
+
if (!role) throw new Error('角色不存在')
|
|
206
|
+
await rt.setRoleStatus(id, 'disabled')
|
|
207
|
+
for (const group of rt.listGroups()) {
|
|
208
|
+
if (group.members.includes(id)) rt.updateGroupRecord(group.id,{members:group.members.filter(m=>m!==id),admins:(group.admins??[]).filter(m=>m!==id)})
|
|
209
|
+
}
|
|
210
|
+
for (const task of rt.listTasks()) if (task.assignees.includes(id)) {
|
|
211
|
+
const updated = rt.updateTaskRecord(task.id,{assignees:task.assignees.filter(a=>a!==id),updatedAt:Date.now()})
|
|
212
|
+
await rt.taskChanged?.(updated,task)
|
|
213
|
+
}
|
|
214
|
+
rt.removeRole(id);await rt.saveAll?.()
|
|
215
|
+
return {deleted:true,role:role.name}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ledger 底层记录更新(避免与 ledger.updateRole 递归冲突)。
|
|
219
|
+
rt.updateRoleRecord = (id, patch) => {
|
|
220
|
+
const index = rt.state.roles.items.findIndex(item => item.id === id)
|
|
221
|
+
if (index === -1) return undefined
|
|
222
|
+
rt.state.roles.items[index] = { ...rt.state.roles.items[index], ...patch }
|
|
223
|
+
rt.touch()
|
|
224
|
+
return rt.state.roles.items[index]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// "provider/model" → { provider, model };不合法返回 null。
|
|
229
|
+
function parseModel(model) {
|
|
230
|
+
const raw = String(model ?? '').trim()
|
|
231
|
+
if (!raw) return null
|
|
232
|
+
const i = raw.indexOf('/')
|
|
233
|
+
if (i <= 0 || i >= raw.length - 1) return null
|
|
234
|
+
return { provider: raw.slice(0, i), model: raw.slice(i + 1) }
|
|
235
|
+
}
|