Compare commits

..
Author SHA1 Message Date
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
20 changed files with 422 additions and 39 deletions
+3
View File
@@ -6,3 +6,6 @@ dist/
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库) # mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa .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: // 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。 // 新内置命令未写入 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' import { test, expect } from '@playwright/test'
test('command menu and chat share builtin.help', async ({ page }) => { 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 page.goto(process.env.PLAYWRIGHT_BASE_URL!)
await expect(page.getByText('帮助')).toBeVisible() 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" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4", "@types/node": "^22.20.4",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
@@ -1212,6 +1213,22 @@
"win32" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2749,6 +2766,35 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"test": "vitest run", "test": "vitest run",
"preview": "vite preview" "preview": "vite preview",
"e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"docx-preview": "^0.4.1", "docx-preview": "^0.4.1",
@@ -21,6 +22,7 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4", "@types/node": "^22.20.4",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^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(() => { useEffect(() => {
if (authState !== 'ok') return if (authState !== 'ok') return
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
store.refreshMe()
store.refreshProjects() store.refreshProjects()
store.refreshProfiles() store.refreshProfiles()
store.refreshPlugins() store.refreshPlugins()
+3 -1
View File
@@ -1,7 +1,7 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth' import { auth } from './auth'
import type { import type { AdminZoneSection,
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard, ActivityCard,
@@ -33,6 +33,7 @@ export const api = {
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(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> }) => 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)), 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)), 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)), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)---- // ---- 认证与会话(管理员发放制,R2)----
@@ -112,6 +113,7 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()), listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).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()), 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 }[]>, 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}`) }), 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}`) }),
+49 -9
View File
@@ -1,10 +1,15 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。 // 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。 // 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。 // 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
import { useState } from 'react' import { useState } from 'react'
import { store, useStore } from '../store' 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 { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import type { AdminZoneSection } from '../contracts'
interface AdminSectionDef { interface AdminSectionDef {
key: string key: string
@@ -27,12 +32,47 @@ const SECTIONS: AdminSectionDef[] = [
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> }, { 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() { export function AdminZone() {
const me = useStore(s => s.me) const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const [sectionKey, setSectionKey] = useState('users') const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。 const isAdmin = !!me?.isAdmin
if (!me?.isAdmin) return null // 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0] // 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
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 ( return (
<div className="az" role="dialog" aria-label="系统管理"> <div className="az" role="dialog" aria-label="系统管理">
<div className="az-head"> <div className="az-head">
@@ -40,14 +80,14 @@ export function AdminZone() {
<Icon name="chevron-down" size={14} /> 返回工作台 <Icon name="chevron-down" size={14} /> 返回工作台
</button> </button>
<span className="az-title">系统管理</span> <span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span> <span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span> <span className="az-tenant">{me?.owner ?? ''}</span>
</div> </div>
<div className="az-body"> <div className="az-body">
<nav className="az-side"> <nav className="az-side">
{SECTIONS.map(s => ( {sections.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}> <button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} /> <Icon name={s.icon} size={15} />
<span>{s.label}</span> <span>{s.label}</span>
</button> </button>
+49 -11
View File
@@ -144,11 +144,14 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null) const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | 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) const genRef = useRef(0)
useEffect(() => { useEffect(() => {
const gen = ++genRef.current const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null) setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' }) api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) }) .then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) }) .catch(e => { if (gen === genRef.current) setErr(e.message) })
@@ -162,7 +165,19 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) }) .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) => { const selectNode = (item: TreeItem) => {
if (item.kind === 'doc') { toggleDoc(item); return }
setSelected(item); setDetail(null) setSelected(item); setDetail(null)
const gen = genRef.current const gen = genRef.current
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id }) api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
@@ -178,12 +193,28 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
<div className="rdm-audit-tree"> <div className="rdm-audit-tree">
<div className="rdm-audit-tree-list"> <div className="rdm-audit-tree-list">
{shell.items.map(item => ( {shell.items.map(item => (
<button key={item.id} className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`} <div key={item.id}>
onClick={() => selectNode(item)}> <button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
<span className="rdm-audit-tree-kind">{item.kind}</span> onClick={() => selectNode(item)}>
<span className="rdm-audit-tree-title">{item.title}</span> {item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>} <span className="rdm-audit-tree-kind">{item.kind}</span>
</button> <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.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>} {shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
@@ -216,14 +247,21 @@ function TraceTab({ pid }: { pid: string }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) }) .catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel]) }, [pid, sel])
// 未选对象时列出章节供选择 // 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([]) const [sections, setSections] = useState<TreeItem[]>([])
useEffect(() => { useEffect(() => {
if (sel) return if (sel) return
let c = false let c = false
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' }) ;(async () => {
.then(s => { if (!c) setSections(s.items.filter(i => i.kind === 'section')) }) const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
.catch(() => {}) 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 } return () => { c = true }
}, [pid, sel]) }, [pid, sel])
+2 -1
View File
@@ -40,6 +40,7 @@ export function TaskSidebar() {
const projects = useStore(s => s.projects) const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks) const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me) const me = useStore(s => s.me)
const adminSections = useStore(s => s.adminSections)
const artifacts = useStore(s => s.artifacts) const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId) const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId) 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 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: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</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 && <> {me?.auth && <>
<div className="tsb-menu-div" /> <div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem> <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 { store, useStore } from '../store'
import type { Run } from '../contracts' import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui' 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 { previewTabsKey } from '../previewtabs'
import { ArtifactsPanel } from './ArtifactsPanel' import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane' import { PreviewPane } from './PreviewPane'
@@ -115,6 +115,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
{inboxScope === 'project' {inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} /> ? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />} : <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</div> </div>
</> </>
) )
@@ -172,3 +173,33 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
</div> </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>
)
}
+12 -3
View File
@@ -193,9 +193,18 @@ export function PluginsSection() {
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>} {plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => ( {plugins.map(p => (
<SettingRow key={p.manifest.id} <SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`} label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}> desc={[
<span className="effect-badge">api {p.manifest.apiVersion}</span> `工具 ${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> </SettingRow>
))} ))}
</div> </div>
+7
View File
@@ -288,6 +288,13 @@ export interface PluginManifest {
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number }; backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
} }
// ---- 管理区声明分区(WP3-I3/KTD4)----
/** 编译插件经 Registry.AddAdminZone 声明的管理区展示分区;入口不做前端权限合并,
* 分区内数据能力由组件自身的命令面 fail-closed。 */
export interface AdminZoneSection {
id: string; title: string; icon?: string; desc?: string; order?: number;
}
export interface PluginPanel { export interface PluginPanel {
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string; id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
} }
+17 -4
View File
@@ -15,7 +15,7 @@ const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
export function isFontSizeChoice(value: number): value is FontSizeChoice { export function isFontSizeChoice(value: number): value is FontSizeChoice {
return (FONT_SIZE_CHOICES as readonly number[]).includes(value) return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
} }
import type { import type { AdminZoneSection,
ActivityCard, ActivityCard,
AgentProfile, AgentProfile,
Artifact, Artifact,
@@ -27,6 +27,7 @@ import type {
Project, Project,
Run, Run,
StreamEvent, StreamEvent,
SurfaceMeta,
SurfaceSnapshot, SurfaceSnapshot,
TodoList TodoList
} from './contracts' } from './contracts'
@@ -58,6 +59,8 @@ export interface UIState {
rightTab: 'work' | 'files' rightTab: 'work' | 'files'
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */ /** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
filesView: 'list' | 'preview' filesView: 'list' | 'preview'
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
workspaceSurfacePin: string | null
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */ /** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo' inboxFilter: 'all' | 'todo'
theme: ThemeChoice theme: ThemeChoice
@@ -99,6 +102,8 @@ interface AppState {
artifacts: Artifact[] artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */ /** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[] surfaces: SurfaceSnapshot[]
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
projectSurfaces: SurfaceMeta[]
cards: ActivityCard[] cards: ActivityCard[]
projectCards: ActivityCard[] projectCards: ActivityCard[]
inboxScope: 'conv' | 'project' inboxScope: 'conv' | 'project'
@@ -107,6 +112,8 @@ interface AppState {
profiles: AgentProfile[] profiles: AgentProfile[]
pluginPanels: PluginPanel[] pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState ui: UIState
connected: boolean connected: boolean
/** 发送请求已经出去,run 记录还没回来。 */ /** 发送请求已经出去,run 记录还没回来。 */
@@ -125,7 +132,7 @@ const DEFAULT_UI: UIState = {
rightWidth: 360, rightWidth: 360,
rightCollapsed: false, rightCollapsed: false,
rightTab: 'work', rightTab: 'work',
filesView: 'list', filesView: 'list', workspaceSurfacePin: null,
inboxFilter: 'all', inboxFilter: 'all',
activeWorkspace: null, activeWorkspace: null,
theme: 'dark', theme: 'dark',
@@ -168,8 +175,9 @@ let state: AppState = {
contextObs: {}, contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: 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: [], serverWorkspaces: [],
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null, ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewTabs: {}, previewActive: {},
@@ -374,7 +382,7 @@ export const store = {
async openConversation(convId: string) { async openConversation(convId: string) {
if (sse) { sse.close(); sse = null } 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) persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。 // 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) { if ((state.previewTabs['_'] ?? []).length > 0) {
@@ -385,6 +393,7 @@ export const store = {
}) })
} }
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。 // U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(list => { api.surfaceList(convId).then(list => {
if (state.currentConvId !== convId) return if (state.currentConvId !== convId) return
set({ surfaces: list ?? [] }) set({ surfaces: list ?? [] })
@@ -778,6 +787,10 @@ export const store = {
const ws = await api.listWorkspaces() const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] }) set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ } } catch { /* ignore */ }
try {
const sections = await api.adminZoneSections()
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try { try {
const settings = await api.getSettings() const settings = await api.getSettings()
const runtime = settings.user.runtime const runtime = settings.user.runtime
+2
View File
@@ -596,6 +596,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.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 { 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:hover { background: var(--bg-hover); color: var(--text); }
.rdm-audit-tree-node.sel { background: var(--bg-active); 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-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-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-more { margin-top: 8px; } .rdm-audit-more { margin-top: 8px; }
+18
View File
@@ -57,3 +57,21 @@ describe('surface.updated 可见性联动', () => {
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true) 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()
})
})
+1
View File
@@ -57,6 +57,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
return 99 return 99
} }
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] }) 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 }) for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked return ranked
.filter(item => item.rank <= 9) .filter(item => item.rank <= 9)
+16 -6
View File
@@ -1,19 +1,20 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces' import { buildWorkspaceEntries } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。 // 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
describe('buildWorkspaceEntries', () => { describe('buildWorkspaceEntries', () => {
it('没有领域插件时,代理补在 ⌘1', () => { it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
const entries = buildWorkspaceEntries([]) 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 的工作区占住该位,内置工具让位', () => { it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
const entries = buildWorkspaceEntries([ const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' }, { id: 'trace', title: '追溯网', shortcut: '1' },
]) ])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理']) expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2']) expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace') expect(entries[0].key).toBe('ws:trace')
}) })
@@ -21,6 +22,15 @@ describe('buildWorkspaceEntries', () => {
const entries = buildWorkspaceEntries([ const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' }, { 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 }, '/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' }, test: { environment: 'node' },
}) })