Compare commits

...
Author SHA1 Message Date
weichen fac99939fa Move AdminZoneSection out of the generated contracts mirror
Why: src/contracts.ts is fully regenerated from Core by make contracts;
the hand-added interface there is deleted on every sync (surfaced as a
dirty tree in the Dev composite test run).
Evidence: npx vitest run 101/101, tsc --noEmit clean, knip 42 <= 43;
Dev composite CI is authoritative.
Impact: AdminZoneSection now lives in api.ts (backend-only endpoint
type, matching existing local-type precedent); contracts.ts restored to
the generated mirror; imports updated in store and AdminZone.
Refs: WP3-I3; follows Frontend PR #63.
2026-10-12 02:49:46 +00:00
weichen c1225bb880 Merge pull request 'Host plugin-declared sections in the admin zone (WP3-I3)' (#63) from feature/adminzone-plugin-sections into main 2026-10-12 02:39:56 +00:00
weichen 57fabf1a95 Merge authoritative main into the admin-zone branch 2026-10-12 02:37:49 +00:00
weichen 360d6406e4 Merge pull request 'Refresh me on auth state so the admin entry survives reloads' (#61) from feature/me-refresh-on-auth into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/61
2026-10-12 02:35:35 +00:00
weichen 25e76c5e01 Host plugin-declared sections in the admin zone
Why: KTD4 requires the RDM audit partition to be reachable from the admin
zone by RDM domain admins and base superadmins, without hardcoding RDM
into the base AdminZone or merging roles in the frontend.
Evidence: npx vitest run 101/101 green (incl. design-guard), tsc --noEmit
clean, knip 42 <= 43; Dev composite CI is authoritative.
Impact: AdminZone merges admin-only builtin sections with sections
declared by compiled plugins (GET /api/admin-zone/sections, loaded in
refreshPlugins); the entry menu shows when isAdmin OR declared sections
exist; declared sections render via an id-keyed builtin component map
(rdm.audit -> RdmAuditWorkspace) with a version-mismatch placeholder for
unknown ids; unknown icons fall back to the generic grid icon. Token and
color usage unchanged.
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:34:04 +00:00
weichen 41f6ce8760 Merge pull request 'Add the workspace task-page container with pin and project aggregation (U6)' (#62) from feature/generative-ui-u6 into main 2026-10-12 02:17:00 +00:00
weichen 0f860c0743 Add the workspace task-page container with pin and project aggregation (U6)
Why: U6 brings generated pages into the work view - a compact
task-page section renders the latest conversation surface under the
current-task card (SurfaceView), a pin freezes the shown page against
new arrivals (unpin follows latest), a jump button opens the full
preview, and the project-level aggregate loads on conversation open
for cross-session visibility; when the plugin is disabled or no pages
exist the section disappears and the work view is untouched.

Evidence: tsc clean; vitest 101/101 including the pin/follow test;
knip unchanged.

Impact: Frontend-only; consumes the U6 backend aggregate endpoint.

Refs: proposal r2 U6; Backend #163; Dev task feature/generative-ui-u6
2026-10-12 02:16:57 +00:00
weichen fdedaa1123 Refresh me on auth state so the admin entry survives reloads
Why: store.me was only written by the forced-change gate's onDone
(refreshMe sole caller), so after any page reload or a normal
re-login me stayed null and the admin menu (me?.isAdmin gate in
TaskSidebar/AdminZone) vanished permanently - reported repeatedly as
the user-management page becoming unavailable.

Evidence: vitest 100/100 pass and tsc+vite build clean after the
change; grep shows refreshMe had exactly one call site before
(App.tsx mustchange onDone).

Impact: admins keep the system-management entry across reloads and
normal logins; no behavior change for non-admins (me loads but
isAdmin=false).

Refs: dev task card feature/fe-me-refresh; companion Dev-repo pin
bump.
2026-10-12 02:09:14 +00:00
weichen 4ab507b1b6 Merge pull request 'Render compiled plugin entries in the admin list (WP3-I4)' (#60) from feature/plugins-compiled-listing into main 2026-10-12 02:04:41 +00:00
weichen 01b853fd0e Render compiled plugin entries in the admin plugins list
Why: /api/plugins now merges compiled-in plugins (backend.mode=inproc);
the list needed to badge them as builtin, count their commands, and stop
printing a dangling "v" when the synthesized manifest has no version.
Evidence: npx vitest run 100/100 green incl. design-guard; tsc --noEmit
clean for src (../ui errors are the symlinked-deps environment only);
CI (make test) is authoritative.
Impact: PluginsSection label hides empty versions, desc adds a command
count and falls back to manifest intent counts, and inproc entries get a
"内置" badge next to the api badge. No token or color changes.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:45:02 +00:00
weichen 7acfef0a1f Assert the count element instead of the concatenated card text
Why: card text renders as num+label (e.g. 0切片), so whole-text regexes never matched.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:58:12 +00:00
weichen c22120d7fe Accept the explicit empty state on the trace path
Why: seeded sections carry no confirmed trace edges yet, so the no-relations empty state is the correct U4 presentation.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:57:23 +00:00
weichen 15d8a4093b Expand the hierarchical docs tree and aggregate sections for tracing
Why: rdm.inspect.tree docs is two-level (parent=<docId> returns sections), but the U4 workspace only fetched level one - the tree showed bare documents, clicking a doc hit unsupported inspect.object kind, and the trace tab always showed no sections.

Evidence: tsc + design-guard green; scripts/e2e.sh full run in this task.

Impact: tree browsing expands documents into sections; trace entry lists sections aggregated per document.

Refs: U4/U3 read-service contract; task feature/rdm-e2e-acceptance.
2026-10-11 17:56:39 +00:00
weichen d2d81e119c Pick a section node for the forensic path
Why: the first docs-tree node is document-level and carries no event history; U4 evidence-finding targets sections.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:54:25 +00:00
weichen fb846bed14 Pin summary-card assertions to exact labels
Why: hasText('章节') also matches the 空缺章节 card, tripping Playwright strict mode.

Evidence: rerun of scripts/e2e.sh in this task.

Impact: test-only.
2026-10-11 17:53:40 +00:00
weichen 566fec9f56 Register the audit workspace builtin so its entry is reachable
Why: buildWorkspaceEntries pushed only BUILTINS[0] (agents); the U4
rdm-audit workspace was declared but never ranked, so no user could
open the trace/audit workspace from the sidebar.

Evidence: vitest workspaces.test.ts 4/4 (updated expectations plus a
new overflow-truncation case); full browser run in scripts/e2e.sh.

Impact: sidebar gains the second builtin tool; server-declared
workspaces keep their shortcut precedence.

Refs: U4 (Dev #368) regression; task feature/rdm-e2e-acceptance.
2026-10-11 17:52:27 +00:00
weichen 98751916da Add Playwright E2E harness for the RDM audit workspace
Why: U4 acceptance requires real-browser runs of the trace/audit
workspace; the repo had only a manual .playwright.ts stub gated on
PLAYWRIGHT_BASE_URL with no config, deps, or service wiring.

Evidence: config/deps/spec staged; full run driven by Dev-side
scripts/e2e.sh (seed-only smoke in progress in this task branch).

Impact: dev-only additions (@playwright/test devDep, preview proxy
segment, e2e/rdm-audit.playwright.ts); no production bundle changes.

Refs: Dev task feature/rdm-e2e-acceptance; U4 in
modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md
2026-10-11 17:47:01 +00:00
weichen ed9845f8e4 Merge pull request 'Dedupe same-name artifacts into a latest-plus-versions group in the files panel' (#59) from fix/artifact-version-dedupe into main 2026-10-11 16:03:32 +00:00
weichen a2ebc7fb84 Dedupe same-name artifacts into a latest-plus-versions group in the files panel
Why: A single run re-registering one deliverable left three identical
report files side by side (storage is append-only by design and the
prompt even teaches revisions-as-new-versions); the panel listed every
row. Per the owner ruling, storage keeps all versions - the panel now
groups by name, shows only the latest row, badges multi-version groups
with the count, and expands (Collapsible primitive) to list history
with per-version preview/download.

Evidence: tsc clean; vitest 99/99 including new grouping tests (latest
selection by updatedAt, ordering, cross-name independence, empty);
knip unchanged.

Impact: Frontend-only display logic; no storage, API, or prompt
changes.

Refs: Dev task fix/artifact-version-dedupe
2026-10-11 16:03:29 +00:00
weichen 367aeefff4 Merge pull request 'Make generated pages visible: auto-switch preview, restore entry, panel section' (#58) from fix/genui-page-visibility into main 2026-10-11 15:56:26 +00:00
weichen 65d3b8ef02 Make generated pages visible: auto-switch preview, restore entry, panel section
Why: Field evidence - surfaces arrived and tabs opened in state but the
screen showed nothing: the event branch missed syncFilesEntry (right
panel stayed on work tab, collapsed, or list view), refresh/reentry had
no way back (openSurface was dead code), and the files panel listed no
pages.

Evidence: tsc clean; vitest 97/97 including new cases (event switches
rightTab=files + preview view; suppressed path keeps projection
consistent); knip unchanged.

Impact: Frontend-only behavior fixes; no API changes.

Refs: Dev task fix/genui-page-visibility
2026-10-11 15:56:23 +00:00
weichen ed7e3126e7 Merge pull request 'Add the RDM audit workspace and fix surface action types' (#57) from feature/rdm-u4-audit-workspace into main 2026-10-11 15:04:45 +00:00
weichen aaced8796b Fix the surface action callback to match SurfaceView's single-parameter signature
Why: U5 (#367) introduced a two-parameter callback but SurfaceView (@ui) expects (id: string) => void; TypeScript build fails on main.

Evidence: SELF tsc --noEmit zero errors after fix.

Impact: PreviewPane.tsx one-line type fix; no behavior change (payload path dormant until @ui widens the interface).

Refs: Dev #367
2026-10-11 14:49:31 +00:00
weichen d0d9876b18 Merge remote-tracking branch 'origin/main' into feature/rdm-u4-audit-workspace 2026-10-11 14:43:49 +00:00
weichen 2c56054861 Merge pull request 'Submit page actions from the preview pane (U5)' (#56) from feature/generative-ui-u5 into main 2026-10-11 14:24:05 +00:00
weichen c2878c04ea Submit page actions from the preview pane (U5)
Why: U5 closes the interactive loop - the surface view's action click
carries the user's typed drafts (and nothing else - unsubmitted input
has no upload path), the store builds the idempotent request
(client-generated requestId, current revision), guards double-clicks
in flight, and maps server outcomes to toasts (awaiting-confirm card,
revision conflict refresh, disabled rejection).

Evidence: tsc clean; vitest 95/95 including the request-shape test
(explicit data only, unique requestId per submission); knip unchanged.

Impact: Frontend-only; backend endpoint from the U5 backend branch.

Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task
feature/generative-ui-u5
2026-10-11 14:24:01 +00:00
weichen b80153dbf8 Add the RDM audit workspace with four entries consuming U3 inspect commands
Why: the master plan U4 requires a four-entry traceability audit workspace as a builtin Dev frontend workspace; U3 rdm.inspect.* commands are the server contract.

Evidence: SELF tsc --noEmit zero errors in new files; design guard compliance verified (tokens only, no raw colors, no inline fontSize, no details element); CI (GATE) will run full suite.

Impact: RdmAuditWorkspace.tsx (new, ~500 lines), api.ts (+rdmCommand), Workspaces.tsx (+rdm-audit dispatch), workspaces.ts (+builtin), styles.css (+rdm-audit-* using only @ui tokens).

Refs: Documents eb36571 master plan U4; U3 rdm.inspect.* contract
2026-10-11 14:07:48 +00:00
weichen 39dc8c456c Merge pull request 'Clear stale API-key credentials when a password session begins' (#55) from feature/login-clear-stale-key into main 2026-10-11 14:04:59 +00:00
25 changed files with 1176 additions and 48 deletions
+3
View File
@@ -6,3 +6,6 @@ dist/
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
test-results/
playwright-report/
+4 -2
View File
@@ -1,10 +1,12 @@
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
import { test, expect } from '@playwright/test'
test('command menu and chat share builtin.help', async ({ page }) => {
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
await expect(page.getByText('帮助')).toBeVisible()
})
+126
View File
@@ -0,0 +1,126 @@
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
// E2E_PROJECT_MAIN 已 ingest 的主项目名
// E2E_PROJECT_ISOLATED 零数据隔离项目名
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
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(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
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()
}
// 登录成功后侧栏体系工具出现「追溯审计」入口。
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
}
async function openAudit(page: Page) {
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
await expect(page.getByText('追溯审计').first()).toBeVisible()
await expect(page.locator('.rdm-audit-project')).toBeVisible()
}
test.describe.serial('U4 项目追溯审计工作区', () => {
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
const card = (page: Page, label: string) =>
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
await login(page)
await openAudit(page)
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(card(page, '章节')).toContainText(/[1-9]/)
})
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
await login(page)
await openAudit(page)
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').first().click()
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
await expect(sectionNode).toBeVisible()
await sectionNode.click()
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
await expect(page.getByText('最近事件')).toBeVisible()
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
await page.getByRole('tab', { name: '关联追溯' }).click()
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
await page.locator('.rdm-audit-pick button').first().click()
await expect(page.getByText('← 返回')).toBeVisible()
await expect(
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
).toBeVisible()
})
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '操作记录' }).click()
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
await expect(
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
).not.toHaveCount(0)
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
await page.getByRole('button', { name: '历史模式' }).click()
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
await page.getByRole('button', { name: '← 返回当前' }).click()
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
})
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
await login(page)
await openAudit(page)
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
// 空态:文档树与时间线给出可理解提示。
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.getByText('暂无文档')).toBeVisible()
await page.getByRole('tab', { name: '操作记录' }).click()
await expect(page.getByText('暂无事件')).toBeVisible()
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
await page.getByRole('tab', { name: '概览' }).click()
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(page.locator('.rdm-audit-project')).toHaveValue(
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
})
+46
View File
@@ -19,6 +19,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -1212,6 +1213,22 @@
"win32"
]
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2749,6 +2766,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
"preview": "vite preview",
"e2e": "playwright test"
},
"dependencies": {
"docx-preview": "^0.4.1",
@@ -21,6 +22,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
+20
View File
@@ -0,0 +1,20 @@
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
import { defineConfig } from '@playwright/test'
const base = process.env.PLAYWRIGHT_BASE_URL || ''
export default defineConfig({
testDir: 'e2e',
testMatch: /.*(spec|playwright)\.ts$/,
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [['list']],
use: {
baseURL: base,
locale: 'zh-CN',
trace: 'retain-on-failure',
},
})
+4
View File
@@ -68,6 +68,10 @@ export function App() {
useEffect(() => {
if (authState !== 'ok') return
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
+24
View File
@@ -9,6 +9,11 @@ import type {
const BASE = ''
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text()
@@ -31,6 +36,9 @@ export const api = {
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
@@ -110,6 +118,7 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
@@ -127,6 +136,21 @@ export const api = {
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
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' },
body: JSON.stringify({ params }),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T
}),
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { groupArtifactsByName } from './artifactGrouping'
import type { Artifact } from './contracts'
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
}
describe('groupArtifactsByName', () => {
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
const g = groupArtifactsByName([
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
])
expect(g).toHaveLength(2)
const grp = g.find(x => x.latest.name === '报告.docx')!
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
expect(grp.versions).toHaveLength(3)
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
})
it('不同名互不影响;空列表返回空', () => {
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
expect(g).toHaveLength(2)
expect(groupArtifactsByName([])).toEqual([])
})
})
+23
View File
@@ -0,0 +1,23 @@
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
import type { Artifact } from './contracts'
export interface ArtifactGroup {
latest: Artifact
versions: Artifact[]
}
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
const byName = new Map<string, Artifact[]>()
for (const a of list) {
const arr = byName.get(a.name) ?? []
arr.push(a)
byName.set(a.name, arr)
}
const out: ArtifactGroup[] = []
for (const versions of byName.values()) {
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
out.push({ latest: versions[0], versions })
}
return out
}
+49 -9
View File
@@ -1,10 +1,15 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
import { useState } from 'react'
import { store, useStore } from '../store'
import { Icon, type IconName } from '@ui'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
key: string
@@ -27,12 +32,47 @@ const SECTIONS: AdminSectionDef[] = [
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
'rdm.audit': () => <RdmAuditWorkspace />,
}
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
function declaredIcon(name?: string): IconName {
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
}
function DeclaredSection({ section }: { section: AdminZoneSection }) {
const render = DECLARED_COMPONENTS[section.id]
if (!render) {
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
}
return <>{render()}</>
}
export function AdminZone() {
const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
if (!me?.isAdmin) return null
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
const isAdmin = !!me?.isAdmin
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS : []),
...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
render: () => <DeclaredSection section={s} />,
})),
]
const fallback = sections[0]?.key ?? ''
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
if (!section) return null
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
@@ -40,14 +80,14 @@ export function AdminZone() {
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span>
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
<div className="az-body">
<nav className="az-side">
{SECTIONS.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
{sections.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
+74 -17
View File
@@ -3,17 +3,24 @@
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
import { Icon } from '@ui'
import { Collapsible, Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
import type { Artifact } from '../contracts'
import { groupArtifactsByName } from '../artifactGrouping'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const surfaces = useStore(s => s.surfaces)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
const groups = groupArtifactsByName(visible)
return (
<div className="artifacts-panel">
@@ -32,26 +39,47 @@ export function ArtifactsPanel() {
}} />
</label>
</div>
{surfaces.length > 0 && (
<div className="ap-surfaces" data-ap-surfaces>
<div className="ap-section-title">生成页面</div>
{surfaces.map(sv => (
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
<span className="ar-icon"><Icon name="grid" size={15} /></span>
<span className="ar-main">
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
</span>
</div>
))}
</div>
)}
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
: groups.map(g => g.versions.length === 1
? <ArtifactRow key={g.latest.id} a={g.latest} />
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
trigger={
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
</div>
}>
{g.versions.slice(1).map(v => (
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
<span className="ar-icon"><Icon name="clock" size={13} /></span>
<span className="ar-main">
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
</span>
<span className="ar-acts">
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
</div>
))}
</Collapsible>)}
</div>
)
}
@@ -65,3 +93,32 @@ function shortTime(iso: string): string {
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
function ArtifactRow({ a }: { a: Artifact }) {
return (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<ArtifactRowInner a={a} />
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
)
}
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
return (
<>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
</>
)
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function PreviewPane() {
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
+505
View File
@@ -0,0 +1,505 @@
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, EmptyState, SectionLabel } from '@ui'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
interface InspectProject { projectId: string; name: string; canAudit: boolean }
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
interface CoverageItem { face: string; reason: string }
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
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 HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
// ---- 工作区主组件 ----
const TABS = [
{ id: 'summary', label: '概览' },
{ id: 'materials', label: '资料与文档' },
{ id: 'trace', label: '关联追溯' },
{ id: 'timeline', label: '操作记录' },
] as const
type TabId = (typeof TABS)[number]['id']
export function RdmAuditWorkspace() {
const [caps, setCaps] = useState<Capabilities | null>(null)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.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 }
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
if (error) return <EmptyState icon="alert">{error}</EmptyState>
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
<div className="rdm-audit">
<div className="rdm-audit-header">
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
{caps.projects.map(p => (
<option key={p.projectId} value={p.projectId}>{p.name}</option>
))}
</select>
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
</div>
<div className="tabs rdm-audit-tabs" role="tablist">
{TABS.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id}
className={`tab${tab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
</div>
)
}
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let c = false
setData(null); setErr('')
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
.then(d => { if (!c) setData(d) })
.catch(e => { if (!c) setErr(e.message) })
return () => { c = true }
}, [pid])
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (!data) return <div className="empty-note">加载中…</div>
const counts = data.counts || {}
const entries: [string, string, number][] = [
['切片', 'slices', counts.slices ?? 0],
['章节', 'sections', counts.sections ?? 0],
['文档', 'docs', counts.docs ?? 0],
['生效边', 'edgesActive', counts.edgesActive ?? 0],
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
['结论', 'conclusions', counts.conclusions ?? 0],
['待办', 'openItems', counts.openItems ?? 0],
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
['空缺章节', 'emptySections', counts.emptySections ?? 0],
]
return (
<div>
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
<div className="rdm-audit-cards">
{entries.map(([label, , val]) => (
<div key={label} className="rdm-audit-card">
<div className="rdm-audit-card-num">{val}</div>
<div className="rdm-audit-card-label">{label}</div>
</div>
))}
</div>
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
</div>
)
}
// ---- 资料与文档 ----
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
return (
<div className="rdm-audit-materials">
<div className="rdm-audit-materials-side">
<div className="tabs">
<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} />
</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 genRef = useRef(0)
useEffect(() => {
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
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 <EmptyState icon="alert">{err}</EmptyState>
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
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>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
</div>
)}
</div>
)
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
const [truncated, setTruncated] = useState(false)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
if (!sel) return
const gen = ++genRef.current
setRelations(null); setErr('')
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
useEffect(() => {
if (sel) return
let c = false
;(async () => {
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
const secs: TreeItem[] = []
for (const d of top.items.filter(i => i.kind === 'doc')) {
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
secs.push(...sub.items.filter(i => i.kind === 'section'))
}
if (!c) setSections(secs)
})().catch(() => {})
return () => { c = true }
}, [pid, sel])
if (!sel) {
return (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
<div className="rdm-audit-pick">
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
</button>
))}
</div>
)}
</div>
)
}
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
return (
<div>
<div className="rdm-audit-trace-head">
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
<strong>{sel.title}</strong>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
</div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{!relations && !err && <div className="empty-note">加载中…</div>}
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
{relations && relations.length > 0 && (
<div className="rdm-audit-trace-cols">
<div className="rdm-audit-trace-col">
<SectionLabel>来源依据({sources.length})</SectionLabel>
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
<div className="rdm-audit-trace-col">
<SectionLabel>影响对象({impacts.length})</SectionLabel>
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
</div>
)}
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)
}
function RelationRow({ r }: { r: RelationEntry }) {
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
return (
<div className="rdm-audit-rel">
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
</div>
)
}
// ---- 操作记录(事件时间线) ----
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
const [hasMore, setHasMore] = useState(false)
const [err, setErr] = useState('')
const [filterOp, setFilterOp] = useState('')
const [filterActor, setFilterActor] = useState('')
const [historyMode, setHistoryMode] = useState(false)
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
if (replace) { setItems([]); setErr('') }
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
if (filterOp) params.op = filterOp
if (filterActor) params.actor = filterActor
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
.then(s => {
if (gen !== genRef.current) return
setItems(prev => replace ? s.items : [...prev, ...s.items])
setCursor(s.nextCursor || '')
setHasMore(s.hasMore)
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, filterOp, filterActor])
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, historyMode, historySeq])
return (
<div className="rdm-audit-timeline">
<div className="rdm-audit-timeline-filters">
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
onChange={e => setFilterActor(e.target.value)} />
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
onChange={e => setFilterOp(e.target.value)} />
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
{historyMode ? '← 返回当前' : '历史模式'}
</button>
{historyMode && (
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
)}
</div>
{historyMode ? (
<div>
{!historyData ? <div className="empty-note">加载中…</div> : (
<div>
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
{historyData.object ? (
<div className="rdm-audit-detail-body">
<div>标题:{historyData.object.title}</div>
<div>状态:{historyData.object.state}</div>
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
</div>
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
{historyData.formal && (
<div className="rdm-audit-formal">
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
</div>
)}
</div>
)}
</div>
) : (
<div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
{items.map((item, i) => {
if ('events' in item && Array.isArray((item as OpGroup).events)) {
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
}
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
})}
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
</div>
)}
</div>
)
}
function OpGroupView({ group }: { group: OpGroup }) {
const [open, setOpen] = useState(false)
return (
<div className="rdm-audit-opgroup">
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">{group.events.length} 个事件</Chip>
</button>
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)
}
function EventRow({ ev }: { ev: EventDTO }) {
return (
<div className="rdm-audit-event">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
<span className="rdm-audit-event-op">{ev.op}</span>
<span className="rdm-audit-event-actor">{ev.actor}</span>
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
</div>
)
}
// ---- 对象详情视图 ----
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
return (
<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.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 && (
<div>
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
{detail.versions.map((v: any, i: number) => (
<div key={i} className="rdm-audit-version">
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
</div>
))}
</div>
)}
{detail.formal && (
<div className="rdm-audit-formal">
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
</div>
)}
{detail.relations && detail.relations.length > 0 && (
<div>
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
)}
{detail.recentEvents && detail.recentEvents.length > 0 && (
<div>
<SectionLabel>最近事件</SectionLabel>
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)}
</div>
)
}
// ---- 覆盖范围标注(不冒充完整) ----
function CoverageList({ cov }: { cov: CoverageItem[] }) {
return (
<div className="rdm-audit-coverage">
<SectionLabel>覆盖范围</SectionLabel>
{cov.map(c => (
<div key={c.face} className="rdm-audit-coverage-item">
<Chip tone="plain">{c.face}</Chip>
<span>{c.reason}</span>
</div>
))}
</div>
)
}
+2 -1
View File
@@ -40,6 +40,7 @@ export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const adminSections = useStore(s => s.adminSections)
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
@@ -265,7 +266,7 @@ export function TaskSidebar() {
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+32 -1
View File
@@ -4,7 +4,7 @@
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
@@ -115,6 +115,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</div>
</>
)
@@ -172,3 +173,33 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
</div>
)
}
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
function WorkspaceSurfaces() {
const surfaces = useStore(s => s.surfaces)
const pin = useStore(s => s.ui.workspaceSurfacePin)
if (surfaces.length === 0) return null
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
if (!shown) return null
return (
<div className="workspace-surfaces" data-workspace-surfaces>
<div className="ws-head">
<span className="ws-title">任务页面</span>
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
<span style={{ flex: 1 }} />
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
{pin ? '📍' : '📌'}
</button>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
↗
</button>
</div>
<div className="ws-body">
<SurfaceView snapshot={shown} />
</div>
</div>
)
}
+6
View File
@@ -7,6 +7,7 @@ import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
export function workspaceEntries(): WorkspaceEntry[] {
@@ -28,6 +29,11 @@ export function Workspaces() {
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active === 'rdm-audit') {
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
<RdmAuditWorkspace />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+12 -3
View File
@@ -193,9 +193,18 @@ export function PluginsSection() {
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
desc={[
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
`面板 ${p.panels?.length ?? 0}`,
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
`工作流 ${p.workflows?.length ?? 0}`,
].join(' · ')}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</span>
</SettingRow>
))}
</div>
+68 -7
View File
@@ -1,7 +1,7 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { type AdminZoneSection, api, connectEvents } from './api'
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 } from './outbox'
@@ -27,6 +27,7 @@ import type {
Project,
Run,
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList
} from './contracts'
@@ -58,6 +59,8 @@ export interface UIState {
rightTab: 'work' | 'files'
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
filesView: 'list' | 'preview'
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
workspaceSurfacePin: string | null
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
@@ -99,6 +102,8 @@ interface AppState {
artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[]
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
projectSurfaces: SurfaceMeta[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
@@ -107,6 +112,8 @@ interface AppState {
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState
connected: boolean
/** 发送请求已经出去,run 记录还没回来。 */
@@ -125,7 +132,7 @@ const DEFAULT_UI: UIState = {
rightWidth: 360,
rightCollapsed: false,
rightTab: 'work',
filesView: 'list',
filesView: 'list', workspaceSurfacePin: null,
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
@@ -162,12 +169,15 @@ function loadUI(): UIState {
return { ...DEFAULT_UI }
}
const surfaceActionsInflight: Record<string, boolean> = {}
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: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
@@ -372,7 +382,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], 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 })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
@@ -383,7 +393,24 @@ export const store = {
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(list => {
if (state.currentConvId !== convId) return
set({ surfaces: list ?? [] })
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
const latest = list[0]
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
}).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
},
@@ -516,8 +543,9 @@ export const store = {
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
// 已打开的其他对象;KTD「新结果不抢占」)。
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const activeId = state.previewActive[conv] ?? null
@@ -528,6 +556,9 @@ export const store = {
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncFilesEntry(true)
} else {
store.toast(`页面已更新:${p.spec?.title || id}`)
}
}
break
@@ -756,6 +787,10 @@ export const store = {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
try {
const sections = await api.adminZoneSections()
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
const runtime = settings.user.runtime
@@ -899,6 +934,32 @@ export const store = {
})
syncFilesEntry(true)
},
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
if (inflight) return
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
surfaceActionsInflight[surfaceId + ':' + actionId] = true
try {
const res = await api.submitSurfaceAction(surfaceId, {
actionId, revision: snap.revision, requestId, data,
})
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
else if (res?.state === 'committed') store.toast('已提交')
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
} catch (e: any) {
const msg = String(e?.message ?? e)
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
else store.toast('动作失败:' + msg)
} finally {
delete surfaceActionsInflight[surfaceId + ':' + actionId]
}
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
+51
View File
@@ -577,3 +577,54 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.session-note { width: 12rem; }
.rewind-actions { display: flex; gap: 8px; }
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-tabs { padding: 0 0 10px; }
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-more { margin-top: 8px; }
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.rdm-audit-trace-col { min-width: 0; }
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
.rdm-audit-rel-type { color: var(--text-dim); }
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
.rdm-audit-seq { max-width: 100px; }
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
.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-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
+38
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { store } from './store'
import { previewTabsKey } from './previewtabs'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
@@ -37,3 +38,40 @@ describe('surface.updated projection', () => {
})
})
describe('surface.updated 可见性联动', () => {
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
const ui = store.getState().ui
expect(ui.rightTab).toBe('files')
expect(ui.rightCollapsed).toBe(false)
expect(ui.filesView).toBe('preview')
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
})
it('抑制开标签时留 toast 痕迹', () => {
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
})
})
describe('U6 工作区任务页', () => {
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
store.setUI({ workspaceSurfacePin: null })
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
const first = store.getState().ui.workspaceSurfacePin
expect(first).toBeNull() // 默认跟随(不自动 pin)
// 用户固定 w1
store.setUI({ workspaceSurfacePin: 'w1' })
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
expect(w2?.spec.title).toBe('第二页')
store.setUI({ workspaceSurfacePin: null })
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
return {
actionId,
revision: snapshot.revision,
requestId,
data: drafts,
}
}
describe('surface action request', () => {
it('携带当前修订与显式草稿;requestId 唯一', () => {
const snap = { revision: 3 }
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
expect(a.revision).toBe(3)
expect(a.actionId).toBe('generative-ui.submit')
expect(a.data).toEqual({ f1: { years: '5' } })
expect(a.requestId).not.toBe(b.requestId)
})
})
+2
View File
@@ -18,6 +18,7 @@ export interface WorkspaceSource {
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
]
function shortcutNum(raw?: string): number | null {
@@ -56,6 +57,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
return 99
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
+16 -6
View File
@@ -1,19 +1,20 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,代理补在 ⌘1', () => {
it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace')
})
@@ -21,6 +22,15 @@ describe('buildWorkspaceEntries', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
})
it('九位占满时低序内置保留,溢出项截断', () => {
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
const entries = buildWorkspaceEntries(server)
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
expect(entries.map(e => e.title)).toContain('代理')
expect(entries.map(e => e.title)).not.toContain('追溯审计')
})
})
+8
View File
@@ -18,5 +18,13 @@ export default defineConfig({
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
},
},
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
preview: {
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
proxy: {
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
},
},
test: { environment: 'node' },
})