Compare commits

..
Author SHA1 Message Date
weichen 92e05392a7 Verify saved role permissions and settled project-switch responses
Why: Checkbox assertions did not prove the save payload, and the project
switch test had no expanded descendants or settled late-response assertion.

Evidence: The strengthened seven browser checks pass as SELF in the Dev
feature/admin-notes-repair task card. Production source is unchanged.

Impact: Test-only coverage adds exact role/action/default/cap save assertions,
refresh checks, expanded descendants, and awaited late-response settlement.

Refs: Independent reviewer repair_candidate_review; Dev R4/R3
2026-10-13 03:12:06 +00:00
weichen 9ea8249be8 Restore management controls and complete note-tree browsing
Why: EasyList hid ad-prefixed admin controls, note spines could not expand,
and late details and stale permission checkboxes misrepresented selections.

Evidence: Dev task feature/admin-notes-repair records the red/green browser
checks, 215 Frontend tests, and build results as SELF.

Impact: Rename management selectors, use recursive Collapsible tree levels
with local retry and request guards, and load role checkbox values correctly.
No permission or storage contracts change.

Refs: Dev feature/admin-notes-repair; RDM feature/inspect-notes-repair
2026-10-13 02:45:55 +00:00
55 changed files with 917 additions and 4206 deletions
+200
View File
@@ -0,0 +1,200 @@
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.ADMIN_NOTES_REPAIR_BROWSER, 'Run against the task-owned UI preview')
// Fixed EasyList cosmetic rules that hid the real management controls on 8788.
// https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_hide.txt
const cosmeticRules = '.ad-body,.ad-hint,.ad-notice,.ad-row,.ad-table { display:none!important }'
const stamp = '2026-10-13T02:00:00Z'
const commands = ['rdm.role.create', 'rdm.role.action', 'rdm.role.assign', 'rdm.role.disable', 'rdm.role.list',
'rdm.inspect.capabilities', 'rdm.inspect.summary', 'rdm.inspect.tree', 'rdm.inspect.object',
'rdm.inspect.relations', 'rdm.inspect.events', 'rdm.inspect.history']
const roles = [
{ roleId: 'engineer', name: '工程师', disabled: false, version: 1, actions: { 'project.create': [true, true], 'doc.write': [false, true] }, accounts: [] },
{ roleId: 'reviewer', name: '审核员', disabled: false, version: 1, actions: { 'project.create': [false, false] }, accounts: [] },
]
const shell = (items: unknown[], nextCursor = '') => ({ items, hasMore: !!nextCursor, nextCursor, truncated: false, coverage: [] })
async function fixture(page: Page) {
let detailFailed = false
let childFailed = false
let releaseSlow!: () => void
let reportSlow!: () => void
const slow = new Promise<void>(resolve => { releaseSlow = resolve })
const slowStarted = new Promise<void>(resolve => { reportSlow = resolve })
page.on('close', releaseSlow)
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'repair-admin', name: '修复验证', auth: true, authVia: 'session', isAdmin: true, mustChangePassword: false }
else if (path === '/api/settings') body = {}
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path === '/api/admin-zone/sections') body = [{ id: 'rdm.audit', title: '项目追溯与审计', icon: 'search' }]
else if (path === '/api/admin/users') body = [{ owner: 'repair-admin', name: '修复验证', isAdmin: true, mustChangePassword: false, hasPassword: true, createdAt: stamp }]
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.slice('/api/commands/'.length))
const params = request.postDataJSON()?.params ?? {}
let payload: unknown
if (command === 'rdm.role.list') payload = { roles, count: 2 }
else if (command === 'rdm.role.action') payload = params
else if (command === 'rdm.inspect.capabilities') payload = { actor: 'repair-admin', crossProject: true, projects: [
{ projectId: 'p1', name: '主项目', canAudit: true }, { projectId: 'p2', name: '隔离项目', canAudit: true },
] }
else if (command === 'rdm.inspect.summary') payload = { projectId: params.projectId, counts: {}, watermark: 10, coverage: [] }
else if (command === 'rdm.inspect.tree') {
if (params.projectId === 'p2') payload = params.kind === 'notes'
? shell([{ kind: 'spine', id: 'other', title: '隔离骨架' }])
: shell([{ kind: 'doc', id: 'other-doc', title: '隔离文档' }])
else if (params.kind === 'docs') payload = shell(params.parent ? [{ kind: 'section', id: 'sec1', title: '章节一' }] : [{ kind: 'doc', id: 'doc1', title: '项目文档' }])
else if (!params.parent) payload = shell([
{ kind: 'spine', id: 'root', title: '阶段骨架' },
{ kind: 'slice', id: 'slow', title: '慢速资料' }, { kind: 'slice', id: 'fast', title: '快速资料' },
])
else if (params.parent === 'root') payload = params.cursor ? shell([{ kind: 'slice', id: 'second', title: '第二页资料' }]) : shell([{ kind: 'spine', id: 'child', title: '需求骨架' }], 'root-page-2')
else if (params.parent === 'child') {
if (!childFailed) {
childFailed = true
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary child query failure' } } })
}
payload = shell([{ kind: 'slice', id: 'meeting', title: '会议资料' }])
} else payload = shell([])
} else if (command === 'rdm.inspect.object') {
if (params.id === 'meeting' && !detailFailed) {
detailFailed = true
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary detail query failure' } } })
}
if (params.id === 'slow') { reportSlow(); await slow }
const titles: Record<string, string> = { root: '阶段骨架详情', child: '需求骨架详情', meeting: '会议资料详情', slow: '慢速资料详情', fast: '快速资料详情', second: '第二页资料详情', sec1: '章节一详情', other: '隔离骨架详情' }
payload = { kind: params.kind, id: params.id, projectId: params.projectId, title: titles[params.id], parentId: params.id === 'child' ? 'root' : '', childCount: 1, sliceCount: 1, relations: [], recentEvents: [], coverage: [] }
} else return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: `unexpected command ${command}` } } })
body = { receipt: { state: 'committed', commandId: command }, payload }
}
await route.fulfill({ status: 200, json: body })
})
await page.goto('/')
await expect(page.locator('.tsb-user')).toContainText('修复验证')
await page.locator('.tsb-user').click()
await page.getByText('系统管理…', { exact: true }).click()
return { slowStarted, releaseSlow }
}
async function notes(page: Page) {
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.getByRole('button', { name: '笔记树', exact: true }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
}
test('advertising filters leave management forms and role tables visible', async ({ page }, testInfo) => {
await fixture(page)
await page.addStyleTag({ content: cosmeticRules })
await expect(page.getByPlaceholder('用户名(登录名)')).toBeVisible()
await page.getByPlaceholder('用户名(登录名)').fill('new-user')
await page.getByPlaceholder('初始密码(≥8 位)').fill('fixture-password')
await expect(page.getByRole('button', { name: '创建账号', exact: true })).toBeEnabled()
await expect(page.getByRole('cell', { name: 'repair-admin', exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('users-with-adblock.png') })
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
await expect(page.getByRole('button', { name: '创建角色', exact: true })).toBeVisible()
await expect(page.getByRole('cell', { name: /工程师/ })).toBeVisible()
await expect(page.getByLabel('默认允许')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('roles-with-adblock.png') })
})
test('selecting a role and action loads their actual permission checkboxes', async ({ page }) => {
await fixture(page)
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
const selects = page.locator('.az-main select')
await selects.nth(0).selectOption('engineer')
await expect(page.getByLabel('默认允许')).toBeChecked()
await expect(page.getByLabel('上限允许')).toBeChecked()
const saved = page.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/rdm.role.action'))
const refreshed = page.waitForResponse(r => r.url().endsWith('/rdm.role.list'))
await page.getByRole('button', { name: '保存动作', exact: true }).click()
expect((await saved).postDataJSON()).toEqual({ params: {
roleId: 'engineer', action: 'project.create', default: true, cap: true,
} })
await refreshed
await expect(page.getByLabel('默认允许')).toBeChecked()
await expect(page.getByLabel('上限允许')).toBeChecked()
await selects.nth(1).selectOption('doc.write')
await expect(page.getByLabel('默认允许')).not.toBeChecked()
await expect(page.getByLabel('上限允许')).toBeChecked()
await selects.nth(1).selectOption('project.create')
await selects.nth(0).selectOption('reviewer')
await expect(page.getByLabel('默认允许')).not.toBeChecked()
await expect(page.getByLabel('上限允许')).not.toBeChecked()
})
test('notes expand nested spines, retry a child query, and inspect mounted slices', async ({ page }, testInfo) => {
await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('阶段骨架详情')
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
await expect(page.getByRole('button', { name: '重试列表', exact: true })).toBeVisible()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
await page.getByRole('button', { name: '重试列表', exact: true }).click()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' }).click()
await expect(page.getByRole('button', { name: '重试详情', exact: true })).toBeVisible()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
await page.getByRole('button', { name: '重试详情', exact: true }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('会议资料详情')
await page.screenshot({ path: testInfo.outputPath('notes-detail.png') })
})
test('each expanded spine can load its next page', async ({ page }) => {
await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
await page.getByRole('button', { name: '加载更多', exact: true }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '第二页资料' })).toBeVisible()
})
test('late detail responses cannot replace the newly selected object', async ({ page }) => {
const control = await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
await control.slowStarted
await page.locator('.rdm-audit-tree-node').filter({ hasText: '快速资料' }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
control.releaseSlow()
await response
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
})
test('project switching clears note descendants and in-flight object details', async ({ page }) => {
const control = await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
await page.getByRole('button', { name: '重试列表', exact: true }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
await control.slowStarted
await page.locator('.rdm-audit-project').selectOption('p2')
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '隔离骨架' })).toBeVisible()
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
control.releaseSlow()
await response
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toHaveCount(0)
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toHaveCount(0)
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toHaveCount(0)
await expect(page.locator('.rdm-audit-detail')).toHaveCount(0)
})
test('document expansion and section inspection remain available', async ({ page }) => {
await fixture(page)
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '项目文档' }).click()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '章节一' }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('章节一详情')
})
+4 -11
View File
@@ -17,21 +17,14 @@ async function login(page: Page) {
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
const gate = page.getByRole('heading', { name: '设置新密码' })
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD2)
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
-112
View File
@@ -1,112 +0,0 @@
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
// 新密码登录进工作台;重复兑换同一 Key 被拒。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
import { test, expect, type Page } from '@playwright/test'
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
async function passwordLogin(page: Page, owner: string, password: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
async function adminLogin(page: Page) {
await passwordLogin(page, ADMIN, ADMIN_BOOT)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
let gate = page.getByRole('heading', { name: '设置新密码' })
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!gated) {
// boot 密码失败(此前运行已完成改密):换正式密码重登。
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
if (!loginErr) throw new Error('admin login neither gated nor errored')
await passwordLogin(page, ADMIN, ADMIN_PW)
gate = page.getByRole('heading', { name: '设置新密码' })
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
}
if (gated) {
const pw = page.locator('.lg-card input[type="password"]')
await pw.nth(0).fill(ADMIN_PW)
await pw.nth(1).fill(ADMIN_PW)
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await passwordLogin(page, ADMIN, ADMIN_PW)
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
// 每个用户行都有同款按钮:限定目标用户所在行。
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
await expect(keyInput).toBeVisible({ timeout: 10_000 })
const key = await keyInput.inputValue()
expect(key).toMatch(/^alk_/)
await expect(zone.getByText(/单次有效/)).toBeVisible()
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(USER_PW)
await pw.nth(1).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
})
-20
View File
@@ -123,24 +123,4 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
await login(page)
await openAudit(page)
const exportBox = page.locator('.rdm-audit-export')
await expect(exportBox).toBeVisible()
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
const draft = exportBox.getByRole('button', { name: '生成草稿' })
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
await draft.click()
}
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
await expect(exportBox.getByText(/有效期/)).toBeVisible()
const download = page.waitForEvent('download')
await exportBox.getByRole('button', { name: '下载' }).click()
const file = await download
expect(file.suggestedFilename()).toMatch(/\.docx$/)
})
})
-101
View File
@@ -1,101 +0,0 @@
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
async function dutiesPage(page: Page) {
const writes: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
let generation = 0
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
const hold = holds.get(command)
if (hold) { hold.started.resolve(); await hold.release.promise }
const author = generation === 0 ? 'alice' : 'bob'
if (command === 'rdm.duty.matrix') {
body = { payload: {
canAssign: true,
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
cells: [
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
],
} }
} else if (command === 'rdm.duty.preview') {
writes.push({ command, params })
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
} else if (command === 'rdm.duty.legacy.scan') {
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
} else {
writes.push({ command, params })
generation += 1
body = { payload: { applied: 1 } }
}
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
return { writes, hold(command: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(command, pending)
return pending
} }
}
test('three views share one draft and do not offer review actions', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('选择成员').selectOption('bob')
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
await page.getByRole('tab', { name: '按文档' }).click()
await page.getByLabel('选择文档').selectOption('doc-A')
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
await page.getByRole('button', { name: '保存职责' }).click()
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
})
test('a stale matrix response does not replace the current draft', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('选择成员').selectOption('alice')
const held = fixture.hold('rdm.duty.matrix')
await page.getByLabel('筛选文档名称或编号').fill('文档A')
await held.started.promise
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
held.release.resolve()
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
})
test('a narrow viewport points away from the full matrix', async ({ page }) => {
await dutiesPage(page)
await page.setViewportSize({ width: 390, height: 800 })
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
})
-107
View File
@@ -1,107 +0,0 @@
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
// This checks client request ownership, not server authorization or business acceptance.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
async function mount(page: Page, delayed: string) {
const index = await page.request.get('/')
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
// Let Vite prepare ReactDOM before requesting its optimized module directly.
await page.request.get('/src/main.tsx')
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
body: html }))
await page.goto('/__export_test')
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
await page.evaluate(async delayed => {
const load = (path: string) => import(/* @vite-ignore */ path)
const { default: React } = await load('/node_modules/.vite/deps/react.js')
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
const { api } = await load('/src/api.ts')
const { auth } = await load('/src/auth.ts')
const { store } = await load('/src/store.ts')
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
const calls: { command: string; params: any }[] = []
let actor = 'alice'
const pending: (() => void)[] = []
let downloads = 0
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = () => {}
api.listPlugins = async () => []
api.listWorkspaces = async () => []
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
api.adminZoneSections = async () => []
api.getSettings = async () => ({ user: {} })
api.rdmCommand = async (command: string, params: any = {}) => {
calls.push({ command, params })
const result = command === 'rdm.inspect.capabilities'
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
: command === 'rdm.inspect.summary'
? { projectId: params.projectId, counts: {}, watermark: 7 }
: command === 'rdm.inspect.export.create'
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
: command === 'rdm.inspect.export.status'
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
if (command === delayed && params.projectId === 'A') {
await new Promise<void>(resolve => pending.push(resolve))
}
return result
}
await store.refreshPlugins()
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(React.createElement(RdmAuditWorkspace))
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
unmount: () => root.unmount() }
}, delayed)
await expect(page.locator('.rdm-audit-export')).toBeVisible()
}
async function release(page: Page) {
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
}
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
for (const stage of ['create', 'status']) {
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.' + stage)
await generate(page)
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await page.getByLabel('选择项目').selectOption('A')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await release(page)
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
})
}
for (const change of ['project', 'identity', 'unmount']) {
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.download')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
if (change === 'project') {
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
} else if (change === 'identity') {
await page.evaluate(() => (window as any).exportTest.identity())
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
} else await page.evaluate(() => (window as any).exportTest.unmount())
await release(page)
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
})
}
test('current identity can generate and download normally', async ({ page }) => {
await mount(page, '')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
})
+13 -369
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test'
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } from '../src/contracts'
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
@@ -21,20 +21,18 @@ const confirmation = work('confirm1', 'action_required', { kind: 'confirm', titl
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
})
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
let cards = [...initialCards]
let actionCount = 0
let releaseAction: (() => void) | undefined
let holdAction = false
let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined
const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = []
let sent: { text: string; attachmentIds: string[] } | undefined
let uploadNumber = 0
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(({ fontSize, theme }) => {
await page.addInitScript(({ fontSize }) => {
localStorage.setItem('as.conv', 'c1')
localStorage.setItem('as.ui', JSON.stringify({ theme, fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
const streams: { url: string; emit: (event: unknown) => void }[] = []
class TestEventSource {
onopen: (() => void) | null = null
@@ -50,7 +48,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
close() {}
}
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
}, { fontSize, theme })
}, { fontSize })
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
@@ -66,21 +64,8 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
{ type: 'thinking', process: true, text: '先核对日期' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
{ type: 'text', text: '最终回复已完成' },
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
], createdAt: stamp }] : []),
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
] : [], runs: [], activeHitl: [], lastSeq: 10 }
} else if (path === '/api/conversations/c1/cards') body = { cards }
else if (path === '/api/conversations/c1/runs/run-history/events') {
const after = Number(url.searchParams.get('after') ?? 0)
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
}
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
else if (path === '/api/projects/p1/artifacts') body = [artifact]
@@ -92,22 +77,6 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
else if (/\/messages$/.test(path) && request.method() === 'POST') {
sent = request.postDataJSON()
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
} else if (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) {
const cardId = path.split('/')[3]
const actionId = path.split('/').at(-1)!
const payload = request.postDataJSON() as WorkCardAction
workActions.push({ cardId, actionId, payload })
cards = cards.map(card => {
if (card.id !== cardId || !card.work) return card
const next = { ...card, work: { ...card.work } }
if (actionId === 'work.read') next.work.readVersion = payload.resultVersion
else if (next.work.decision) next.work.decision = { ...next.work.decision,
state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted',
...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}),
}
return next
})
body = { card: cards.find(card => card.id === cardId) }
} else if (path === '/api/cards/confirm1/actions/confirm') {
actionCount++
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
@@ -119,7 +88,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
await page.goto('/')
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
return { errors, workActions, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
await page.evaluate(({ type, payload, seq }) => {
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
@@ -131,145 +100,6 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
}
}
function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard {
return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history',
work: { resultVersion: 2, readVersion: 0, completedAt: stamp,
result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n'
+ '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n'
+ '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。'
+ '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。'
+ '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。'
+ '每项结论都应保留材料范围和来源,便于后续复核。\n\n'
+ '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。'
+ '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。'
+ '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。',
decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] },
},
})
}
test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => {
const state = await fixture(page, [optionalWork('optional-work', 'multiple')])
const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]')
await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible()
await inline.getByRole('button', { name: '先不选择', exact: true }).click()
await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible()
await expect(inline.getByRole('checkbox')).toHaveCount(0)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
expect(state.sent?.taskRef).toBeUndefined()
// Receiving the POST is earlier than rendering its legitimate foreground reply.
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
const assistantCount = await page.locator('.msg.assistant').count()
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check()
await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制')
await inline.getByRole('button', { name: '开始处理', exact: true }).click()
await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible()
await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible()
await expect(inline.getByRole('textbox')).toHaveCount(0)
expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{
cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' },
}])
await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount)
expect(state.errors).toEqual([])
})
test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1050 })
const state = await fixture(page, [optionalWork()])
await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果')
await page.locator('.work-result-notice').click()
const card = page.locator('.right-panel [data-card-id="optional-work"]')
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible()
if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true })
await card.getByRole('button', { name: '查看完整结果', exact: true }).click()
await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible()
expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0)
await card.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果')
await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 })
expect(state.sent?.text).toBe('解释一下需要确认的事项')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0)
await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2)
await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2)
expect(state.errors).toEqual([])
})
test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => {
const state = await fixture(page, [optionalWork()])
await page.locator('.work-result-notice').click()
await page.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') })
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
await input.fill('结合补充材料解释重点')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect(input).toHaveValue('结合补充材料解释重点')
await expect(page.locator('.attach-chip')).toContainText('补充材料.txt')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible()
expect(state.sent).toBeUndefined()
})
test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 800 })
const events: StreamEvent[] = [
{ seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
]
const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))]
const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const right = page.locator('.right-panel [data-work-scroll]')
const chat = page.locator('.chat-body')
await right.evaluate(element => { element.scrollTop = 180 })
const rightTop = await right.evaluate(element => element.scrollTop)
await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop)
const chatTop = await chat.evaluate(element => element.scrollTop)
await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop)
await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click()
const record = page.getByRole('region', { name: '说明书材料整理的执行记录' })
await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible()
await record.getByRole('button', { name: /查看记录/ }).click()
await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible()
await expect(page.locator('.work-execution-record')).toHaveCount(1)
await record.getByRole('button', { name: '返回工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible()
expect(state.errors).toEqual([])
})
test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => {
const older = optionalWork('older')
const latest = optionalWork('latest')
older.title = '旧材料整理'; latest.title = '新材料整理'
older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp,
options: [{ id: 'summary', label: '生成摘要' }] } }
latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } }
latest.updatedAt = '2026-10-13T10:00:00Z'
const state = await fixture(page, [older, latest])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2)
expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older'])
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await page.reload()
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
const state = await fixture(page)
await expect(page.locator('.right-panel')).toBeHidden()
@@ -284,105 +114,17 @@ test('keeps real summary and details accessible with right pane closed', async (
expect(state.errors).toEqual([])
})
test('places the full work card only in the desktop work pane', async ({ page }) => {
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
expect(state.errors).toEqual([])
})
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const conversation = page.locator('.chat-body .msg').first()
const authored = page.locator('.chat-body .msg.user').first()
const answer = page.locator('.chat-body .msg.assistant').first()
const [conversationBox, authoredBox, answerBox] = await Promise.all([
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
])
expect(conversationBox).not.toBeNull()
expect(authoredBox).not.toBeNull()
expect(answerBox).not.toBeNull()
expect(conversationBox!.width).toBeLessThanOrEqual(840)
expect(authoredBox!.width).toBeLessThanOrEqual(680)
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
const surfaces = await page.evaluate(() => {
const pick = (selector: string) => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return {
background: style.backgroundColor,
color: style.color,
borderRadius: Number.parseFloat(style.borderRadius),
paddingLeft: Number.parseFloat(style.paddingLeft),
}
}
return {
chrome: pick('.companion-topbar'),
context: pick('.left-nav'),
conversation: pick('.main'),
answer: pick('.msg.assistant'),
authored: pick('.msg.user .bubble'),
}
})
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
const activeCard = page.locator('.right-panel .acard.status-active')
await expect(activeCard).toBeVisible()
const cardStyle = await activeCard.evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
})
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
expect(cardStyle.background).not.toBe(panelBackground)
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
expect(state.errors).toEqual([])
})
for (const theme of ['light', 'dark', 'molv'] as const) {
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page, [progress], 14, undefined, theme)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return { selector, foreground: style.color, background: style.backgroundColor }
}))
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const luminance = (value: string) => {
const linear = channels(value).map(channel => {
const normalized = channel / 255
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
for (const surface of surfaces) {
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
}
expect(state.errors).toEqual([])
})
}
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const tabbed = work('work1', 'active', { body: [...progress.body!, {
@@ -418,117 +160,19 @@ test('preserves the selected material, physical scroll and focus when background
expect(state.errors).toEqual([])
})
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
const state = await fixture(page, [confirmation])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
state.hold()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
expect(state.actionCount).toBe(1)
state.release()
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
]
const state = await fixture(page, [], 14, { events })
const message = page.locator('[data-msg-id="m4"]')
await expect(message.getByText('最终回复已完成')).toBeVisible()
await expect(message.getByText('开始读取材料')).toHaveCount(0)
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('先核对日期')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整工具输出')).toBeVisible()
await page.reload()
const restored = page.locator('[data-msg-id="m4"]')
await expect(restored.getByText('最终回复已完成')).toBeVisible()
await restored.getByRole('button', { name: /执行记录/ }).click()
await expect(restored.getByText('先核对日期')).toBeVisible()
await restored.locator('.tool-block-head').click()
await expect(restored.getByText('完整工具输出')).toBeVisible()
expect(state.errors).toEqual([])
})
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
event(2, 'message.delta', { index: 1, text: '开始读取' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(7, 'run.phase', { phase: 'finalizing' }),
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
]
const savedParts: Part[] = [
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
{ type: 'thinking', process: true, text: '已解析的思考' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
{ type: 'text', text: '最终回复已完成' },
]
await fixture(page, [], 14, { events, savedParts })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
await expect(message.getByText('已解析的思考')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整输出留存')).toBeVisible()
})
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
await fixture(page, [], 14, { events: [
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '已完成' }),
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('协作结束:已完成')).toBeVisible()
})
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const workPane = page.locator('.right-panel')
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
})
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
const state = await fixture(page, [result])
+10 -19
View File
@@ -77,7 +77,7 @@ export function App() {
const h = (e: KeyboardEvent) => {
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
const key = e.key.toLowerCase()
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
if (key === 'b') { e.preventDefault(); openContext(); return }
}
workspaceShortcuts(e)
@@ -91,14 +91,11 @@ export function App() {
try {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
if (gated) {
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
@@ -114,14 +111,10 @@ export function App() {
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
let active = true
store.refreshMe().then(() => {
if (!active) return
void store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
})
return () => { active = false }
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
@@ -180,11 +173,9 @@ export function App() {
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
+13 -71
View File
@@ -1,11 +1,10 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
} from './contracts'
const BASE = ''
@@ -15,26 +14,11 @@ const BASE = ''
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> {
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res
}
async function json<T>(res: Response): Promise<T> {
await checked(res)
return res.json() as Promise<T>
}
@@ -42,7 +26,7 @@ export const api = {
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
@@ -58,48 +42,20 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
return request
},
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -107,13 +63,8 @@ export const api = {
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
.then(r => json<{ events: StreamEvent[] }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
@@ -127,8 +78,8 @@ export const api = {
listCards: (conversationId: string) =>
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
cardAction: (cardId: string, actionId: string, freeText?: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
cardPin: (cardId: string, pinned: boolean) =>
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
@@ -165,11 +116,7 @@ export const api = {
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
@@ -194,11 +141,6 @@ export const api = {
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
const error = rdmProjectCommandError(project, id)
if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
},
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
+3 -14
View File
@@ -17,22 +17,11 @@ function pickStorage(): KV {
}
const storage = pickStorage()
const identityListeners = new Set<() => void>()
function identityChanged() { identityListeners.forEach(listener => listener()) }
export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) {
const next = key.trim()
if (next === this.apiKey()) return
storage.setItem(KEY, next)
identityChanged()
},
clear() { storage.removeItem(KEY); identityChanged() },
onIdentityChange(listener: () => void) {
identityListeners.add(listener)
return () => { identityListeners.delete(listener) }
},
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
@@ -44,7 +33,7 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
+12 -112
View File
@@ -3,11 +3,8 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
min-height: 64px;
padding: 12px 24px;
background: var(--bg-panel);
padding: 12px 20px;
}
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
@@ -39,71 +36,9 @@
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
.work-companion-chat .chat-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 36px clamp(20px, 6vw, 96px) 48px;
scroll-padding-block: 36px;
}
.work-companion-chat .chat-body > * {
width: min(100%, 840px);
margin-left: auto;
margin-right: auto;
}
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
.work-companion-chat .chat-body > .msg.user {
width: min(80%, 680px);
margin-right: max(0px, calc((100% - 840px) / 2));
margin-left: auto;
}
.work-companion-chat .chat-body > .msg.assistant {
padding: 20px 24px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--bg-raised);
box-shadow: var(--shadow-md);
}
.work-companion-chat .msg-role {
margin-bottom: 10px;
color: var(--text-dim);
font-size: var(--fs-ui-caption);
font-weight: 600;
}
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
.work-result-notice { cursor: pointer; }
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
.work-execution-record .tl { margin-top: 16px; }
.work-execution-record .part-text { overflow-wrap: anywhere; }
.work-companion-chat .msg.assistant .role-mark {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
.work-companion-chat .input-bar {
padding: 12px clamp(20px, 6vw, 96px) 20px;
border-top: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
.work-companion-chat .input-row {
flex-direction: column;
align-items: stretch;
padding: 14px;
border-color: var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.companion-compose-spacer { flex: 1; }
@@ -112,7 +47,7 @@
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; }
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
.companion-operation-body .companion-setting { margin-bottom: 8px; }
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
@@ -122,63 +57,28 @@
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
.companion-empty { min-height: 320px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
.conversation-work { margin: 24px 0 16px; min-width: 0; }
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work .acard { margin: 0; }
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
.conversation-action-anchor:hover { background: var(--bg-hover); }
.work-companion-chat .live-bar { flex-wrap: wrap; }
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 540px) {
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
.companion-input-hint { display: none; }
.companion-empty { min-height: 200px; }
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
}
.work-companion-chat .msg.user .bubble {
padding: 14px 18px;
background: var(--primary);
border: 1px solid var(--primary);
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
color: var(--primary-foreground);
line-height: 1.7;
box-shadow: var(--shadow-md);
}
.work-companion-chat .process-collapse {
margin: 12px 0 16px;
border-color: var(--border);
background: var(--bg-panel);
}
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
.companion-message-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
opacity: 0;
}
.work-companion-chat .msg:hover .companion-message-actions,
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.work-companion-chat .msg-tools { gap: 4px; }
@media (hover: none) {
.companion-message-actions { opacity: 1; }
}
+22 -117
View File
@@ -9,20 +9,6 @@
.companion-app .left-nav,
.companion-app .main,
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
.companion-app .left-nav,
.companion-app .companion-topbar {
--text: var(--workbench-chrome-text);
--text-dim: var(--workbench-chrome-dim);
--text-faint: var(--workbench-chrome-faint);
--bg-hover: var(--workbench-chrome-hover);
--bg-active: var(--workbench-chrome-active);
--border: var(--workbench-chrome-active);
--border-soft: var(--workbench-chrome-hover);
--border-strong: var(--workbench-chrome-dim);
background: var(--workbench-chrome);
color: var(--workbench-chrome-text);
border-color: var(--workbench-chrome-hover);
}
.companion-app .left-nav { transition: none; }
.companion-app .left-nav[hidden],
.companion-app .right-panel[hidden],
@@ -30,58 +16,12 @@
.companion-app .icon-only[hidden] { display: none !important; }
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
.companion-app .companion-topbar {
height: auto;
min-height: 52px;
gap: 12px;
padding: 8px 16px;
box-shadow: var(--shadow-md);
}
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
.companion-app .right-panel .tabs {
min-height: 52px;
align-items: flex-end;
gap: 16px;
padding: 0 20px;
background: var(--bg-panel);
}
.companion-app .right-panel .tab {
min-height: 52px;
padding: 0 2px;
color: var(--text-dim);
font-size: var(--fs-ui-base);
font-weight: 600;
}
.companion-app .right-panel .tab:hover { color: var(--text); }
.companion-app .right-panel .tab-body { padding: 20px; }
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
.companion-app .right-panel .acard.status-active {
border-left: 3px solid var(--work-focus-border);
background: var(--work-focus-surface);
box-shadow: var(--shadow-md);
}
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
.companion-app .right-panel .acard-status {
display: inline-flex;
width: fit-content;
margin-top: 8px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-active);
color: var(--text);
font-weight: 600;
}
.companion-app .right-panel .acard-body { margin-top: 16px; }
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
.companion-app .right-panel .acard-summary-row > span,
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
.companion-app .right-panel .acard-summary-row p,
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
.companion-topbar .brand-mark {
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
background: var(--primary-sidebar);
color: var(--primary-sidebar-foreground);
box-shadow: none;
border-radius: var(--radius-sm) !important;
}
@@ -97,52 +37,39 @@
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--workbench-chrome-dim);
color: var(--text-sidebar-dim);
font-size: var(--fs-ui-caption);
cursor: pointer;
white-space: nowrap;
}
.shell-nav-button:hover,
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
.shell-nav-button svg,
.shell-return svg { flex: none; }
.shell-back-icon { transform: rotate(180deg); }
.shell-pending {
padding: 0 4px;
border-radius: var(--radius-xs);
background: var(--workbench-chrome-active);
color: var(--workbench-chrome-text);
background: var(--bg-active);
color: var(--text-sidebar);
font-size: var(--fs-ui-xs);
font-variant-numeric: tabular-nums;
}
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
.companion-app .tsb-projrow-name { flex: 1; }
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
.companion-app .tsb-actions { padding: 12px 14px; gap: 10px; }
.companion-app .tsb-newtask {
min-height: 40px;
height: auto;
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
transition: none;
}
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
.companion-app .tsb-scroll { padding: 0 8px 12px; }
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
.companion-app .tsb-orgrow { padding-bottom: 4px; }
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
.companion-app .tsb-task-open {
display: flex;
align-items: center;
@@ -158,7 +85,6 @@
cursor: pointer;
}
.companion-app .tsb-task-open > svg { flex: none; }
.companion-app .tsb-task-t { font-size: var(--fs-ui-base); }
.companion-app .tsb-task-tm { color: var(--text-dim); }
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
@@ -177,32 +103,11 @@
cursor: pointer;
}
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
.companion-app .tsb-bottom { padding: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
.companion-app .tsb-tool { min-height: 36px; }
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
.companion-app .tsb-user-sub { color: var(--text-dim); }
.companion-app .tsb-footer { padding: 10px 12px; }
.companion-app .tsb-user { min-height: 46px; height: auto; }
.companion-app .right-panel .inbox-scope {
gap: 4px;
margin: 14px 20px 0;
padding: 4px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--bg);
}
.companion-app .right-panel .inbox-scope button {
flex: 1;
min-width: 0;
border: none;
border-radius: var(--radius-sm);
padding: 5px 10px;
opacity: 1;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
white-space: nowrap;
}
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
.companion-app .tsb-user { min-height: 44px; height: auto; }
.companion-app .tsb-menu {
min-width: min(216px, calc(100vw - 16px));
max-width: min(360px, calc(100vw - 16px));
@@ -211,7 +116,7 @@
}
.companion-app .tsb-mi { overflow-wrap: anywhere; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px 12px; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
.companion-app.compact .shell-nav-button > svg { display: none; }
+5 -5
View File
@@ -9,13 +9,13 @@ import { humanChangePasswordError } from './LoginPage'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal admin-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal-header">
<b>{title}</b>
<span className="spacer" />
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
<button className="admin-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
</div>
<div className="ad-body">{children}</div>
<div className="admin-body">{children}</div>
</div>
</div>
)
@@ -51,8 +51,8 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
{ok ? <div className="admin-notice">密码已更新,其他设备的会话已注销。</div> : (
<form className="admin-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
<label className="lg-label" htmlFor="changepw-username">用户名</label>
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
+1 -5
View File
@@ -7,8 +7,7 @@
import { useState } from 'react'
import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
@@ -32,10 +31,7 @@ const SECTIONS: AdminSectionDef[] = [
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
+31 -76
View File
@@ -5,16 +5,15 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import { MessageTimestamp } from './MessageTimestamp'
import { loadRunEvents } from '../runEvents'
import '../companion-conversation.css'
function hasFiles(dt: DataTransfer | null): boolean {
@@ -26,7 +25,6 @@ function hasFiles(dt: DataTransfer | null): boolean {
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
switch (part.type) {
case 'text':
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
@@ -48,12 +46,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card': {
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
if (part.card?.cardId) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
@@ -199,8 +197,6 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
const parts = msg.parts ?? []
useLiveClock(!!live)
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
@@ -215,26 +211,6 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
useEffect(() => {
if (!touchedProcess.has(memKey)) setOpen(showProcess)
}, [memKey, showProcess])
const [replayed, setReplayed] = useState<Part[] | null>(null)
const [processError, setProcessError] = useState(false)
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
useEffect(() => {
if (!open) { setReplayed(null); setProcessError(false) }
}, [open])
useEffect(() => {
if (!open || live || !msg.runId || !convId || replayed || processError) return
const controller = new AbortController()
let active = true
void (async () => {
try {
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
} catch {
if (active) setProcessError(true)
}
})()
return () => { active = false; controller.abort() }
}, [open, live, msg.runId, convId, replayed, processError])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
@@ -243,11 +219,10 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
})()
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
const toolCount = processParts.filter(p => p.type === 'tool_call').length
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
const timeline = (
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
@@ -260,21 +235,16 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
{live && <LiveBar live={live} />}
</div>
{(split.timeline.length > 0 || !!msg.runId) && (
{split.timeline.some(part => part.type !== 'thinking') && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
</div>
<div className="body">{timeline}</div>
</Collapsible>
</div>
)}
@@ -454,19 +424,18 @@ function OutboxPanel() {
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>待发送 {outbox.length}</span>}
trigger={<span>排队 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
@@ -474,7 +443,6 @@ function OutboxPanel() {
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
@@ -489,7 +457,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
return (
<div className="msg notice" data-msg-id={msg.id}>
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
trigger={<span className="notice-label">通知 · {preview}</span>}>
<div className="notice-body">{text}</div>
</Collapsible>
</div>
@@ -518,8 +486,6 @@ function ConversationSession() {
const cards = useStore(s => s.cards)
const currentProjectId = useStore(s => s.currentProjectId)
const cardActionStates = useStore(s => s.cardActionStates)
const workReference = useStore(s => s.workReference)
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
@@ -548,9 +514,8 @@ function ConversationSession() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// Background work stays in its own card and does not insert another chat turn.
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
useEffect(() => {
if (!stickRef.current) return
@@ -584,18 +549,12 @@ function ConversationSession() {
const t = text
const ids = attachmentIds
const names = attachmentNames
const reference = workReference ?? undefined
if (reference && ids.length > 0) {
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
return
}
setText(''); setAttachmentIds([]); setAttachmentNames([])
store.clearWorkReference()
if (busy) {
store.enqueueOutbox(t, ids, names, reference)
store.enqueueOutbox(t, ids, names)
return
}
await store.sendMessage(t, ids, undefined, reference)
await store.sendMessage(t, ids)
}
if (!currentConvId) {
@@ -658,6 +617,13 @@ function ConversationSession() {
const stick = dist < 80
stickRef.current = stick
setShowJump(prev => (prev === !stick ? prev : !stick))
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
if (m.role === 'user') {
@@ -667,20 +633,16 @@ function ConversationSession() {
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
<MessageTimestamp value={m.createdAt} />
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{anchored.map(c => workPaneOwnsCards
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
<span>{c.title}</span><span>在工作栏处理 ↗</span>
</button>
: <InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
)}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
))}
</div>
)
}
@@ -712,23 +674,16 @@ function ConversationSession() {
<div className="input-bar">
<OutboxPanel />
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
</button>}
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
</div>}
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台工作完成后更新原卡,用户按需打开。 */}
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
: <span>{bgRunning} 项后台工作</span>}
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}</span>
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
</div>
)}
{attachmentNames.length > 0 && (
+7 -17
View File
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
@@ -13,11 +11,8 @@ export function ConversationWork() {
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const rightTab = useStore(s => s.ui.rightTab)
const record = useStore(s => s.workRecord)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
const { current, required, focused, history } = useMemo(() => {
const current = cards.filter(card => card.status !== 'dismissed'
&& card.conversationId === conversationId && card.projectId === projectId)
@@ -29,27 +24,22 @@ export function ConversationWork() {
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? results[0]
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
?? remaining.find(card => card.status === 'done') ?? remaining[0]
return { current, required, focused, history: remaining.filter(card => card !== focused) }
}, [cards, messages, conversationId, projectId])
if (!detailHere) return null
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
if (current.length === 0) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
const callbacks = {
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
onReference: (card: ActivityCard) => store.referenceWork(card),
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
actionState: (card: ActivityCard) => actionStates?.[card.id],
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
-98
View File
@@ -1,98 +0,0 @@
import { useEffect, useState } from 'react'
import { api, type ExecEnvView } from '../api'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
export function ExecEnvPanel() {
const [view, setView] = useState<ExecEnvView | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [conv, setConv] = useState('')
const [reason, setReason] = useState('')
const [wheel, setWheel] = useState<File | null>(null)
const refresh = async () => {
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const run = async (work: () => Promise<string>) => {
setErr(''); setNotice(''); setBusy(true)
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
return (
<div>
<SettingsGroup title="当前环境">
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvRebase()
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
})}>重建基础层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="申请">
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
{(view.requests ?? []).map(req => (
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
{req.status === 'pending_approval' && (
<span className="ad-row">
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, false)
return out.detail || out.status
})}>批准</Btn>
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, true)
return out.detail || '已用隔离层'
})}>隔离层</Btn>
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, false, false)
return out.detail || '已拒绝'
})}>拒绝</Btn>
</span>
)}
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="会话绑定">
{(view.bindings ?? []).map(b => (
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
<span />
</SettingRow>
))}
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvGC()
const n = out.removed?.length ?? 0
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
})}>回收未引用层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="上传 wheel">
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
</SettingRow>
<SettingRow label="理由" desc="写给审批记录">
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
</SettingRow>
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
</SettingRow>
<div className="ad-row">
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
return `${out.file}:${out.outcome.status}`
})}>提交审批</Btn>
</div>
</SettingsGroup>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
+52 -63
View File
@@ -1,7 +1,8 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
// 登录与改密(管理员发放制,R2):
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
@@ -27,11 +28,9 @@ function BrandPanel() {
)
}
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
const [tab, setTab] = useState<'password' | 'apikey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
@@ -42,9 +41,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
// 受限会话先进设密门,完成前不放行。
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
return
}
@@ -56,16 +56,24 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
const submitKey = async () => {
setErr(''); setBusy(true)
auth.setKey(key)
try {
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
const me = await api.redeemLoginKey(key.trim())
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
const r = await auth.fetch('/api/health')
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
onAuthed()
} catch (e) {
setErr(humanRedeemError(String(e)))
throw new Error(String(r.status))
} catch {
auth.clear()
setErr('密钥无效或已撤销,请重试')
} finally { setBusy(false) }
}
@@ -76,10 +84,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
</div>
{tab === 'password' ? (
<>
@@ -92,11 +100,11 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
</>
) : (
<>
<label className="lg-label">一次性登录 Key</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<label className="lg-label">API 访问密钥</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code>。程序化客户端也可直接带 Authorization 头。</p>
</>
)}
</form>
@@ -105,15 +113,13 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
)
}
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const submit = async () => {
setErr('')
@@ -121,8 +127,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword('', next)
setDone(true)
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
@@ -132,27 +138,20 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
{done ? (
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">密码已更新</h2>
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
<button className="lg-submit" type="submit">返回登录</button>
</form>
) : (
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">修改密码</h2>
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
<label className="lg-label">当前密码(初始/重置密码)</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
</form>
</div>
</div>
)
@@ -166,23 +165,13 @@ export function humanLoginError(raw: string): string {
return raw.replace(/^\d{3}:\s*/, '')
}
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
-8
View File
@@ -1,8 +0,0 @@
/** Stable server time, with the full local date available to keyboard and assistive readers. */
export function MessageTimestamp({ value }: { value?: string }) {
const date = value ? new Date(value) : null
if (!date || !Number.isFinite(date.getTime())) return null
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
}
+3 -12
View File
@@ -1,6 +1,6 @@
// 执行记录按工作动作分组;展开状态由调用方控制。
import type { ReactNode } from 'react'
import { Collapsible, ToolBlock } from '@ui'
import { ToolBlock } from '@ui'
import type { Part } from '../contracts'
import { formatDuration, humanSize } from '../messageparts'
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
@@ -15,12 +15,8 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
return (
<div className="tl">
{rows.map((row, i) => {
if (row.kind === 'thinking') {
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
<span className="tl-thinking-label">思考过程</span>
<div className="tl-thinking-body">{row.text}</div>
</div>
}
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
if (row.kind === 'thinking') return null
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
@@ -42,11 +38,6 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
<span className="tl-tools-name">{it.headline}</span>
{it.status === 'error' && <span className="status-error">失败</span>}
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
{it.output && <pre className="tool-preview">{it.output}</pre>}
</Collapsible>}
</li>
))}
</ul>
+12 -32
View File
@@ -8,8 +8,6 @@ import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
import { rdmProjectReadError } from '../rdmManagement'
import { projectActionError, projectWorkError } from '../projectmeta'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
@@ -18,31 +16,21 @@ export function ProjectSettingsModal() {
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
const owner = useStore(s => s.me?.owner)
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
// 授权的职责管理者仍可进入。
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
const [tab, setTab] = useState('project')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!projectId) return
let active = true
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
setLoaded(false)
api.getSettings(projectId).then(s => {
if (!active) return
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
return () => { active = false }
}, [projectId, owner])
}).catch(() => undefined)
}, [projectId])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
@@ -51,19 +39,15 @@ export function ProjectSettingsModal() {
}, [schema])
if (!projectId) return null
const rdmError = rdmProjectReadError(project)
const writeError = projectWorkError(project)
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
const save = async () => {
if (!projectId) return
if (writeError) { store.toast(writeError); return }
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${projectActionError(e)}`)
store.toast(`项目设置保存失败:${e}`)
}
}
@@ -77,25 +61,21 @@ export function ProjectSettingsModal() {
{ key: 'project', label: '本项目', icon: 'folder' },
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
</>}
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
<EmptyState icon="settings">加载中…</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
@@ -111,7 +91,7 @@ export function ProjectSettingsModal() {
))}
</div>
))}
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
) : null}
</div>
+88 -213
View File
@@ -5,8 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
@@ -19,7 +17,7 @@ interface TreeItem { kind: string; id: string; title: string; state?: string }
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; parentId?: string; childCount?: number; sliceCount?: number; mounts?: string[]; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
// ---- 工作区主组件 ----
@@ -47,30 +45,22 @@ function RdmErrorState({ raw }: { raw: string }) {
}
export function RdmAuditWorkspace() {
const identity = useStore(() => store.getIdentityEpoch())
const [caps, setCaps] = useState<Capabilities | null>(null)
const [capsIdentity, setCapsIdentity] = useState(-1)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setCaps(null); setPid(''); setError('')
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => {
if (cancelled || identity !== store.getIdentityEpoch()) return
setCaps(c); setCapsIdentity(identity)
if (c.projects.length > 0) setPid(c.projects[0].projectId)
})
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
return () => { cancelled = true }
}, [identity])
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable)
if (error) return <RdmErrorState raw={error} />
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
@@ -92,7 +82,7 @@ export function RdmAuditWorkspace() {
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
@@ -103,7 +93,7 @@ export function RdmAuditWorkspace() {
// ---- 概览 ----
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
function SummaryTab({ pid }: { pid: string }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -143,131 +133,6 @@ function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
{exportOn && <ExportSection pid={pid} />}
</div>
)
}
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
interface ExportCreated {
exportId: string
fileName: string
watermark: number
sha256: string
draft?: boolean
}
interface ExportStatus {
expiresAt: string
sha256: string
watermark: number
}
interface ExportDownloaded {
fileName: string
mime: string
dataB64: string
sha256: string
}
function saveExportFile(fileName: string, mime: string, dataB64: string) {
const bin = atob(dataB64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
function ExportSection({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const generation = useRef(0)
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [needDraft, setNeedDraft] = useState(false)
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
useEffect(() => {
generation.current++
setArtifact(null)
setErr('')
setNeedDraft(false)
setBusy(false)
return () => { generation.current++ }
}, [pid, identity])
const generate = async (draft: boolean) => {
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
setNeedDraft(false)
try {
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
if (!current()) return
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
if (!current()) return
setArtifact({ ...created, expiresAt: st.expiresAt })
} catch (e) {
if (!current()) return
const msg = e instanceof Error ? e.message : String(e)
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
else setErr(msg)
} finally {
if (current()) setBusy(false)
}
}
const download = async () => {
if (!artifact) return
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
try {
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
if (!current()) return
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
if (current()) setBusy(false)
}
}
return (
<div className="rdm-audit-export">
<SectionLabel>导出</SectionLabel>
<div className="rdm-audit-export-row">
<label className="rdm-audit-export-kind">
类型
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
<option value="cgraph-docx">合规追溯报告</option>
<option value="cgraph-matrix">追溯矩阵</option>
</select>
</label>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
</div>
{needDraft && (
<div className="rdm-audit-export-draft">
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
</div>
)}
{err && <div className="rdm-audit-export-err">{err}</div>}
{artifact && (
<div className="rdm-audit-export-result">
<div>水位 {artifact.watermark}</div>
<div>sha256 {artifact.sha256}</div>
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
{artifact.draft && <div>草稿</div>}
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
</div>
)}
</div>
)
}
@@ -283,102 +148,107 @@ function MaterialsTab({ pid }: { pid: string }) {
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
</div>
<TreeList pid={pid} kind={kind} />
<TreeList key={`${pid}:${kind}`} pid={pid} kind={kind} />
</div>
</div>
)
}
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
const [detailError, setDetailError] = useState('')
const genRef = useRef(0)
useEffect(() => {
useEffect(() => () => { genRef.current++ }, [])
const selectNode = useCallback((item: TreeItem) => {
const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, kind])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleDoc = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
if (item.kind === 'doc') { toggleDoc(item); return }
setSelected(item); setDetail(null)
const gen = genRef.current
setSelected(item); setDetail(null); setDetailError('')
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (gen === genRef.current) setDetail(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
if (err) return <RdmErrorState raw={err} />
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
.catch(e => { if (gen === genRef.current) setDetailError(e.message) })
}, [pid])
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
{children[item.id].map(sub => (
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
onClick={() => selectNode(sub)}>
<span className="rdm-audit-tree-kind">{sub.kind}</span>
<span className="rdm-audit-tree-title">{sub.title}</span>
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
</button>
))}
</div>
)}
</div>
))}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
<TreeLevel pid={pid} kind={kind} parent="" selected={selected} onSelect={selectNode} />
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
{detailError ? <>
<RdmErrorState raw={detailError} />
<button className="btn v-ghost" onClick={() => selectNode(selected)}>重试详情</button>
</> : detail ? <ObjectDetailView detail={detail} /> : <div className="empty-note">加载中…</div>}
</div>
)}
</div>
)
}
interface TreeLevelProps { pid: string; kind: 'notes' | 'docs'; parent: string; selected: TreeItem | null; onSelect: (item: TreeItem) => void }
function TreeLevel({ pid, kind, parent, selected, onSelect }: TreeLevelProps) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const genRef = useRef(0)
const cursorRef = useRef('')
const load = useCallback((cursor = '') => {
const gen = ++genRef.current
cursorRef.current = cursor
setLoading(true); setError('')
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent, cursor })
.then(next => {
if (gen !== genRef.current) return
setShell(prev => cursor && prev ? { ...next, items: [...prev.items, ...next.items] } : next)
})
.catch(e => { if (gen === genRef.current) setError(e.message) })
.finally(() => { if (gen === genRef.current) setLoading(false) })
}, [pid, kind, parent])
useEffect(() => {
load()
return () => { genRef.current++ }
}, [load])
return (
<div className="rdm-audit-tree-list">
{shell?.items.map(item => <TreeNode key={`${item.kind}:${item.id}`} item={item}
pid={pid} kind={kind} selected={selected} onSelect={onSelect} />)}
{!shell && loading && <div className="empty-note">加载中…</div>}
{shell?.items.length === 0 && <div className="empty-note">{parent ? kind === 'notes' ? '无子骨架或挂载切片' : '无章节' : kind === 'notes' ? '暂无骨架节点或切片' : '暂无文档'}</div>}
{error && <>
<RdmErrorState raw={error} />
<button className="btn v-ghost" disabled={loading} onClick={() => load(cursorRef.current)}>重试列表</button>
</>}
{shell?.hasMore && !error && <button className="btn v-ghost rdm-audit-more" disabled={loading}
onClick={() => load(shell.nextCursor)}>加载更多</button>}
{shell?.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)
}
function TreeNode({ item, pid, kind, selected, onSelect }: Omit<TreeLevelProps, 'parent'> & { item: TreeItem }) {
const [open, setOpen] = useState(false)
const branch = item.kind === 'doc' || item.kind === 'spine'
const className = `rdm-audit-tree-node${selected?.kind === item.kind && selected.id === item.id ? ' sel' : ''}`
const label = <>
{branch && <span className="rdm-audit-tree-caret">{open ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</>
if (!branch) return <button className={className} onClick={() => onSelect(item)}>{label}</button>
return <Collapsible open={open} trigger={label} triggerClassName={className}
bodyClassName="rdm-audit-tree-children" onToggle={next => {
setOpen(next)
if (item.kind === 'spine') onSelect(item)
}}>
<TreeLevel pid={pid} kind={kind} parent={item.id} selected={selected} onSelect={onSelect} />
</Collapsible>
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
@@ -605,6 +475,11 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || detail.id}</strong></div>
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
{detail.kind === 'spine' && <>
<div>父骨架:{detail.parentId || '根节点'}</div>
<div>子骨架 {detail.childCount ?? 0} · 挂载切片 {detail.sliceCount ?? 0}</div>
</>}
{!!detail.mounts?.length && <div>挂载骨架:{detail.mounts.join('、')}</div>}
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
{detail.versions && detail.versions.length > 0 && (
+8 -13
View File
@@ -7,7 +7,7 @@ import { api } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import { projectStage } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -68,10 +68,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const [newProjectName, setNewProjectName] = useState('')
const createConversation = async (projectId?: string) => {
try {
await store.createTask(projectId)
onConversationOpen?.()
} catch { /* store owns the actionable failure toast */ }
await store.createTask(projectId)
onConversationOpen?.()
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
@@ -79,8 +77,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
const workError = currentProject ? projectWorkError(currentProject) : null
const stage = projectStage(currentProject?.settings)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -165,10 +162,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
try {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
} catch { /* keep the input available for a corrected name */ }
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
}
if (e.key === 'Escape') setCreatingProject(false)
}}
@@ -184,7 +179,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -229,7 +224,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
const pstage = projectStage(p.settings)
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
+2 -2
View File
@@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<Icon name="message" size={15} /><span>对话</span>
</button>
</>}
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
<button className="shell-nav-button" aria-label="打开工作"
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
<Icon name="inbox" size={15} /><span>工作</span>
@@ -45,7 +45,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<button className="shell-nav-button" aria-label="打开文件"
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
<Icon name="folder" size={15} /><span>文件</span>
</button></>}
</button>
</nav>
</header>
)
-50
View File
@@ -1,50 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { SafeMarkdown } from '@ui'
import type { StreamEvent } from '../contracts'
import { loadRunEvents } from '../runEvents'
import { store, useStore } from '../store'
import { runEventsToTimeline } from '../live'
import { mergeProcessTimeline } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { MessageTimestamp } from './MessageTimestamp'
/** The work pane and inline fallback share one selection. Only this detail host
* reads that run's authorized durable events; opening a record never adds chat. */
export function WorkExecutionRecord() {
const selected = useStore(s => s.workRecord)
const conversationId = useStore(s => s.currentConvId)
const messages = useStore(s => s.messages)
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!selected || selected.conversationId !== conversationId) return
const controller = new AbortController()
let active = true
setResult(null); setFailed(false)
void (async () => {
try {
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
if (active) setResult({ runId: selected.runId, events })
} catch { if (active) setFailed(true) }
})()
return () => { active = false; controller.abort() }
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
const parts = useMemo(() => {
if (!selected || result?.runId !== selected.runId) return []
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
}, [messages, result, selected?.runId])
if (!selected || selected.conversationId !== conversationId) return null
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
<MessageTimestamp value={run?.createdAt} />
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
: !result ? <p role="status">正在读取已保存的记录…</p>
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
</section>
}
+4 -11
View File
@@ -2,14 +2,13 @@
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run, WorkCardAction } from '../contracts'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
@@ -22,9 +21,7 @@ export const docBridge: DocBridge = {
}
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
@@ -67,7 +64,6 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -75,7 +71,7 @@ export function Workbench() {
<>
<div className="tabs">
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
@@ -112,12 +108,10 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-work-scroll>
{record ? <WorkExecutionRecord /> : <>
<div className="tab-body" data-scroll-sync="inbox">
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
@@ -132,7 +126,6 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
</>
)
+202 -289
View File
@@ -1,19 +1,18 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback } from 'react'
// 既有 admin-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
import { Btn, EmptyState, Icon } from '@ui'
import {
ROLE_ACTIONS,
ROLE_ACTIONS, parseReviewers,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
memberAddPayload, memberRemovePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
@@ -39,9 +38,11 @@ export function RdmRolesPanel() {
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(true)
const [editCap, setEditCap] = useState(false)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const sel = roles?.find(role => role.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
const refresh = async () => {
try {
@@ -51,6 +52,11 @@ export function RdmRolesPanel() {
}
useEffect(() => { refresh() }, [])
useEffect(() => {
setEditDef(editCurrent?.[0] ?? false)
setEditCap(editCurrent?.[1] ?? false)
}, [roles, editRole, editAction])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
@@ -63,136 +69,99 @@ export function RdmRolesPanel() {
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
return (
<div>
<div className="ad-create">
<div className="ad-row">
<div className="admin-create">
<div className="admin-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
<button className="lg-submit admin-create-btn" disabled={busy || !newName.trim()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
<p className="admin-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="ad-table">
<table className="admin-table">
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
<td className="ad-dim">v{r.version}</td>
<td><span className="admin-mono">{r.name}</span><br /><span className="admin-dim">{r.roleId}</span></td>
<td>{r.disabled ? <span className="admin-chip admin-chip-warn">已停用</span> : <span className="admin-chip">生效</span>}</td>
<td className="admin-dim">v{r.version}</td>
<td>{Object.keys(r.actions ?? {}).length} 项</td>
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="ad-mini" disabled={busy}
<td className="admin-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="admin-mini" disabled={busy}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="admin-dim">{placeholder}</td></tr>}
</tbody>
</table>
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
<div className="ad-row">
<div className="admin-row">
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => {
setEditAction(e.target.value)
const cur = sel?.actions?.[e.target.value]
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
}}>
<select className="lg-input" value={editAction} onChange={e => setEditAction(e.target.value)}>
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<label className="ad-check"><input type="checkbox" checked={editDef}
<label className="admin-check"><input type="checkbox" checked={editDef}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="ad-check"><input type="checkbox" checked={editCap}
<label className="admin-check"><input type="checkbox" checked={editCap}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
<button className="lg-submit admin-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
</div>
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
{sel && editCurrent && <p className="admin-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="ad-row">
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
onChange={e => setAssignOwner(e.target.value)} />
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
<button className="lg-submit admin-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
// ---- 项目设置:成员 ----
interface MemberRow {
owner: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
}
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
export function RdmMembersSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
export function RdmMembersSection({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [member, setMember] = useState('')
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
rowsRef.current = rows
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
'rdm.account.list', { projectId, cursor, limit, query })
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
id: a.owner,
title: a.name || a.owner,
subtitle: a.owner,
badge: joined.has(a.owner) ? '已加入' : undefined,
}))
if (query !== '' && cursor === '') {
const q = query.toLowerCase()
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
}
return { items, nextCursor: out.next || undefined }
}, [project])
const refresh = async () => {
try {
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
setRows(out.members ?? [])
} catch (e) { setErr(String(e)) }
}
useEffect(() => { setPicked([]); refresh() }, [project])
useEffect(() => { refresh() }, [projectId])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
@@ -200,276 +169,220 @@ export function RdmMembersSection({ project }: { project: Project }) {
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const pickedId = picked[0] ?? ''
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={setPicked}
fetchPage={fetchPage}
placeholder="输入账号或姓名,或浏览候选…"
/>
<div className="admin-create">
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
onChange={e => setMember(e.target.value)} />
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
<option value="member">member(成员)</option>
<option value="owner">owner(负责人)</option>
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
onClick={() => run(`已添加 ${pickedId}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
setPicked([])
<button className="lg-submit admin-create-btn" disabled={busy || !member.trim()}
onClick={() => run(`已添加 ${member.trim()}`, async () => {
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
setMember('')
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
<p className="admin-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
</div>
<table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<table className="admin-table">
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
<td className="ad-dim">{m.profRole?.name || '—'}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td>
<button className="ad-mini" disabled={busy || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>{' '}
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</td>
<td className="admin-mono">{m.owner}</td>
<td>{m.role === 'owner' ? <span className="admin-chip admin-chip-admin">OWNER</span> : <span className="admin-chip">MEMBER</span>}</td>
<td className="admin-dim">{m.addedBy || '—'}</td>
<td className="admin-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td><button className="admin-mini" disabled={busy}
onClick={() => run(`已移除 ${m.owner}`,
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="admin-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={5} className="admin-dim">加载中…</td></tr>}
</tbody>
</table>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
export { RdmDutiesSection } from './duties/RdmDutiesSection'
// ---- 项目设置:文档职责 · 整文审签 ----
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
interface MatrixAction {
action: string; label?: string; scope?: string
allowed: boolean; source: string; reason?: string
interface DocSubmissionView {
round: number
state: string
digest: string
createdBy: string
createdAt: string
duties: { author: string; reviewers: string[]; approver: string }
reviews: Record<string, boolean>
sections: Record<string, number>
}
export function RdmDutiesSection({ projectId }: { projectId: string }) {
const [docId, setDocId] = useState('')
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [author, setAuthor] = useState('')
const [reviewers, setReviewers] = useState('')
const [approver, setApprover] = useState('')
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
const refreshStatus = async (id: string) => {
if (!id) return
try {
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
setSub(out.submission)
} catch (e) { setErr(String(e)); setSub(undefined) }
}
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refreshStatus(docId) }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="admin-create">
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
</div>
</div>
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
{sub && (
<div className="settings-group">
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="admin-chip admin-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="admin-chip admin-chip-admin">已终批</span> : <span className="admin-chip">已驳回</span>}</h4>
<div className="setting-row"><label>三权</label><span>编制 <b className="admin-mono">{sub.duties.author}</b> · 审核 <b className="admin-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="admin-mono">{sub.duties.approver}</b></span></div>
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
<div className="setting-row"><label>冻结摘要</label><span className="admin-mono admin-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
<div className="admin-row" style={{ marginTop: 8 }}>
<button className="lg-submit admin-create-btn" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('整文终批完成(各章节已基线)',
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已通过整文审核',
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已驳回整文(解锁回编写态)',
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
</div>
</div>
)}
{sub === undefined && docId && <p className="admin-hint">输入文档 ID 后查看整文状态。</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
onChange={e => setAuthor(e.target.value)} />
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
onChange={e => setReviewers(e.target.value)} />
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
onChange={e => setApprover(e.target.value)} />
</div>
<div className="admin-row">
<button className="lg-submit admin-create-btn"
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
<button className="admin-mini" disabled={busy || !docId}
onClick={() => run('整文已提交审核(全文锁定)',
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
</div>
<p className="admin-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
interface MatrixOut {
projectId: string; member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixRowOut {
member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
function actionTitle(a: MatrixAction) { return a.label || a.action }
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
function ActionDetailTable({ actions, onOverride, canManage }: {
actions: MatrixAction[]
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
canManage?: boolean
}) {
const project = actions.filter(a => a.scope !== 'system')
const system = actions.filter(a => a.scope === 'system')
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
return (
<>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
<tbody>
{project.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
<option value="default">沿用角色默认</option>
<option value="allow">项目内允许</option>
<option value="deny">项目内禁止</option>
</select>
</td>
) : null}
</tr>
))}
</tbody>
</table>
{system.length > 0 && (
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
<tbody>
{system.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</Collapsible>
)}
</>
)
}
export function RdmPermMatrixSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
const me = useStore(s => s.me)
const [view, setView] = useState<'member' | 'matrix'>('member')
const [member, setMember] = useState('')
const [members, setMembers] = useState<MemberRow[] | null>(null)
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
const [expand, setExpand] = useState('')
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const loadMembers = () =>
cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
useEffect(() => {
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
.then(out => {
setMembers(out.members ?? [])
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
if (me?.owner) setMember(prev => prev || me.owner)
}).catch(e => setErr(String(e)))
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
})
.catch(e => setErr(String(e)))
}, [projectId, me?.owner])
const query = async (m: string) => {
if (!m) return
setErr(''); setBusy(true); setMatrix(null)
try {
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const queryBatch = async () => {
setErr(''); setBusy(true); setBatch(null)
try {
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
if (writeError) { setErr(writeError); return }
setErr(''); setNotice(''); setBusy(true)
try {
if (mode === 'default') {
await cmd('rdm.perm.unset', { projectId, member: m, action })
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
} else {
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
}
await query(m)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const canManage = !writeError && !!matrix && matrix.role !== undefined
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
<option value="member">按成员查看(默认自省)</option>
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
<div className="admin-create">
<div className="admin-row">
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员(默认自省)…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
{view === 'member' && (
<>
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看</Btn>
</>
)}
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
</div>
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。</p>
<p className="admin-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
</div>
{view === 'member' && matrix && (
{matrix && (
<>
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
<div className="admin-row">
<span>成员 <b className="admin-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
</span>
</div>
<ActionDetailTable actions={matrix.actions ?? []}
canManage={canManage}
onOverride={(action, mode) => override(matrix.member, action, mode)} />
<table className="admin-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
<tbody>
{(matrix.actions ?? []).map(a => (
<tr key={a.action}>
<td className="admin-mono">{a.action}</td>
<td>{a.allowed ? <span className="admin-chip admin-chip-admin">允许</span> : <span className="admin-chip admin-chip-warn">拒绝</span>}</td>
<td className="admin-dim">{describeSource(a.source)}</td>
<td className="admin-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
{view === 'matrix' && batch && (
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
<thead><tr>
<th>成员</th>
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
))}
</tr></thead>
<tbody>
{batch.rows.map(row => (
<tr key={row.member}>
<td>
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
{row.member}
</button>
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
</td>
{row.actions.filter(a => a.scope !== 'system').map(a => (
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
{view === 'matrix' && batch && expand && (
<div className="settings-group" style={{ marginTop: 12 }}>
<h4>{expand} · 单成员详情与三态覆盖</h4>
{(() => {
const row = batch.rows.find(r => r.member === expand)
if (!row) return null
return <ActionDetailTable actions={row.actions} canManage={!writeError}
onOverride={(action, mode) => override(expand, action, mode)} />
})()}
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{!matrix && !err && members !== null && <p className="admin-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
+34 -160
View File
@@ -6,22 +6,11 @@ import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
function keyStatusChip(status: string) {
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
if (status === 'consumed') return <span className="ad-chip">已使用</span>
if (status === 'expired') return <span className="ad-chip">已过期</span>
return <span className="ad-chip">已撤销</span>
}
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -33,24 +22,14 @@ export function UsersPanel() {
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
const [keyFor, setKeyFor] = useState<string | null>(null)
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshKeys = async (owner: string) => {
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const openKeyPanel = async (owner: string) => {
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
await refreshKeys(owner)
}
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
@@ -63,97 +42,63 @@ export function UsersPanel() {
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const issue = async () => {
if (!keyFor) return
setErr(''); setNotice(''); setBusy(true)
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
const out = await api.adminIssueLoginKey(keyFor)
setIssued({ key: out.key, expiresAt: out.expiresAt })
await refreshKeys(keyFor)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const copyKey = async () => {
if (!issued) return
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
}
const revoke = async (id: string) => {
if (!keyFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeLoginKey(keyFor, id)
await refreshKeys(keyFor)
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<div className="admin-create">
<div className="admin-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="admin-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit admin-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
<p className="admin-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{keyFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
</div>
{issued && (
<div className="ad-row">
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
</div>
)}
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
{keys !== null && keys.length > 0 && (
<table className="ad-table">
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
<td>{keyStatusChip(k.status)}</td>
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
</tr>
))}
</tbody>
</table>
)}
{resetFor ? (
<div className="admin-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit admin-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="admin-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="ad-table">
<table className="admin-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td className="admin-user-cell"><span className="admin-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{u.isAdmin ? <span className="admin-chip admin-chip-admin">ADMIN</span> : <span className="admin-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="admin-chip admin-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="admin-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
<button className="admin-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
@@ -295,74 +240,3 @@ export function PluginWorkspacesPanel() {
</div>
)
}
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
/**
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
* 非受管项属于用户/项目偏好,不在本面板出现。
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
* 原样传回,由服务端视为未修改。
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
*/
export function SystemSettingsPanel() {
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [forbidden, setForbidden] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = async () => {
setErr(''); setForbidden(false)
try {
const out = await api.getSystemSettings()
setSchema(out.schema.filter(d => d.scope === 'system'))
setDraft(flatten(out.system))
setLoaded(true)
} catch (e) {
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
else setErr(String(e))
}
}
useEffect(() => { load() }, [])
const groups: Record<string, SettingDesc[]> = {}
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
const save = async () => {
setErr(''); setNotice(''); setBusy(true)
try {
await api.putSystemSettings(unflatten(draft))
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
await load()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
return (
<div>
<p className="ad-hint" style={{ marginBottom: 14 }}>
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
</p>
{Object.entries(groups).map(([group, descs]) => (
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
{descs.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
))}
<div className="ad-row" style={{ marginTop: 12 }}>
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
-253
View File
@@ -1,253 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../../api'
import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
import {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
interface MatrixDoc {
docId: string
title: string
code?: string
typeKey: string
stage?: string
dutyVersion: number
activeRound?: number
legacySectionOverrides?: number
}
interface MatrixOut {
canAssign: boolean
docs: MatrixDoc[]
members: DutyMember[]
cells: { docId: string; owner: string; duty: string }[]
nextDocCursor?: string
nextMemberCursor?: string
}
interface PreviewOut {
ok: boolean
docs: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
}
interface LegacyOut {
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
}
export function RdmDutiesSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const [view, setView] = useState<ViewName>('member')
const [docs, setDocs] = useState<DutyDocDraft[]>([])
const [members, setMembers] = useState<DutyMember[]>([])
const [member, setMember] = useState('')
const [docId, setDocId] = useState('')
const [query, setQuery] = useState('')
const [stage, setStage] = useState('')
const [typeKey, setTypeKey] = useState('')
const [assignment, setAssignment] = useState('')
const [docCursor, setDocCursor] = useState('')
const [nextDoc, setNextDoc] = useState('')
const [canAssign, setCanAssign] = useState(false)
const [narrow, setNarrow] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState<PreviewOut['docs']>([])
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
docsRef.current = docs
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
const seenMembers: DutyMember[] = []
const seenCells: MatrixOut['cells'] = []
let pageDocs: MatrixDoc[] = []
let memberCursor = ''
let nextDocument = ''
let assignable = false
try {
do {
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
}))
if (request !== seq.current) return
if (pageDocs.length === 0) pageDocs = page.docs ?? []
seenMembers.push(...(page.members ?? []))
seenCells.push(...(page.cells ?? []))
assignable = page.canAssign
nextDocument = page.nextDocCursor ?? ''
memberCursor = page.nextMemberCursor ?? ''
} while (memberCursor)
if (request !== seq.current) return
setMembers(seenMembers)
setCanAssign(assignable)
setNextDoc(nextDocument)
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
useEffect(() => {
const media = window.matchMedia('(max-width: 720px)')
const apply = () => setNarrow(media.matches)
apply()
media.addEventListener('change', apply)
return () => media.removeEventListener('change', apply)
}, [])
useEffect(() => {
void load(docCursor)
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false
if (stage && doc.stage !== stage) return false
if (typeKey && doc.typeKey !== typeKey) return false
return true
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
}
const request = ++seq.current
const ticket = ++op.current
setBusy(true); setErr(''); setNotice('')
let sent = false
try {
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
if (request !== seq.current) return
setPreview(looked.docs ?? [])
if (!looked.ok) {
setErr('预览未通过,未写入')
return
}
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
setErr('在途审签将失效。确认后再保存。')
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
setConfirm(false)
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
if (!sent) {
setErr(String(error))
return
}
setErr(`保存结果未确认:${error}`)
setConfirm(false)
await load(docCursor, request, true)
} finally {
if (ticket === op.current) setBusy(false)
}
}
const scanLegacy = async (request = ++seq.current) => {
try {
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setLegacy(out.findings ?? [])
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
const applyIdentical = async () => {
const request = ++seq.current
const ticket = ++op.current
setBusy(true)
try {
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setNotice('已归并无歧义的章节职责')
await scanLegacy(request)
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
} finally {
if (ticket === op.current) setBusy(false)
}
}
const disabled = busy || !!writeError || !canAssign
const ambiguous = legacy.filter(item => item.kind !== 'identical')
return (
<div>
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-row" role="tablist" aria-label="职责视图">
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
</div>
<div className="ad-row">
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<option value="">全部</option>
<option value="assigned">已分配</option>
<option value="unassigned">未分配</option>
</select>
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
</div>
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
<ul>
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
</ul>
)}
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
</Collapsible>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
</div>
)
}
+1 -17
View File
@@ -151,21 +151,7 @@ export interface ActivityCard {
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
export interface WorkCard {
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
decision?: WorkDecision;
}
export interface WorkDecision {
runId?: ID;
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
options?: { id: string; label: string }[];
selectedIds?: string[]; freeText?: string; respondedAt?: string;
}
export interface WorkCardAction {
resultVersion: number; selectedIds?: string[]; freeText?: string;
pinned?: boolean; createdAt: string; updatedAt: string;
}
export interface CardPayload { card: ActivityCard }
@@ -284,8 +270,6 @@ export interface SettingDesc {
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
scope?: 'system';
}
// ---- 插件 ----
-197
View File
@@ -1,197 +0,0 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
author: string
reviewers: string[]
approver: string
}
export interface DutyDocDraft extends DutyRoles {
docId: string
title: string
code: string
typeKey: string
stage: string
dutyVersion: number
activeRound: number
legacySectionOverrides: number
dirty: boolean
}
export interface DutyMember {
owner: string
role: string
valid: boolean
canAuthor: boolean
canReview: boolean
canApprove: boolean
}
interface DutySaveItem {
docId: string
author: string
reviewers: string[]
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
if (roles.author === owner) return 'author'
if (roles.approver === owner) return 'approver'
if (roles.reviewers.includes(owner)) return 'reviewer'
return 'none'
}
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
roles: DutyRoles
replacedAuthor: string
replacedApprover: string
} {
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
let author = roles.author === owner ? '' : roles.author
let approver = roles.approver === owner ? '' : roles.approver
let reviewers = roles.reviewers.filter(person => person !== owner)
switch (duty) {
case 'author':
author = owner
break
case 'approver':
approver = owner
break
case 'reviewer':
reviewers = [...reviewers, owner]
break
case 'none':
break
default: {
const exhaustive: never = duty
throw new Error(exhaustive)
}
}
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
}
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
return ''
}
function dutyConflicts(roles: DutyRoles): number {
const seen = new Map<string, number>()
const mark = (owner: string) => {
if (!owner) return
seen.set(owner, (seen.get(owner) ?? 0) + 1)
}
mark(roles.author)
mark(roles.approver)
for (const reviewer of roles.reviewers) mark(reviewer)
let conflicts = 0
for (const count of seen.values()) if (count > 1) conflicts += 1
return conflicts
}
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
return {
authors: roles.author ? 1 : 0,
reviewers: roles.reviewers.length,
approvers: roles.approver ? 1 : 0,
conflicts: dutyConflicts(roles),
}
}
export function saveIssues(roles: DutyRoles): string[] {
const issues: string[] = []
if (!roles.author) issues.push('需指定编制人')
if (roles.reviewers.length === 0) issues.push('审核至少一人')
if (!roles.approver) issues.push('需指定批准人')
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
return issues
}
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
for (const cell of cells) {
if (cell.docId !== docId) continue
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
const next = applyCell(roles, cell.owner, cell.duty)
roles.author = next.roles.author
roles.reviewers = next.roles.reviewers
roles.approver = next.roles.approver
}
return roles
}
export function absorbServerDocs(
current: DutyDocDraft[],
docs: {
docId: string; title: string; code?: string; typeKey: string; stage?: string
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
}[],
cells: { docId: string; owner: string; duty: string }[],
replaceDirty = false,
): DutyDocDraft[] {
const byId = new Map(current.map(doc => [doc.docId, doc]))
const pageIds = new Set(docs.map(doc => doc.docId))
const next: DutyDocDraft[] = []
for (const doc of docs) {
const previous = byId.get(doc.docId)
const serverRoles = rolesFromCells(doc.docId, cells)
const keepDraft = !replaceDirty && previous?.dirty === true
next.push({
docId: doc.docId,
title: doc.title,
code: doc.code ?? doc.docId,
typeKey: doc.typeKey,
stage: doc.stage ?? '',
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
activeRound: doc.activeRound ?? 0,
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
dirty: keepDraft,
author: keepDraft ? previous.author : serverRoles.author,
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
approver: keepDraft ? previous.approver : serverRoles.approver,
})
}
for (const doc of current) {
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
}
return next
}
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
return docs.map(doc => {
if (doc.docId !== docId) return doc
const next = applyCell(doc, owner, duty)
return { ...doc, ...next.roles, dirty: true }
})
}
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
for (const doc of dirty) {
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => ({
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
})),
issues,
}
}
-203
View File
@@ -1,203 +0,0 @@
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
{ value: 'author', label: '编制' },
{ value: 'reviewer', label: '审核' },
{ value: 'approver', label: '批准' },
]
function DutySelect({
doc, owner, disabled, onChange,
}: {
doc: DutyDocDraft
owner: string
disabled: boolean
onChange: (duty: CellDuty) => void
}) {
const current = cellDuty(doc, owner)
return (
<label className="duty-cell">
<select
aria-label={`${owner} 在 ${doc.title} 的职责`}
value={current}
disabled={disabled}
onChange={event => {
const value = event.target.value
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
}}
>
{DUTY_OPTIONS.map(option => {
const hint = replacementNote(doc, owner, option.value)
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
})}
</select>
</label>
)
}
export function DutyByMemberView({
member, members, docs, disabled, onMember, onCell,
}: {
member: string
members: DutyMember[]
docs: DutyDocDraft[]
disabled: boolean
onMember: (owner: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
return (
<div>
<div className="setting-row">
<label htmlFor="duty-member">成员</label>
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
<option value="">选择成员</option>
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
</select>
</div>
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
<table className="ad-table">
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
<tbody>
{docs.map(doc => {
const score = completeness(doc)
return (
<tr key={doc.docId}>
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
</tr>
)
})}
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
</tbody>
</table>
)}
</div>
)
}
export function DutyByDocumentView({
docId, docs, members, disabled, onDoc, onCell,
}: {
docId: string
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
onDoc: (id: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
const doc = docs.find(item => item.docId === docId)
const score = doc ? completeness(doc) : null
const issues = doc ? saveIssues(doc) : []
return (
<div>
<div className="setting-row">
<label htmlFor="duty-doc">文档</label>
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
<option value="">选择整份文档</option>
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
</select>
</div>
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
<table className="ad-table">
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
narrow: boolean
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
if (narrow) {
return <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
}
return (
<div className="duty-matrix-wrap">
<table className="ad-table duty-matrix">
<thead>
<tr>
<th className="duty-sticky">成员</th>
{docs.map(doc => {
const score = completeness(doc)
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
})}
</tr>
</thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="duty-sticky ad-mono">{person.owner}</td>
{docs.map(doc => (
<td key={doc.docId}>
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function DutyChangePreview({
impacts, confirm, onConfirm,
}: {
impacts: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
confirm: boolean
onConfirm: (value: boolean) => void
}) {
if (impacts.length === 0) return null
const needs = impacts.some(item => item.needsConfirm)
return (
<div className="settings-group">
<h4>保存前预览</h4>
<ul>
{impacts.map(item => (
<li key={item.docId}>
{item.title || item.docId}
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
</li>
))}
</ul>
{needs && (
<label>
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
确认使受影响的在途审签失效
</label>
)}
</div>
)
}
+5 -86
View File
@@ -3,9 +3,8 @@
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part, StreamEvent } from './contracts'
import type { Message, Part } from './contracts'
import type { LiveState } from './store'
import { splitTimeline, type ReplayPart } from './messageparts'
export type ToolLiveState = 'composing' | 'running' | 'done'
@@ -13,7 +12,6 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
| {
kind: 'tool'
callId?: string
@@ -120,13 +118,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > maxChars) output = output.slice(output.length - maxChars)
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
const out = items.slice()
out[i] = { ...it, output }
return out
@@ -151,27 +149,6 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
return [...items, { kind: 'check', passed, reason }]
}
/** A child streams cumulative previews. Replace that child's latest preview in
* place, while preserving real phase changes and the order of other children. */
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
const text = note.trim()
if (!text) return items
for (let i = items.length - 1; i >= 0; i--) {
const previous = items[i]
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
if (previous.agentPhase !== 'progress') break
const value = previous.text.slice('协作进展:'.length)
if (value === text || value.startsWith(text)) return items
if (text.startsWith(value) && !/^调用\s/.test(text)) {
const next = items.slice()
next[i] = { ...previous, text: `协作进展:${text}` }
return next
}
break
}
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
@@ -216,9 +193,7 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'agent') {
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
} else if (it.kind === 'thinking') {
if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({
type: 'thinking', text: it.text, process: true,
@@ -246,63 +221,7 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
}
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
let items: LiveItem[] = []
let finalizing = false
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
const p = event.payload as Record<string, unknown> | undefined
const now = Date.parse(event.ts) || 0
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
switch (event.type) {
case 'agent.spawn':
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
break
case 'agent.progress':
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
break
case 'agent.result':
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
break
case 'thinking.delta':
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
break
case 'message.delta':
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
break
case 'tool.call.delta':
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
break
case 'tool.call':
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
break
case 'tool.output':
if (typeof p?.text === 'string') items = applyToolOutput(items,
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
break
case 'tool.result':
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
break
case 'run.phase':
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
break
case 'run.check':
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
break
}
}
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
+10 -75
View File
@@ -1,4 +1,5 @@
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
import type { Part } from './contracts'
export interface TimelineSplit {
@@ -6,86 +7,20 @@ export interface TimelineSplit {
final: Part[]
}
/** Client-only provenance. It is never written to the Core message contract. */
export interface ReplayPart extends Part { replaySource?: 'agent' }
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
const timeline: Part[] = []
const final: Part[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
else timeline.push(part)
}
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
let idx = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text') { idx = i; break }
}
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
}
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
const before = parts.slice(0, last + 1)
const timeline = before.filter(part => !visible(part))
const final = [...before.filter(visible), ...parts.slice(last + 1)]
return { timeline, final }
}
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
function sameProcessPart(saved: Part, replayed: Part): boolean {
if (saved.type !== replayed.type) return false
switch (saved.type) {
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
case 'thinking':
case 'text': return normalized(saved.text) === normalized(replayed.text)
default: return false
}
}
/** Keep the settled message as the content authority; add only event-only records
* and full tool output. Event deltas can be missing, while terminal parts can
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
if (replayed.length === 0) return saved
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
const merged: Part[] = []
let cursor = 0
let pending: Part[] = []
for (const part of replayed) {
const text = normalized(part.text)
const agentNote = part.replaySource === 'agent'
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|| /<\/?think\b/i.test(part.text ?? ''))) continue
let match = -1
for (let i = cursor; i < saved.length; i++) {
if (sameProcessPart(saved[i], part)) { match = i; break }
}
if (match < 0) {
// A missing delta batch can leave only a prefix in events. The complete
// terminal part is authoritative; never render that prefix beside it.
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
if (item.type !== part.type) return false
const value = normalized(item.text)
return value && (value.includes(text) || text.includes(value))
})) continue
pending.push(part)
continue
}
merged.push(...saved.slice(cursor, match), ...pending)
pending = []
const output = (part as Part & { toolOutput?: string }).toolOutput
merged.push(output && saved[match].type === 'tool_call'
? { ...saved[match], toolOutput: output } as Part : saved[match])
cursor = match + 1
}
merged.push(...saved.slice(cursor), ...pending)
return merged
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
const order: string[] = []
+1 -6
View File
@@ -1,22 +1,17 @@
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
export interface WorkReference { cardId: string; resultVersion: number; title: string }
export interface QueuedMessage {
id: string
text: string
attachmentIds: string[]
attachmentNames: string[]
taskRef?: WorkReference
deliveryError?: string
}
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
text,
attachmentIds,
attachmentNames,
...(taskRef ? { taskRef } : {}),
}
}
-38
View File
@@ -3,41 +3,3 @@ export function projectStage(settings?: Record<string, unknown> | null): string
const stage = settings?.stage
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
}
/** Service metadata describes readiness; permission is always checked by the server. */
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
const settings = project.settings
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
switch (settings['rdm.state']) {
case 'ready':
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
break
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
}
return '项目尚未绑定,请联系项目负责人完成项目接入'
}
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
switch (settings?.['rdm.state']) {
case 'provisioning': return '准备中'
case 'failed': return '准备失败'
case 'unbound': return '未接入'
case 'archived': return '已归档'
case 'closed': return '已关闭'
default: return null
}
}
export function projectActionError(error: unknown): string {
const raw = String(error)
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
return raw.replace(/^Error:\s*/, '')
}
+11 -70
View File
@@ -2,36 +2,6 @@
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
import type { Project } from './contracts'
import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string {
const id = project.settings?.['rdm.projectId']
if (typeof id !== 'string' || !id.trim()) {
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
}
return id.trim()
}
/** Historical project reads remain available; this does not grant read permission. */
export function rdmProjectReadError(project: Project | undefined): string | null {
const state = project?.settings?.['rdm.state']
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
try { rdmCommandProjectId(project); return null }
catch (e) { return String(e).replace(/^Error:\s*/, '') }
}
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
}
const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
}
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
export const RDM_ROLES_COMMANDS = [
@@ -55,12 +25,14 @@ export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.project.member.list',
] as const
/** 项目设置「文档职责」页消费的命令面。审签命令留在文档工作区,不作为本页门控。 */
export const RDM_DUTY_COMMANDS = [
'rdm.duty.matrix',
'rdm.duty.preview',
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
export const RDM_DOC_REVIEW_COMMANDS = [
'rdm.review.assign.doc',
'rdm.review.assign.doc.batch',
'rdm.review.doc.submit',
'rdm.review.doc.approve',
'rdm.review.doc.reject',
'rdm.review.doc.final',
'rdm.review.doc.status',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
@@ -78,9 +50,9 @@ export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_MEMBER_COMMANDS)
/** 文档职责页是否就绪。命令未齐则 fail-closed。 */
export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DUTY_COMMANDS)
/** 文档职责/整文审签页是否就绪。 */
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
/** 项目权限矩阵页是否就绪。 */
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
@@ -104,41 +76,10 @@ export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ role
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
) => ({
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
})
export const dutyMatrixPayload = (
projectId: string,
query: {
view: 'member' | 'document' | 'matrix'
memberOwner?: string
docId?: string
query?: string
stage?: string
typeKey?: string
assignment?: string
memberQuery?: string
memberCursor?: string
docCursor?: string
memberLimit?: number
docLimit?: number
},
) => ({ projectId, ...query })
export const dutyPreviewPayload = (
projectId: string,
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
) => ({ projectId, docId, author, reviewers, approver })
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
-17
View File
@@ -1,17 +0,0 @@
import { api } from './api'
import type { StreamEvent } from './contracts'
/** Read the durable record in bounded pages; callers own cancellation and projection. */
export async function loadRunEvents(conversationId: string, runId: string, signal: AbortSignal): Promise<StreamEvent[]> {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(conversationId, runId, after, signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) return events
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
}
+21 -1
View File
@@ -1,4 +1,24 @@
// Explicit message/card location feedback. The middle and work panes scroll independently.
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
+65 -232
View File
@@ -2,17 +2,15 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, api, connectEvents } from './api'
import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { rdmAuditReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -34,8 +32,7 @@ import type {
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList,
WorkCardAction
TodoList
} from './contracts'
export interface ContextObs {
@@ -114,8 +111,6 @@ interface AppState {
projectCards: ActivityCard[]
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
cardActionStates: Record<string, 'submitting' | 'unknown'>
workRecord: { cardId: string; runId: string; conversationId: string; title: string } | null
workReference: WorkReference | null
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
@@ -124,8 +119,6 @@ interface AppState {
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean
rdmMembersAvailable: boolean
@@ -196,10 +189,9 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
@@ -287,10 +279,6 @@ let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
let conversationEpoch = 0
let identityEpoch = 0
let projectsRequest = 0
let tasksRequest = 0
let meRequest = 0
let streamSeq = 0
let detailRequest = 0
let detailSeq = 0
@@ -309,7 +297,6 @@ let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
@@ -370,7 +357,7 @@ function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: stri
if (value ? current[id] === value : !(id in current)) continue
states ??= { ...state.cardActionStates }
if (value) states[id] = value
else { delete states[id]; delete actionFeedbackOwners[id]; workActionExpectations.delete(id) }
else { delete states[id]; delete actionFeedbackOwners[id] }
}
if (states) set({ cardActionStates: states })
}
@@ -385,22 +372,13 @@ function startActionFeedback(key: string, ids: string[] = []) {
function settleCardFeedback(cards: ActivityCard[]) {
let states: AppState['cardActionStates'] | undefined
for (const card of cards) {
const expected = workActionExpectations.get(card.id)
if (expected) {
const decision = card.work?.decision?.state
const settled = expected.actionId === 'work.read' ? (card.work?.readVersion ?? 0) >= expected.payload.resultVersion
: expected.actionId === 'work.defer' ? decision === 'deferred'
: expected.actionId === 'work.reopen' ? decision === 'open' : decision === 'submitted'
if (!settled) continue
}
if (!expected && card.status === 'action_required') continue
if (card.status === 'action_required') continue
const source = hitlSource(card)
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
if (!(id in (states ?? state.cardActionStates))) continue
states ??= { ...state.cardActionStates }
delete states[id]
delete actionFeedbackOwners[id]
workActionExpectations.delete(id)
}
}
if (states) set({ cardActionStates: states })
@@ -420,59 +398,24 @@ export function useStore<T>(selector: (s: AppState) => T): T {
export const store = {
getState: () => state,
getIdentityEpoch: () => identityEpoch,
resetIdentity() {
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
sse?.close(); sse = null
projectSse?.close(); projectSse = null
conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++
projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0
cardVersion = 0; projectCardVersion = 0
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
runObservations = {}; messageObservations = {}; hitlObservations = {}
actionInflight.clear()
workActionExpectations.clear()
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
persistConvId(null)
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
previewExpanded: false, filesView: 'list', showArchived: false })
},
async refreshProjects() {
const epoch = identityEpoch
const request = ++projectsRequest
try {
const projects = await api.listProjects()
if (epoch !== identityEpoch || request !== projectsRequest) return
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
set({ error: String(e) })
}
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
store.refreshTasks()
store.refreshMe()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
const epoch = identityEpoch
const request = ++tasksRequest
try {
const tasks = await api.listTasks(includeArchived)
if (epoch !== identityEpoch || request !== tasksRequest) return
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
@@ -486,35 +429,14 @@ export const store = {
},
async refreshMe() {
const epoch = identityEpoch
const request = ++meRequest
try {
const me = await api.me()
if (epoch !== identityEpoch || request !== meRequest) return
const changed = state.me !== null && state.me.owner !== me.owner
if (changed) store.resetIdentity()
set({ me })
if (changed) {
await store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
}
} catch { /* 身份不可用时侧栏只显示通用入口 */ }
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
const epoch = identityEpoch
try {
const p = await api.createProject(name)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.refreshProjects()
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
store.toast(`项目「${p.name}」已创建`)
return p
} catch (e) {
if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`)
throw e
}
const p = await api.createProject(name)
await store.refreshProjects()
store.toast(`项目「${p.name}」已创建`)
return p
},
async openProject(projectId: string) {
@@ -527,7 +449,7 @@ export const store = {
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
@@ -543,43 +465,28 @@ export const store = {
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
async createTask(projectId?: string) {
const epoch = identityEpoch
try {
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const error = projectWorkError(state.projects.find(p => p.id === pid))
if (error) throw new Error(error)
if (pid !== state.currentProjectId) await store.openProject(pid)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const c = await api.createConversation(pid)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.refreshTasks()
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.openTask(c.id)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
} catch (e) {
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
throw e
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (pid !== state.currentProjectId) await store.openProject(pid)
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
await store.createTask()
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const epoch = identityEpoch
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
if (epoch !== identityEpoch) return
await store.openConversation(convId)
},
@@ -617,7 +524,7 @@ export const store = {
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
@@ -730,9 +637,6 @@ export const store = {
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
const belongsToLive = (agentRun: string) => !!state.live && (e.runId === state.live.runId
|| Object.prototype.hasOwnProperty.call(state.live.agentNotes, agentRun)
|| state.runs.some(run => run.id === agentRun && run.parentRunId === state.live?.runId))
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
@@ -794,17 +698,17 @@ export const store = {
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
if (state.live && p.note && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
store.reconcile()
break
}
@@ -905,7 +809,7 @@ export const store = {
}
},
async sendMessage(text: string, attachmentIds: string[], requestId?: string, taskRef?: WorkReference): Promise<'ok' | 'busy' | 'rejected'> {
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
const convId = state.currentConvId
const epoch = conversationEpoch
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
@@ -913,7 +817,7 @@ export const store = {
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
set({ posting: true })
try {
const out = await (taskRef ? api.sendMessage(convId, text, attachmentIds, id, taskRef) : api.sendMessage(convId, text, attachmentIds, id))
const out = await api.sendMessage(convId, text, attachmentIds, id)
if (!sameContext()) return 'ok'
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
@@ -939,8 +843,8 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
const live = !state.live || state.live.runId !== started.id
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: eventTime({ ts: started.createdAt }) }
: { ...state.live, startedAt: eventTime({ ts: started.createdAt }, state.live.startedAt) }
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
: state.live
set({ live, runs, messages })
store.connectIfNeeded()
}
@@ -948,22 +852,19 @@ export const store = {
} catch (e) {
if (!sameContext()) return 'ok'
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'rejected'
return 'ok'
} finally {
if (sameContext()) set({ posting: false })
}
},
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames, taskRef)) })
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
},
updateOutbox(id: string, text: string) {
@@ -974,10 +875,6 @@ export const store = {
set({ outbox: removeQueuedMessage(state.outbox, id) })
},
clearOutboxReference(id: string) {
set({ outbox: state.outbox.map(item => item.id === id ? { ...item, taskRef: undefined } : item) })
},
async sendOutboxNow(id: string) {
const item = state.outbox.find(q => q.id === id)
if (!item) return
@@ -988,23 +885,23 @@ export const store = {
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item), item.taskRef)
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
},
async flushOutbox() {
if (state.outboxDraining) return
if (!canFlushOutbox(shellPhase())) return
const next = state.outbox.find(item => !item.deliveryError)
const next = state.outbox[0]
if (!next) return
set({ outbox: removeQueuedMessage(state.outbox, next.id), outboxDraining: true })
let result: 'ok' | 'busy' | 'rejected' = 'ok'
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next), next.taskRef)
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (canFlushOutbox(shellPhase()) && state.outbox.some(item => !item.deliveryError)) {
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
await store.flushOutbox()
}
},
@@ -1089,8 +986,7 @@ export const store = {
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, payload?: string | WorkCardAction) {
const freeText = typeof payload === 'object' ? payload.freeText : payload
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
const source = hitlSource(card)
if (source) {
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
@@ -1100,14 +996,12 @@ export const store = {
const convId = state.currentConvId
const pid = state.currentProjectId
const epoch = conversationEpoch
const identity = identityEpoch
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
const feedbackOwner = startActionFeedback(card.id)
if (actionId.startsWith('work.') && typeof payload === 'object') workActionExpectations.set(card.id, { actionId, payload })
try {
const out = await api.cardAction(card.id, actionId, payload)
const out = await api.cardAction(card.id, actionId, freeText)
if (!sameContext()) return
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
@@ -1116,7 +1010,7 @@ export const store = {
const cards = state.projectCards
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
}
if (!actionId.startsWith('work.') && out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
observeDetail(messageObservations, out.receipt.id)
set({ messages: [...state.messages, out.receipt as Message] })
}
@@ -1127,7 +1021,7 @@ export const store = {
}
if (sameContext()) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
actionFeedback(card.id, !actionId.startsWith('work.') && actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
} catch (e) {
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
@@ -1141,81 +1035,57 @@ export const store = {
else await store.loadProjectCards()
}
} finally {
if (identity === identityEpoch) actionInflight.delete(card.id)
actionInflight.delete(card.id)
}
},
async cardPin(card: ActivityCard, pinned: boolean) {
const epoch = identityEpoch
try {
const out = await api.cardPin(card.id, pinned)
if (epoch === identityEpoch) store.upsertCard(out.card)
} catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) }
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
},
async cardDismiss(card: ActivityCard) {
const epoch = identityEpoch
try {
const out = await api.cardDismiss(card.id)
if (epoch === identityEpoch) store.upsertCard(out.card)
} catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) }
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
},
async refreshArtifacts(projectId: string) {
const epoch = identityEpoch
try {
const artifacts = await api.listArtifacts(projectId)
if (epoch !== identityEpoch || state.currentProjectId !== projectId) return
if (state.currentProjectId !== projectId) return
set({ artifacts })
} catch { /* 项目删除竞态等,静默 */ }
},
async refreshProfiles() {
const epoch = identityEpoch
try {
const profiles = await api.listProfiles()
if (epoch === identityEpoch) set({ profiles })
} catch { /* ignore */ }
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
},
async refreshPlugins() {
const epoch = identityEpoch
try {
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
if (epoch !== identityEpoch) return
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
if (epoch !== identityEpoch) return
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
try {
const cmds = await api.listCommands()
if (epoch !== identityEpoch) return
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
}
} catch { set({ rdmAuditAvailable: false }) }
try {
const sections = await api.adminZoneSections()
if (epoch !== identityEpoch) return
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
if (epoch !== identityEpoch) return
const runtime = settings.user.runtime
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
set({ runtimeMock: mock })
@@ -1224,16 +1094,15 @@ export const store = {
async uploadAttachment(file: File): Promise<string | null> {
const pid = state.currentProjectId
const epoch = identityEpoch
if (!pid) return null
try {
const up = await api.upload(pid, file)
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
if (state.currentProjectId !== pid) return null
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
await store.refreshArtifacts(pid)
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
return up.artifactId
} catch (e) {
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
return null
}
},
@@ -1271,10 +1140,9 @@ export const store = {
connectProject() {
const pid = store.getState().currentProjectId
const epoch = identityEpoch
if (!pid) return
projectSse?.close()
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
},
applyProjectEvent(e: StreamEvent) {
@@ -1315,10 +1183,8 @@ export const store = {
},
async cardToProject(card: ActivityCard) {
const epoch = identityEpoch
try {
const out = await api.cardToProject(card.id)
if (epoch !== identityEpoch) return
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === out.card.id)
@@ -1326,23 +1192,17 @@ export const store = {
store.toast('已留档到项目收件箱')
store.setInboxScope('project')
} catch (e) {
if (epoch === identityEpoch) store.toast(`留档失败:${e}`)
store.toast(`留档失败:${e}`)
}
},
async tidyCards() {
const epoch = identityEpoch
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
for (const c of done) {
if (epoch !== identityEpoch) return
try {
const out = await api.cardDismiss(c.id)
if (epoch !== identityEpoch) return
store.upsertCard(out.card)
} catch { /* 单卡失败不阻断 */ }
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
@@ -1353,33 +1213,9 @@ export const store = {
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
if (state.workRecord) set({ workRecord: null })
if (cardId) {
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
set({ inboxScope: scope })
}
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
setUI({ rightCollapsed: false, rightTab: 'work' })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
openWorkRecord(card: ActivityCard) {
if (!card.sourceRunId || !card.conversationId || card.conversationId !== state.currentConvId || card.projectId !== state.currentProjectId) {
store.toast('请先打开这项工作所属的对话,再查看执行记录')
return
}
set({ workRecord: { cardId: card.id, runId: card.sourceRunId, conversationId: card.conversationId, title: card.title } })
},
closeWorkRecord() { set({ workRecord: null }) },
referenceWork(card: ActivityCard) {
if (card.projectId !== state.currentProjectId || card.conversationId !== state.currentConvId || !card.work?.result?.trim()) {
store.toast('请在这项工作所属的对话中引用结果')
return
}
set({ workReference: { cardId: card.id, resultVersion: card.work.resultVersion, title: card.title } })
if (card.work.resultVersion > card.work.readVersion) void store.cardAction(card, 'work.read', { resultVersion: card.work.resultVersion })
if (typeof document !== 'undefined') document.querySelector<HTMLTextAreaElement>('.input-row textarea')?.focus()
},
clearWorkReference() { set({ workReference: null }) },
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
@@ -1464,13 +1300,10 @@ export const store = {
},
}
auth.onIdentityChange(() => store.resetIdentity())
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {},
startedAt: eventTime({ ts: state.runs.find(run => run.id === _runId)?.createdAt }, now) }
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
+24 -41
View File
@@ -194,10 +194,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
@@ -230,7 +226,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.process-collapse .process-collapse-meta { color: var(--text-dim); }
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
@@ -502,29 +497,29 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
/* ---- 用户管理 / 改密弹层 ---- */
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.ad-row { display: flex; gap: 8px; align-items: center; }
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-notice svg { flex: none; margin-top: 1px; }
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
.admin-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.admin-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.admin-row { display: flex; gap: 8px; align-items: center; }
.admin-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.admin-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.admin-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.admin-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.admin-mini:hover { color: var(--text); border-color: var(--accent); }
.admin-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.admin-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.admin-notice svg { flex: none; margin-top: 1px; }
.admin-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.admin-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.admin-table tr:last-child td { border-bottom: none; }
.admin-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.admin-dim { color: var(--text-dim); }
.admin-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
.admin-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.admin-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
.admin-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.admin-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.admin-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
/* ---- 收起轨道(左栏收起态) ---- */
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
@@ -536,7 +531,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.role-mark.role-assistant { background: none; border: none; padding: 0; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
.admin-user-cell { display: flex; align-items: center; gap: 8px; }
.artifact-chip .ac-act { border: none; background: none; }
@@ -632,19 +627,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.rdm-audit-export-draft p { margin: 0 0 8px; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
.duty-matrix-wrap { overflow: auto; }
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); }
-18
View File
@@ -30,24 +30,6 @@ export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.history',
] as const
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
export const RDM_EXPORT_COMMANDS = [
'rdm.inspect.export.create',
'rdm.inspect.export.status',
'rdm.inspect.export.download',
] as const
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
export function exportNeedsDraft(message: string): boolean {
return /不完整|草稿/.test(message)
}
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
+8 -73
View File
@@ -69,29 +69,6 @@ describe('conversation work reading', () => {
expect(html).not.toContain('data-testid="input-status"')
})
it('renders stable semantic timestamps on user and assistant messages', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])]
const html = render()
expect(html.match(/<time class="message-timestamp"/g)).toHaveLength(2)
expect(html.match(/dateTime="2026-10-12T10:00:00.000Z"/g)).toHaveLength(2)
expect(html).toContain('title="2026/10/12')
})
it('announces unread work without inserting its full result into the chat', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ status: 'done', work: { result: 'background-result-full-text', resultVersion: 2, readVersion: 1 } })]
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('background-result-full-text')
})
it('shows an explicit removable result reference without copying its content into the draft', () => {
snapshot.state!.workReference = { cardId: 'work', resultVersion: 2, title: '材料整理' }
const html = render()
expect(html).toContain('基于「材料整理」的第 2 版结果')
expect(html).toContain('aria-label="移除工作结果引用"')
})
it('shows the current real work with the auxiliary pane closed', () => {
snapshot.state!.cards = [card()]
const html = render()
@@ -137,30 +114,25 @@ describe('conversation work reading', () => {
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
})
it('leaves completed results to the visible right work pane', () => {
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toBe('')
expect(html).toContain('最新整理成果 · 查看摘要')
expect(html).not.toContain('最新成果的主要结论')
expect(html).not.toContain('先前整理成果')
})
it('keeps the work card in one detail location when the right work pane is visible', () => {
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).toContain('整理项目材料')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps the conversation detail when the right pane is reading files', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'files'
snapshot.state!.cards = [card()]
expect(render()).toContain('核对会议结论')
})
it('keeps secondary message operations available through progressive disclosure', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
const html = render()
@@ -218,29 +190,6 @@ describe('conversation work reading', () => {
expect(html).toContain('确认新增')
})
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('在工作栏处理')
expect(html).not.toContain('确认新增')
})
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
const html = render()
expect(html).toContain('材料核对结果')
expect(html).not.toContain('展开全文')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('展开全文')
})
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
@@ -248,26 +197,12 @@ describe('conversation work reading', () => {
expect(render()).toContain('确认新增')
})
it('keeps a thinking-only record available for deliberate review', () => {
it('does not reveal internal thinking when records are deliberately opened', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).toContain('private-internal-deliberation')
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
{ type: 'text', text: '已完成核对' }])]
expect(render()).toContain('执行记录')
})
it('lets readers reopen the full output of a grouped read action', () => {
setPersistedOpen('tool-record:read-history:call-read:0', true)
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'tool_call', process: true,
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
toolOutput: 'full-read-output' } as Message['parts'][number]],
live: false, idKey: 'read-history', renderPart: () => null,
}))
expect(html).toContain('full-read-output')
expect(html).not.toContain('private-internal-deliberation')
})
it('blocks repeated HITL choices when the shared action result is unknown', () => {
+3 -19
View File
@@ -1,6 +1,6 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/**/*.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
@@ -36,14 +36,8 @@ describe('DESIGN.md 宪法在位', () => {
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('宿主 CSS 无 @keyframes 或私有 transition(全部走 @ui/motion.css 的 mk-* 类)', () => {
for (const path of walk(SRC).filter(p => p.endsWith('.css'))) {
const css = stripCssComments(read(path))
expect(css, `${relative(SRC, path)} 不得定义 @keyframes`).not.toContain('@keyframes')
const transitions = css.split('\n').filter(line => /transition\s*:/.test(line) && !/transition\s*:\s*none\b/.test(line))
if (path.endsWith('styles.css')) continue // 历史 transition 由现有棘轮逐步收紧;新分层 CSS 不再扩散。
expect(transitions, `${relative(SRC, path)} 动效必须消费 mk-*`).toEqual([])
}
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
})
})
@@ -59,16 +53,6 @@ describe('裸色值棘轮', () => {
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('分层组件 CSS 只消费令牌,不派生私有颜色', () => {
const componentCss = walk(SRC).filter(p => p.endsWith('.css') && !p.endsWith('styles.css'))
const extra: string[] = []
for (const path of componentCss) {
const css = stripCssComments(read(path))
for (const hex of collectHex(css)) extra.push(`${relative(SRC, path)}: ${hex}`)
if (/color-mix\s*\(/.test(css)) extra.push(`${relative(SRC, path)}: color-mix`)
}
expect(extra, '分层组件 CSS 的颜色须由 @ui/tokens.css 提供').toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
absorbServerDocs, applyCell, cellDuty, completeness, editCell, replacementNote, saveIssues, savePayload,
type DutyDocDraft,
} from '../src/duties/draft'
function doc(over: Partial<DutyDocDraft> = {}): DutyDocDraft {
return {
docId: 'd1', title: '需求', code: 'UR-1', typeKey: 'design', stage: 'BD',
dutyVersion: 3, activeRound: 0, legacySectionOverrides: 0, dirty: false,
author: 'alice', reviewers: ['bob'], approver: 'carol',
...over,
}
}
describe('文档职责草稿', () => {
it('单元格由三权反推,三人视图读到同一份角色', () => {
const roles = { author: 'alice', reviewers: ['bob', 'dave'], approver: 'carol' }
expect(cellDuty(roles, 'alice')).toBe('author')
expect(cellDuty(roles, 'bob')).toBe('reviewer')
expect(cellDuty(roles, 'dave')).toBe('reviewer')
expect(cellDuty(roles, 'carol')).toBe('approver')
expect(cellDuty(roles, 'erin')).toBe('none')
expect(completeness(roles)).toEqual({ authors: 1, reviewers: 2, approvers: 1, conflicts: 0 })
})
it('替换编制前先记住原编制,并保证一人一责', () => {
const next = applyCell({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')
expect(next.replacedAuthor).toBe('alice')
expect(next.roles).toEqual({ author: 'bob', reviewers: [], approver: 'carol' })
expect(replacementNote({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')).toBe('将替换alice')
})
it('清掉唯一编制后允许留在草稿,但禁止保存', () => {
const cleared = editCell([doc()], 'd1', 'alice', 'none')
expect(cleared[0].author).toBe('')
expect(cleared[0].dirty).toBe(true)
expect(saveIssues(cleared[0])).toContain('需指定编制人')
expect(savePayload(cleared, false).issues.join(' ')).toContain('需指定编制人')
})
it('过期服务端页不覆盖未保存草稿', () => {
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
const absorbed = absorbServerDocs([dirty], [{
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
}], [{ docId: 'd1', owner: 'alice', duty: 'author' }, { docId: 'd1', owner: 'bob', duty: 'reviewer' }, { docId: 'd1', owner: 'carol', duty: 'approver' }])
expect(absorbed[0].author).toBe('erin')
expect(absorbed[0].dutyVersion).toBe(3)
})
it('写入结果未知时采用服务端版本,不再留着旧草稿重放', () => {
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
const absorbed = absorbServerDocs([dirty], [{
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
}], [
{ docId: 'd1', owner: 'alice', duty: 'author' },
{ docId: 'd1', owner: 'bob', duty: 'reviewer' },
{ docId: 'd1', owner: 'carol', duty: 'approver' },
], true)
expect(absorbed[0]).toMatchObject({ author: 'alice', dutyVersion: 4, dirty: false })
})
it('保存载荷是每份脏文档的完整目标,超过 20 份被拒绝', () => {
const many = Array.from({ length: 21 }, (_, index) => doc({ docId: `d${index}`, dirty: true }))
const blocked = savePayload(many, true)
expect(blocked.issues).toContain('一次保存最多 20 份文档')
const one = savePayload([doc({ dirty: true, activeRound: 2 })], true)
expect(one.docs[0]).toMatchObject({
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true,
})
})
})
-57
View File
@@ -5,9 +5,7 @@ import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
applyCheck, applyFinalizing,
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
runEventsToTimeline,
} from '../src/live'
import type { StreamEvent } from '../src/contracts'
import type { LiveState } from '../src/store'
function live(over: Partial<LiveState> = {}): LiveState {
@@ -161,61 +159,6 @@ describe('liveToMessage', () => {
})
})
it('reopens the ordered complete process from persisted run events', () => {
const output = 'x'.repeat(5000)
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'thinking.delta', { index: 0, text: '先核对' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }),
event(4, 'tool.output', { callId: 'call1', text: output }),
event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复' }),
])
expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result'])
expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output)
expect(parts.some(part => part.text === '最终回复')).toBe(false)
})
it('retains collaboration notes from a child after the parent has replied', () => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '最终回复' }),
event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
], 'parent')
expect(parts.map(part => part.text)).toEqual([
'协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对',
])
expect(parts.every(part => part.replaySource === 'agent')).toBe(true)
})
it('coalesces cumulative child updates without mixing tasks or erasing tool phases', () => {
const notes = [
['one', '我来检索'], ['two', '核对第二份材料'], ['one', '我来检索一下材料'],
['one', '调用 knowledge_search'], ['one', '根据检索结果,开始整理'],
...Array.from({ length: 80 }, () => ['one', '根据检索结果,开始整理完整报告']),
]
const events: StreamEvent[] = notes.map(([agentRun, note], seq) => ({
seq: seq + 1, type: 'agent.progress', payload: { agentRun, note }, runId: agentRun,
conversationId: 'c', ts: '2026-10-15T10:00:00Z', v: 1,
}))
expect(runEventsToTimeline(events, 'parent').map(part => part.text)).toEqual([
'协作进展:我来检索一下材料', '协作进展:核对第二份材料',
'协作进展:调用 knowledge_search', '协作进展:根据检索结果,开始整理完整报告',
])
})
it('uses the stable run start time for the temporary assistant message', () => {
expect(liveToMessage(live({ startedAt: Date.parse('2026-10-15T10:00:00Z') })).createdAt)
.toBe('2026-10-15T10:00:00.000Z')
})
describe('livePhaseLabel', () => {
it('names the current activity in Chinese', () => {
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
+1 -18
View File
@@ -1,7 +1,7 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -30,20 +30,3 @@ describe('humanChangePasswordError(改密门)', () => {
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(登录恢复新增文案)', () => {
it('并发凭据变化给出重新登录指引', () => {
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
})
})
+19 -54
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitTimeline, mergeProcessTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
import type { Part } from '../src/contracts'
@@ -8,7 +8,7 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
}
describe('splitTimeline', () => {
it('keeps intermediate narration with its tools in reading order and the reply outside', () => {
it('keeps public Markdown before, between and after tools in reading order', () => {
const parts: Part[] = [
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
@@ -18,11 +18,12 @@ describe('splitTimeline', () => {
p({ type: 'text', text: '接下来确认日期冲突。' }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
expect(split.final.map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。',
'评审意见未能读取,完整结论尚未生成。',
'接下来确认日期冲突。',
])
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
})
it('keeps live scope limits visible while execution records are folded by default', () => {
@@ -35,8 +36,9 @@ describe('splitTimeline', () => {
expect(message.parts[1].process).toBe(true)
expect(showProcessInline(live, true)).toBe(false)
const split = splitTimeline(message.parts)
expect(split.final).toEqual([])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call'])
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
expect(split.final.every(part => part.type === 'text')).toBe(true)
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
})
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
@@ -50,8 +52,8 @@ describe('splitTimeline', () => {
]
for (const finished of [false, true]) {
const split = splitTimeline(parts, finished)
expect(split.final.map(part => part.text)).toEqual(['已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['text', 'thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.length + split.timeline.length).toBe(parts.length)
}
})
@@ -67,7 +69,7 @@ describe('splitTimeline', () => {
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
})
it('keeps narration beside execution records when it precedes the last process part', () => {
it('keeps only execution records folded when narration precedes the last process part', () => {
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
@@ -76,8 +78,8 @@ describe('splitTimeline', () => {
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
})
it('keeps a card sandwiched between process parts inside the timeline', () => {
@@ -92,7 +94,7 @@ describe('splitTimeline', () => {
expect(split.final.map(x => x.text)).toEqual(['最终'])
})
it('promotes only the last narration when a finished run has no final text', () => {
it('keeps all narrations visible even when a finished run has no final text', () => {
const parts: Part[] = [
p({ type: 'text', text: '先写一版', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
@@ -100,9 +102,9 @@ describe('splitTimeline', () => {
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
]
const split = splitTimeline(parts, true)
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual([])
expect(split.final.map(x => x.text)).toEqual(['先写一版', '纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual(split.final)
})
it('puts a message with no process parts entirely in the final section', () => {
@@ -113,43 +115,6 @@ describe('splitTimeline', () => {
})
})
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
const saved: Part[] = [
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
p({ type: 'thinking', process: true, text: '检查附件边界' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
]
const replayed: Part[] = [
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
p({ type: 'text', process: true, text: '最终回复' }),
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
]
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
])
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
})
it('does not repeat a partial event delta beside the complete saved narration', () => {
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
})
it('keeps a collaboration result even when the final reply contains its short status', () => {
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
[p({ type: 'text', text: '已完成' })])
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
})
describe('artifact ids in reply text', () => {
it('extracts unique ids in order', () => {
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
-368
View File
@@ -1,368 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { store } from '../src/store'
import type { ActivityCard, Conversation, Project } from '../src/contracts'
import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement'
const project = (id: string, settings?: Project['settings']): Project => ({
id, name: id, settings, createdAt: '', updatedAt: '',
})
const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })
const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' })
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(ok => { resolve = ok })
return { promise, resolve }
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
let projects: Project[]
let tasks: Conversation[]
let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response>
beforeEach(async () => {
projects = []; tasks = []; requests = []
const saved = new Map<string, string>()
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
removeItem: (key: string) => { saved.delete(key) } })
route = async (path, init) => {
if (path === '/api/projects' && !init?.method) return response(projects)
if (path.startsWith('/api/conversations') && !init?.method) return response(tasks)
if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true })
if (path.endsWith('/artifacts')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
if (path.endsWith('/surfaces')) return response([])
if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([])
if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} })
if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } })
return response({})
}
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, init })
return route(path, init)
})
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
auth.beginSession()
await store.refreshProjects()
await store.refreshTasks()
store.setUI({ lastProjectId: null })
requests = []
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('project command identity', () => {
it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan']
for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.preview', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.legacy.scan', params: { projectId: 'domain-7', docId: 'doc-2' } },
])
})
it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole',
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.apply',
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
}
expect(requests).toEqual([])
})
it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => {
await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } },
])
})
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol'))
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
{ projectId: 'domain-7', member: 'bob', role: 'member' },
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' },
])
})
it.each([
project('host-1', { 'rdm.state': 'unbound' }),
project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }),
project('host-1', { 'rdm.projectId': 'domain-7' }),
project('host-1'),
])('does not invent a domain ID or send commands for an unready binding: %j', async p => {
await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow()
expect(requests).toEqual([])
})
})
describe('project work and mutation failures', () => {
it('keeps private history readable for an archived project while refusing new work', async () => {
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })]
tasks = [task('private-history')]
const previous = route
route = async (path, init) => {
if (path === '/api/conversations/private-history') return response({ conversation: tasks[0],
messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }],
runs: [], activeHitl: [], lastSeq: 0 })
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.refreshProjects()
await store.openTask('private-history')
await expect(store.createTask()).rejects.toThrow()
expect(store.getState().currentConvId).toBe('private-history')
expect(store.getState().messages.map(m => m.id)).toEqual(['own-message'])
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => {
projects = [p]
await store.refreshProjects()
const previous = route
route = async (path, init) => {
if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') {
tasks = [task('new-private')]
return response(tasks[0])
}
if (path === '/api/conversations/new-private') {
return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
}
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.createTask()
expect(store.getState().currentConvId).toBe('new-private')
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations'])
})
it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => {
projects = [project('host-1', { 'rdm.state': state }), project('host-2')]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/)
})
it('does not bypass a selected project absent from the current list', async () => {
await store.openProject('revoked-project')
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => {
route = async () => response({ error: 'project administration required' }, 403)
const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed')
await expect(mutation).rejects.toThrow(/403/)
})
it('retains selection and gives an actionable message when project creation is rejected', async () => {
projects = [ready]
await store.refreshProjects()
route = async () => response({ error: 'name reserved' }, 409)
await expect(store.createProject('Taken')).rejects.toThrow()
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/名称.*(其他|更换)/)
expect(store.getState().toast).not.toContain('已创建')
})
it('checks a denied settings save instead of resolving success', async () => {
route = async () => response({ error: 'denied' }, 403)
await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/)
})
})
describe('identity-scoped state', () => {
it('resets state on a successful cookie login, without relying on a component callback', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' })
await api.login('bob', 'password-for-test')
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
})
it('clears old state as soon as logout starts, even while the server response is pending', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const pending = deferred<Response>()
route = async () => pending.promise
const logout = api.logout()
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
pending.resolve(response({ ok: true }))
await logout
})
it('clears the previous account when the authenticated owner changes during identity refresh', async () => {
await store.refreshMe()
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const previous = route
projects = []; tasks = []
route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init)
await store.refreshMe()
expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null })
})
it('clears projects, private tasks, previews and persisted selections on a new session', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
store.openSurface('surface-alice')
store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' })
localStorage.setItem('as.conv', 'alice-private')
auth.beginSession()
expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} })
expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null })
expect(localStorage.getItem('as.conv')).toBeNull()
expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull()
})
it('drops old project responses after switching accounts even when IDs are shared', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init)
const pending = store.refreshProjects()
auth.beginSession()
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })]
route = previous
await store.refreshProjects()
old.resolve(response([project('alice-only')]))
await pending
expect(store.getState().projects.map(p => p.id)).toEqual(['host-1'])
expect(store.getState().currentProjectId).toBe('host-1')
})
it('drops an old private task list after logout and a new session', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init)
const pending = store.refreshTasks()
auth.logout()
auth.beginSession()
tasks = [task('bob-private')]
route = previous
await store.refreshTasks()
old.resolve(response([task('alice-private')]))
await pending
expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private'])
})
it('drops an old artifact response when the next account selects the same project', async () => {
await store.openProject('host-1')
const old = deferred<Response>()
const previous = route
route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init)
const pending = store.refreshArtifacts('host-1')
auth.beginSession()
route = previous
await store.openProject('host-1')
old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }]))
await pending
expect(store.getState().artifacts).toEqual([])
})
it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' })
const pending = operation === 'action' ? store.cardAction(card, 'finish')
: operation === 'pin' ? store.cardPin(card, true)
: operation === 'dismiss' ? store.cardDismiss(card)
: operation === 'tidy' ? store.tidyCards() : store.cardToProject(card)
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } }))
await pending
expect(store.getState().projectCards).toEqual([])
expect(store.getState().cards).toEqual([])
})
it('does not reopen a previous account conversation after its project load finishes late', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects()
await store.openProject('another-project')
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init)
const pending = store.openTask('alice-private')
auth.beginSession()
tasks = []
route = previous
await store.refreshProjects()
old.resolve(response([]))
await pending
expect(store.getState().currentConvId).toBeNull()
expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private')
})
it('does not resolve old task creation as success after a new login', async () => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init)
const pending = store.createTask()
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response(task('alice-created')))
await expect(pending).rejects.toThrow(/登录状态/)
expect(store.getState().currentConvId).toBeNull()
})
it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const started = deferred<void>()
const previous = route
route = (path, init) => {
if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 }))
if (path.endsWith('/artifacts')) { started.resolve(); return old.promise }
return previous(path, init)
}
const pending = store.uploadAttachment(new File(['abc'], 'alice.txt'))
await started.promise
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response([]))
expect(await pending).toBeNull()
})
})
describe('export capability identity', () => {
it('drops the old identity export capability before any new command refresh', async () => {
const previous = route
route = async (path, init) => path === '/api/commands'
? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download']
.map(id => ({ id, enabled: true }))) : previous(path, init)
await store.refreshPlugins()
expect(store.getState().rdmExportAvailable).toBe(true)
auth.beginSession()
expect(store.getState().rdmExportAvailable).toBe(false)
})
})
+5 -8
View File
@@ -3,8 +3,8 @@
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
import { describe, expect, it } from 'vitest'
import {
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DUTY_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDutyReady, dutyMatrixPayload, dutyPreviewPayload,
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDocReviewReady,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
@@ -19,19 +19,19 @@ describe('RDM 页面能力门控', () => {
it('命令齐备且 enabled 才就绪', () => {
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true)
expect(rdmDutyReady(all(RDM_DUTY_COMMANDS))).toBe(true)
expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true)
})
it('缺一命令即未就绪', () => {
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id))
expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false)
expect(rdmDutyReady(drop(RDM_DUTY_COMMANDS, 'rdm.duty.matrix'))).toBe(false)
expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false)
})
it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
expect(rdmRolesReady(null)).toBe(false)
expect(rdmMembersReady(undefined)).toBe(false)
expect(rdmDutyReady('not-array' as unknown as never)).toBe(false)
expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false)
})
})
@@ -68,9 +68,6 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(dutyMatrixPayload('p1', { view: 'matrix', docLimit: 10 })).toEqual({ projectId: 'p1', view: 'matrix', docLimit: 10 })
expect(dutyPreviewPayload('p1', [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }]))
.toEqual({ projectId: 'p1', docs: [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }] })
})
it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])
-26
View File
@@ -1,26 +0,0 @@
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { loadRunEvents } from '../src/runEvents'
import type { StreamEvent } from '../src/contracts'
afterEach(() => vi.restoreAllMocks())
function event(seq: number): StreamEvent {
return { seq, type: 'message.delta', runId: 'run', conversationId: 'conversation', ts: '2026-10-15T07:00:00Z', v: 1, payload: { index: 0, text: `已保存输出${seq}` } }
}
it('reads a complete saved record across the 500-event boundary in order', async () => {
const query = vi.spyOn(api, 'runEventsPage')
.mockResolvedValueOnce({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
.mockResolvedValueOnce({ events: [event(501), event(502)] })
const controller = new AbortController()
const result = await loadRunEvents('conversation', 'run', controller.signal)
expect(result.map(item => item.seq)).toEqual(Array.from({ length: 502 }, (_, i) => i + 1))
expect(query).toHaveBeenNthCalledWith(1, 'conversation', 'run', 0, controller.signal)
expect(query).toHaveBeenNthCalledWith(2, 'conversation', 'run', 500, controller.signal)
})
it('stops an invalid nonadvancing record cursor instead of fetching forever', async () => {
const query = vi.spyOn(api, 'runEventsPage').mockResolvedValue({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
await expect(loadRunEvents('conversation', 'run', new AbortController().signal)).rejects.toThrow('cursor did not advance')
expect(query).toHaveBeenCalledTimes(2)
})
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { scrollRatio, scrollTopForRatio } from '../src/scrollsync'
describe('scrollsync', () => {
it('没有溢出时不同步', () => {
expect(scrollRatio({ scrollTop: 0, scrollHeight: 100, clientHeight: 100 })).toBeNull()
expect(scrollTopForRatio({ scrollHeight: 80, clientHeight: 80 }, 0.5)).toBeNull()
})
it('按可滚动范围的比例映射,并夹在 0 到 1', () => {
expect(scrollRatio({ scrollTop: 50, scrollHeight: 200, clientHeight: 100 })).toBe(0.5)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 0.5)).toBe(100)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 2)).toBe(200)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, -1)).toBe(0)
})
})
-24
View File
@@ -1,24 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
afterEach(() => vi.restoreAllMocks())
describe('versioned work request bodies', () => {
it('preserves multiple selections and free input on the card action endpoint', async () => {
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ card: { id: 'task' } })))
await api.cardAction('task', 'work.submit', { resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/cards/task/actions/work.submit')
expect(JSON.parse(String(options?.body))).toEqual({ resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
})
it('sends a result identity separately from the user message and discards client display metadata', async () => {
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ message: { id: 'reply' } })))
const reference = { cardId: 'task', resultVersion: 3, title: '说明书整理', result: 'must-not-be-client-context' }
await api.sendMessage('conversation', '比较这些重点', [], 'request-1', reference)
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual({
text: '比较这些重点', attachmentIds: [], requestId: 'request-1', taskRef: { cardId: 'task', resultVersion: 3 },
})
})
})
-99
View File
@@ -43,42 +43,7 @@ beforeEach(async () => {
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('preserves rejected referenced input without retrying it or blocking later queued chat', async () => {
const reference = { cardId: 'saved-result', resultVersion: 1, title: '整理结果' }
const submit = vi.spyOn(api, 'sendMessage').mockRejectedValueOnce(new Error('400: reference no longer valid'))
.mockResolvedValue({ message: { id: 'accepted', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
expect(await store.sendMessage('请解释重点', ['saved-upload'], 'retry-safe-id', reference)).toBe('rejected')
const failed = store.getState().outbox[0]
expect(failed).toMatchObject({ id: 'retry-safe-id', text: '请解释重点', attachmentIds: ['saved-upload'], taskRef: reference })
expect(failed.deliveryError).toBeTruthy()
await store.flushOutbox()
expect(submit).toHaveBeenCalledTimes(1)
store.enqueueOutbox('先讨论其他事情', [], [])
await store.flushOutbox()
expect(submit).toHaveBeenCalledTimes(2)
expect(store.getState().outbox).toHaveLength(1)
store.clearOutboxReference(failed.id)
await store.sendOutboxNow(failed.id)
expect(submit).toHaveBeenLastCalledWith('a', '请解释重点', ['saved-upload'], 'retry-safe-id')
expect(store.getState().outbox).toEqual([])
})
describe('work projection ordering and context', () => {
it('keeps an earlier background task out of a newer foreground process', async () => {
const current: Run = { id: 'new-topic', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
const child: Run = { ...current, id: 'old-child', kind: 'subagent', parentRunId: 'old-topic' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [current, child]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在回答新的问题' }, current.id))
store.applyEvent(event(2, 'agent.progress', { agentRun: child.id, note: '旧材料仍在整理' }, child.id))
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
store.applyEvent(event(3, 'agent.spawn', { agentRun: 'new-child', name: '新任务', task: '核对新问题' }, current.id))
store.applyEvent(event(4, 'agent.progress', { agentRun: 'new-child', note: '核对当前问题' }, 'new-child'))
expect(store.getState().live?.agentNotes['new-child']).toBe('核对当前问题')
store.applyEvent(event(5, 'agent.result', { agentRun: child.id, summary: '旧材料已整理' }, child.id))
expect(store.getState().live?.runId).toBe(current.id)
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
})
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
vi.useFakeTimers()
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
@@ -283,70 +248,6 @@ describe('work projection ordering and context', () => {
})
describe('shared action feedback', () => {
it('sends a versioned optional choice once and keeps it out of the conversation', async () => {
const task = { ...card('work-choice', 'done'), work: { resultVersion: 2, readVersion: 0,
result: '完整材料结果', decision: { mode: 'multiple' as const, state: 'open' as const } } }
store.upsertCard(task)
const response = deferred<Awaited<ReturnType<typeof api.cardAction>>>()
const action = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const payload = { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '请保留来源' }
const pending = store.cardAction(task, 'work.submit', payload)
await store.cardAction(task, 'work.submit', payload)
expect(action).toHaveBeenCalledOnce()
expect(action).toHaveBeenCalledWith(task.id, 'work.submit', payload)
response.resolve({ card: { ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } },
receipt: { id: 'unexpected-work-receipt', conversationId: 'a', role: 'assistant', parts: [], createdAt: '' } })
await pending
expect(store.getState().messages).toEqual([])
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
expect(store.getState().cards.find(c => c.id === task.id)?.work?.decision?.state).toBe('submitted')
})
it('does not unlock an uncertain optional submission merely because its result is done', async () => {
const task = { ...card('work-unknown', 'done'), work: { resultVersion: 1, readVersion: 0,
result: '结果', decision: { mode: 'single' as const, state: 'open' as const } } }
store.upsertCard(task)
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
vi.mocked(api.listCards).mockResolvedValue([task])
await store.cardAction(task, 'work.submit', { resultVersion: 1, selectedIds: ['summary'] })
expect(store.getState().cardActionStates[task.id]).toBe('unknown')
store.upsertCard({ ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } })
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
})
it('settles successful metadata actions while an ordinary card still needs action', async () => {
const task = { ...card('work-read', 'action_required'), work: { resultVersion: 1, readVersion: 0 } }
store.upsertCard(task)
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...task, work: { ...task.work, readVersion: 1 } } })
await store.cardAction(task, 'work.read', { resultVersion: 1 })
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
})
it('retains an explicit result reference across a queued send without adding it to chat text', async () => {
const task = { ...card('work-ref', 'done'), work: { result: '完整结果', resultVersion: 3, readVersion: 3 } }
store.upsertCard(task)
store.referenceWork(task)
const reference = store.getState().workReference!
store.enqueueOutbox('比较重点', [], [], reference)
store.clearWorkReference()
expect(store.getState().outbox[0].taskRef).toEqual({ cardId: 'work-ref', resultVersion: 3, title: '整理材料' })
const send = vi.spyOn(api, 'sendMessage').mockResolvedValue({ message: { id: 'queued-ref', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
await store.flushOutbox()
expect(send).toHaveBeenCalledWith('a', '比较重点', [], expect.any(String), reference)
expect(store.getState().workReference).toBeNull()
})
it('clears record selection and result reference when navigating to another conversation', async () => {
const task = { ...card('work-record', 'done'), sourceRunId: 'child', work: { result: '完整结果', resultVersion: 1, readVersion: 1 } }
store.upsertCard(task)
store.openWorkRecord(task)
store.referenceWork(task)
expect(store.getState().workRecord?.runId).toBe('child')
await store.openConversation('b')
expect(store.getState().workRecord).toBeNull()
expect(store.getState().workReference).toBeNull()
})
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }
+1 -26
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
@@ -72,29 +72,4 @@ describe('rdmAuditReady', () => {
expect(rdmAuditReady(null)).toBe(false)
expect(rdmAuditReady(undefined)).toBe(false)
})
it('导出命令不参与七命令就绪门', () => {
expect(rdmAuditReady(all)).toBe(true)
const withExportOff = [
...all,
{ id: 'rdm.inspect.export.create', enabled: false },
]
expect(rdmAuditReady(withExportOff)).toBe(true)
})
})
describe('rdmExportReady', () => {
const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true }))
it('三条导出命令启用才显示导出区', () => {
expect(rdmExportReady([...audit, ...exp])).toBe(true)
expect(rdmExportReady(audit)).toBe(false)
expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false)
})
it('正式报告不完整时才要求草稿确认', () => {
expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true)
expect(exportNeedsDraft('项目不存在')).toBe(false)
})
})