diff --git a/e2e/changepw-form.playwright.ts b/e2e/changepw-form.playwright.ts index d186727..5f8ee9a 100644 --- a/e2e/changepw-form.playwright.ts +++ b/e2e/changepw-form.playwright.ts @@ -17,14 +17,21 @@ async function login(page: Page) { await page.getByPlaceholder('由管理员分配').fill(OWNER) await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD) await page.getByRole('button', { name: '登录', exact: true }).click() - const gate = page.getByText('修改密码') + // 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名), + // 门挂载后登录卡已卸载,fill 不再与登录卡竞态。 + const gate = page.getByRole('heading', { name: '设置新密码' }) 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(0).fill(PASSWORD2) await pwInputs.nth(1).fill(PASSWORD2) - await pwInputs.nth(2).fill(PASSWORD2) - await page.getByRole('button', { name: '设置新密码并进入' }).click() + await page.getByRole('button', { name: '设置新密码', exact: true }).click() + // 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。 + await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('button', { name: '返回登录', exact: true }).click() + await page.getByPlaceholder('由管理员分配').fill(OWNER) + await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) + await page.getByRole('button', { name: '登录', exact: true }).click() } else { await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) await page.getByRole('button', { name: '登录', exact: true }).click() diff --git a/e2e/login-key.playwright.ts b/e2e/login-key.playwright.ts new file mode 100644 index 0000000..04e3a84 --- /dev/null +++ b/e2e/login-key.playwright.ts @@ -0,0 +1,112 @@ +// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/ +// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key → +// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 → +// 新密码登录进工作台;重复兑换同一 Key 被拒。 +// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE; +// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡 +// feature/svc-login-recovery-flow 的真实浏览器验收记录)。 +import { test, expect, type Page } from '@playwright/test' + +const ADMIN = process.env.E2E_ADMIN || 'e2e-admin' +const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026' +const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b' +const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user' +const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b' + +test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE), + 'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)') + +async function passwordLogin(page: Page, owner: string, password: string) { + await page.goto('/') + await page.getByPlaceholder('由管理员分配').fill(owner) + await page.getByPlaceholder('初始密码或常用密码').fill(password) + await page.getByRole('button', { name: '登录', exact: true }).click() +} + +/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。 + * 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */ +async function adminLogin(page: Page) { + await passwordLogin(page, ADMIN, ADMIN_BOOT) + // 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位, + // getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。 + let gate = page.getByRole('heading', { name: '设置新密码' }) + let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) + if (!gated) { + // boot 密码失败(此前运行已完成改密):换正式密码重登。 + const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false) + if (!loginErr) throw new Error('admin login neither gated nor errored') + await passwordLogin(page, ADMIN, ADMIN_PW) + gate = page.getByRole('heading', { name: '设置新密码' }) + gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) + } + if (gated) { + const pw = page.locator('.lg-card input[type="password"]') + await pw.nth(0).fill(ADMIN_PW) + await pw.nth(1).fill(ADMIN_PW) + await page.getByRole('button', { name: '设置新密码', exact: true }).click() + // 等设密事务完成再回登录:提交在途时点取消会登出受限会话、 + // 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。 + await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('button', { name: '返回登录', exact: true }).click() + await passwordLogin(page, ADMIN, ADMIN_PW) + } + await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 }) +} + +test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => { + // 全流程含多次 argon2 登录与两道门,30s 默认预算不够。 + test.setTimeout(120_000) + // 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。 + await adminLogin(page) + await page.locator('.tsb-user').click() + await page.getByRole('button', { name: '系统管理…' }).click() + const zone = page.getByRole('dialog', { name: '系统管理' }) + await expect(zone).toBeVisible() + // 每个用户行都有同款按钮:限定目标用户所在行。 + await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click() + await zone.getByRole('button', { name: '生成 Key', exact: true }).click() + const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]') + await expect(keyInput).toBeVisible({ timeout: 10_000 }) + const key = await keyInput.inputValue() + expect(key).toMatch(/^alk_/) + await expect(zone.getByText(/单次有效/)).toBeVisible() + + // 2) 管理员退出系统管理与登录态,回到登录页。 + //(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。) + await zone.getByRole('button', { name: '返回工作台' }).click() + await page.locator('.tsb-user').click() + await page.getByRole('button', { name: '退出登录' }).click() + await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 }) + + // 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。 + await page.getByRole('tab', { name: '一次性登录 Key' }).click() + await page.getByPlaceholder('alk_…').fill(key) + await page.getByRole('button', { name: '兑换并设置新密码' }).click() + + // 4) 受限设密门:服务端确认用户名;无当前密码输入框。 + const gate = page.locator('.lg-card') + await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 }) + await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0) + const pw = gate.locator('input[type="password"]') + await pw.nth(0).fill(USER_PW) + await pw.nth(1).fill(USER_PW) + await gate.getByRole('button', { name: '设置新密码', exact: true }).click() + + // 5) 成功页:提示用新密码登录,唯一出口回登录页。 + await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('button', { name: '返回登录', exact: true }).click() + await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible() + + // 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。 + await passwordLogin(page, USER, USER_PW) + await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 }) + + // 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。 + await page.locator('.tsb-user').click() + await page.getByRole('button', { name: '退出登录' }).click() + await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('tab', { name: '一次性登录 Key' }).click() + await page.getByPlaceholder('alk_…').fill(key) + await page.getByRole('button', { name: '兑换并设置新密码' }).click() + await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 }) +}) diff --git a/e2e/rdm-duties.playwright.ts b/e2e/rdm-duties.playwright.ts new file mode 100644 index 0000000..388c882 --- /dev/null +++ b/e2e/rdm-duties.playwright.ts @@ -0,0 +1,160 @@ +// Real React/Picker interaction with deterministic HTTP responses; no live account or database. +import { test, expect, type Page } from '@playwright/test' + +test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server') + +function deferred() { + let resolve!: () => void + const promise = new Promise(ok => { resolve = ok }) + return { promise, resolve } +} + +async function dutiesPage(page: Page) { + const writes: { command: string; params: Record }[] = [] + const calls: { command: string; params: Record }[] = [] + const holds = new Map; release: ReturnType }>() + const active = new Set() + const submissions: Record = { 'doc-A': null, 'doc-B': null } + const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', + 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', + 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status'] + const docs = [ + { docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' }, + { docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' }, + ] + await page.route('**/api/**', async route => { + const request = route.request() + const path = new URL(request.url()).pathname + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true } + else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '', + settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }] + else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} } + else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true })) + else if (path.startsWith('/api/commands/')) { + const command = decodeURIComponent(path.substring('/api/commands/'.length)) + const params = request.postDataJSON().params as Record + expect(params.projectId).toBe('domain-7') + calls.push({ command, params }) + const hold = holds.get(`${command}:${params.docId ?? ''}`) + if (hold) { hold.started.resolve(); await hold.release.promise } + let payload: unknown = {} + if (command === 'rdm.duty.catalog') payload = { docs } + else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) } + else if (command === 'rdm.duty.get') { + const doc = docs.find(d => d.docId === params.docId)! + payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned, + roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], + active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null } + } else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] } + else { writes.push({ command, params }) } + body = { payload } + } else if (path.endsWith('/cards')) body = { cards: [] } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) + }) + await page.goto('/') + await page.getByRole('button', { name: '项目设置', exact: true }).click() + await page.getByRole('tab', { name: /文档职责/ }).click() + await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible() + return { writes, calls, active, submissions, + hold(command: string, docId: string) { + const pending = { started: deferred(), release: deferred() } + holds.set(`${command}:${docId}`, pending) + return pending + } } +} + +async function chooseMember(page: Page, label: string, owner: string) { + await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner) + await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click() +} + +async function settleResponse(page: Page, command: string, release: () => void) { + const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`)) + release() + await response + await page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done())))) +} + +test('a newly assigned document can submit its first whole-document review', async ({ page }) => { + const fixture = await dutiesPage(page) + await page.getByRole('button', { name: '文档A', exact: true }).click() + await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible() + const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true }) + await expect(submit).toBeEnabled() + await submit.click() + await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible() + expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }]) +}) + +test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => { + const fixture = await dutiesPage(page) + fixture.active.add('doc-A') + const dialogs: string[] = [] + let accept = false + page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() }) + await page.getByRole('button', { name: '文档A', exact: true }).click() + await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible() + const save = page.getByRole('button', { name: '保存当前职责', exact: true }) + await expect(save).toBeDisabled() + expect(dialogs).toEqual([]) + await chooseMember(page, '编制人', 'bob') + await expect(save).toBeEnabled() + expect(dialogs).toEqual([]) + await save.click() + expect(dialogs).toHaveLength(1) + expect(fixture.writes).toEqual([]) + accept = true + await save.click() + await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() + expect(dialogs).toHaveLength(2) + expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { + projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', + } }]) +}) + +test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => { + const fixture = await dutiesPage(page) + const old = fixture.hold('rdm.duty.get', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('button', { name: '文档B', exact: true }).click() + await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() + await settleResponse(page, 'rdm.duty.get', old.release.resolve) + await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() + expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) + await chooseMember(page, '批准人', 'alice') + await page.getByRole('button', { name: '保存当前职责', exact: true }).click() + await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() + expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { + projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', + } }]) +}) + +test('a late review status cannot replace the selected document frozen review', async ({ page }) => { + const fixture = await dutiesPage(page) + const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author, + createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} }) + fixture.submissions['doc-A'] = submission(1, 'alice') + fixture.submissions['doc-B'] = submission(2, 'bob') + const old = fixture.hold('rdm.review.doc.status', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('button', { name: '文档B', exact: true }).click() + await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() + await settleResponse(page, 'rdm.review.doc.status', old.release.resolve) + await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0) +}) + +test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => { + const fixture = await dutiesPage(page) + const old = fixture.hold('rdm.duty.get', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click() + await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0) + await settleResponse(page, 'rdm.duty.get', old.release.resolve) + expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) +}) diff --git a/e2e/rdm-export-races.playwright.ts b/e2e/rdm-export-races.playwright.ts new file mode 100644 index 0000000..eb73084 --- /dev/null +++ b/e2e/rdm-export-races.playwright.ts @@ -0,0 +1,107 @@ +// Isolated browser component regression: actual React workspace, deliberately deferred API responses. +// This checks client request ownership, not server authorization or business acceptance. +import { test, expect, type Page } from '@playwright/test' + +test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server') +test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL }) + +async function mount(page: Page, delayed: string) { + const index = await page.request.get('/') + const html = (await index.text()).replace(/]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '') + // Let Vite prepare ReactDOM before requesting its optimized module directly. + await page.request.get('/src/main.tsx') + await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html', + body: html })) + await page.goto('/__export_test') + await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function') + await page.evaluate(async delayed => { + const load = (path: string) => import(/* @vite-ignore */ path) + const { default: React } = await load('/node_modules/.vite/deps/react.js') + const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js') + const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx') + const { api } = await load('/src/api.ts') + const { auth } = await load('/src/auth.ts') + const { store } = await load('/src/store.ts') + const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts') + const calls: { command: string; params: any }[] = [] + let actor = 'alice' + const pending: (() => void)[] = [] + let downloads = 0 + URL.createObjectURL = () => { downloads++; return 'blob:export-test' } + URL.revokeObjectURL = () => {} + HTMLAnchorElement.prototype.click = () => {} + api.listPlugins = async () => [] + api.listWorkspaces = async () => [] + api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true })) + api.adminZoneSections = async () => [] + api.getSettings = async () => ({ user: {} }) + api.rdmCommand = async (command: string, params: any = {}) => { + calls.push({ command, params }) + const result = command === 'rdm.inspect.capabilities' + ? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) } + : command === 'rdm.inspect.summary' + ? { projectId: params.projectId, counts: {}, watermark: 7 } + : command === 'rdm.inspect.export.create' + ? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' } + : command === 'rdm.inspect.export.status' + ? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' } + : { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' } + if (command === delayed && params.projectId === 'A') { + await new Promise(resolve => pending.push(resolve)) + } + return result + } + await store.refreshPlugins() + const root = ReactDOM.createRoot(document.getElementById('root')!) + root.render(React.createElement(RdmAuditWorkspace)) + ;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()), + downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() }, + unmount: () => root.unmount() } + }, delayed) + await expect(page.locator('.rdm-audit-export')).toBeVisible() +} +async function release(page: Page) { + await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) }) +} +const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click() + +for (const stage of ['create', 'status']) { + test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => { + await mount(page, 'rdm.inspect.export.' + stage) + await generate(page) + await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls + .some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true) + await page.getByLabel('选择项目').selectOption('B') + await expect(page.locator('.rdm-audit-export')).toBeVisible() + await page.getByLabel('选择项目').selectOption('A') + await expect(page.locator('.rdm-audit-export')).toBeVisible() + await release(page) + await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0) + if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls + .filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0) + await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled() + }) +} +for (const change of ['project', 'identity', 'unmount']) { + test(`suppresses delayed download after ${change} change`, async ({ page }) => { + await mount(page, 'rdm.inspect.export.download') + await generate(page) + await page.getByRole('button', { name: '下载', exact: true }).click() + if (change === 'project') { + await page.getByLabel('选择项目').selectOption('B') + await expect(page.locator('.rdm-audit-export')).toBeVisible() + } else if (change === 'identity') { + await page.evaluate(() => (window as any).exportTest.identity()) + await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA') + } else await page.evaluate(() => (window as any).exportTest.unmount()) + await release(page) + expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0) + }) +} +test('current identity can generate and download normally', async ({ page }) => { + await mount(page, '') + await generate(page) + await page.getByRole('button', { name: '下载', exact: true }).click() + await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1) + await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7') +}) diff --git a/e2e/work-companion-live.playwright.ts b/e2e/work-companion-live.playwright.ts new file mode 100644 index 0000000..b3be563 --- /dev/null +++ b/e2e/work-companion-live.playwright.ts @@ -0,0 +1,132 @@ +import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test' +import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts' + +test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner') + +async function openRealConversation(page: Page, request: APIRequestContext) { + const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } }) + expect(projectResponse.ok()).toBe(true) + const project = await projectResponse.json() + const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } }) + expect(conversationResponse.ok()).toBe(true) + const conversation = await conversationResponse.json() + await page.addInitScript(({ projectId, conversationId }) => { + localStorage.setItem('as.conv', conversationId) + localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId })) + }, { projectId: project.id, conversationId: conversation.id }) + await page.goto('/') + await expect(page.locator('.chat-title')).toHaveText('真实接口验收') + return { project, conversation } +} + +test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => { + const { project, conversation } = await openRealConversation(page, request) + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + const uploadResponses: Response[] = [] + const uploadPath = `/api/projects/${project.id}/uploads` + const observeUpload = (response: Response) => { + if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response) + } + page.on('response', observeUpload) + await page.locator('input[type=file]').first().setInputFiles([ + { name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议决定:先明确交付日期') }, + { name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求:整理材料并生成摘要') }, + { name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审意见:补充验收标准') }, + ]) + await expect(page.locator('.attach-chip')).toHaveCount(3) + page.off('response', observeUpload) + expect(uploadResponses).toHaveLength(3) + const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => { + expect(response.ok()).toBe(true) + return response.json() + })) + expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt']) + const attachmentIds = uploads.map(upload => upload.artifactId) + for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/)) + expect(new Set(attachmentIds).size).toBe(3) + const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`) + expect(artifactsResponse.ok()).toBe(true) + const artifacts: Artifact[] = await artifactsResponse.json() + expect(artifacts).toHaveLength(3) + expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort()) + const expectedParts: Part[] = uploads.map(upload => { + const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)! + expect(artifact.name).toBe(upload.filename) + return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } } + }) + const messagePath = `/api/conversations/${conversation.id}/messages` + const [sendRequest, sendResponse] = await Promise.all([ + page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath), + page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath), + page.getByRole('button', { name: '发送', exact: true }).click(), + ]) + const sent = sendRequest.postDataJSON() + expect(sent.text).toBe('') + expect(sent.attachmentIds).toEqual(attachmentIds) + expect(sendResponse.ok()).toBe(true) + const accepted: { message: Message; run: Run } = await sendResponse.json() + expect(accepted.message.role).toBe('user') + expect(accepted.message.conversationId).toBe(conversation.id) + expect(accepted.message.parts).toEqual(expectedParts) + expect(accepted.run.kind).toBe('conversation') + await expect.poll(async () => { + const response = await request.get(`/api/conversations/${conversation.id}`) + expect(response.ok()).toBe(true) + const current: { runs: Run[] } = await response.json() + return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed') + }).toBe(true) + async function persistedMessage() { + const response = await request.get(`/api/conversations/${conversation.id}`) + expect(response.ok()).toBe(true) + const current: { messages: Message[] } = await response.json() + const users = current.messages.filter(message => message.role === 'user') + expect(users).toHaveLength(1) + expect(users[0].id).toBe(accepted.message.id) + expect(users[0].conversationId).toBe(conversation.id) + expect(users[0].parts).toEqual(expectedParts) + return users[0] + } + const beforeReload = await persistedMessage() + await page.reload() + await expect(page.locator('.chat-title')).toHaveText('真实接口验收') + const afterReload = await persistedMessage() + expect(afterReload).toEqual(beforeReload) + const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id, + uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })), + sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload } + await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' }) + console.log('real-material-associations', JSON.stringify(association)) + await page.getByRole('button', { name: '打开文件', exact: true }).click() + await expect(page.locator('[data-artifact-row]')).toHaveCount(3) + await page.locator('[data-artifact-row]').first().click() + await expect(page.locator('.preview-pane-name')).toBeVisible() + expect(errors).toEqual([]) +}) + +test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => { + const { conversation } = await openRealConversation(page, request) + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true }) + // Backend's deterministic runner exercises the real run/card/event lifecycle; + // this is not a live-model quality or stage-findings test. + await input.fill('#background') + await page.getByRole('button', { name: '发送', exact: true }).click() + await expect.poll(async () => { + const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json()) + return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status)) + }, { timeout: 15000 }).toBe(true) + await expect(page.locator('[data-conversation-work]')).toBeVisible() + await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible() + await input.fill('#hitl') + await page.getByRole('button', { name: '发送', exact: true }).click() + await expect(page.locator('.hitl-card')).toBeVisible() + await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click() + await expect(page.locator('.hitl-card')).toHaveCount(0) + await expect.poll(async () => { + const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json()) + return current.activeHitl.length + }).toBe(0) + expect(errors).toEqual([]) +}) diff --git a/e2e/work-companion-navigation.playwright.ts b/e2e/work-companion-navigation.playwright.ts new file mode 100644 index 0000000..de01844 --- /dev/null +++ b/e2e/work-companion-navigation.playwright.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from '@playwright/test' +import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts' + +test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview') + +const stamp = '2026-10-12T10:00:00Z' +const conversation: Conversation = { + id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料', + createdAt: stamp, updatedAt: stamp, +} +const artifact: Artifact = { + id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown', + size: 64, createdAt: stamp, updatedAt: stamp, +} +const snapshot: SurfaceSnapshot = { + identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id }, + compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1, + spec: { title: '已保存的材料页面', nodes: [ + { id: 'summary', type: 'markdown', markdown: '已保存的材料范围' }, + { id: 'source', type: 'doc-ref', docId: artifact.id }, + ] }, +} +const metadata: SurfaceMeta = { + surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId, + revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp, +} + +// Delay the external list contract, while running the real store, App and panes. +// The saved selection is restored by openConversation rather than test-only state injection. +async function fixture(page: Page) { + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + let release!: () => void + const hydration = new Promise(resolve => { release = resolve }) + await page.addInitScript(({ conversationId }) => { + localStorage.setItem('as.conv', conversationId) + localStorage.setItem('as.ui', JSON.stringify({ + theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true, + })) + }, { conversationId: conversation.id }) + await page.route('**/api/**', async route => { + const request = route.request() + const path = new URL(request.url()).pathname + if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' }) + if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' }) + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false } + else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }] + else if (path === '/api/conversations') body = [conversation] + else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 } + else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] } + else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot + else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] } + else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact] + else if (path === `/api/artifacts/${artifact.id}/content`) { + return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' }) + } else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] } + else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区', + body: [{ type: 'doc-ref', docId: artifact.id }] }] + else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) + }) + const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`) + await page.goto('/') + await listRequest + await expect(page.locator('.chat-title')).toHaveText(conversation.title) + return { release, errors } +} + +for (const width of [736, 390]) { + test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + const state = await fixture(page) + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true }) + await search.fill('材料') + await expect(search).toBeFocused() + state.release() + // The real hidden preview proves both restored tab selection and snapshot hydration finished. + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context') + await expect(page.locator('.left-nav')).toBeVisible() + await expect(page.locator('.main')).toBeHidden() + await expect(page.locator('.right-panel')).toBeHidden() + await expect(search).toHaveValue('材料') + await expect(search).toBeFocused() + expect(state.errors).toEqual([]) + }) + + test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + const state = await fixture(page) + state.release() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.getByRole('button', { name: '打开工作', exact: true }).click() + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work') + await expect(page.locator('.right-panel')).toBeVisible() + await expect(page.locator('.left-nav')).toBeHidden() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.getByRole('button', { name: '打开文件', exact: true }).click() + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files') + await page.getByRole('button', { name: '返回文件列表', exact: true }).click() + await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + // Surface doc-ref uses the existing document bridge, which opens the same preview. + await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click() + await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name) + await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围') + await page.getByRole('button', { name: '展开预览', exact: true }).click() + await expect(page.locator('.right-panel')).toHaveClass(/expanded/) + await page.getByRole('button', { name: '返回对话', exact: true }).click() + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation') + await expect(page.locator('.main')).toBeVisible() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.setViewportSize({ width: 1440, height: 900 }) + await expect(page.locator('.main')).toBeVisible() + await page.setViewportSize({ width, height: 900 }) + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation') + await expect(page.locator('.left-nav')).toBeHidden() + expect(state.errors).toEqual([]) + }) + + test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + const state = await fixture(page) + state.release() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + await page.getByRole('button', { name: '打开文件', exact: true }).click() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click() + const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true }) + await workspace.getByRole('button', { name: artifact.name, exact: true }).click() + await page.keyboard.press('Escape') + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files') + await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name) + await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围') + expect(state.errors).toEqual([]) + }) +} diff --git a/e2e/work-companion.playwright.ts b/e2e/work-companion.playwright.ts new file mode 100644 index 0000000..d3e7211 --- /dev/null +++ b/e2e/work-companion.playwright.ts @@ -0,0 +1,228 @@ +import { test, expect, type Page } from '@playwright/test' +import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts' + +test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview') + +const stamp = '2026-10-12T10:00:00Z' +const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp } +const conversations: Conversation[] = [ + { id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp }, + { id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp }, +] +const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp } +function work(id: string, status: ActivityCard['status'], over: Partial = {}): ActivityCard { + return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over } +} +const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [ + ['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'], +] }] }) +const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿', + body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }], + actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }], +}) + +async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) { + let cards = [...initialCards] + let actionCount = 0 + let releaseAction: (() => void) | undefined + let holdAction = false + let sent: { text: string; attachmentIds: string[] } | undefined + let uploadNumber = 0 + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + await page.addInitScript(({ fontSize }) => { + localStorage.setItem('as.conv', 'c1') + localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 })) + const streams: { url: string; emit: (event: unknown) => void }[] = [] + class TestEventSource { + onopen: (() => void) | null = null + onerror = null + onmessage: ((event: { data: string }) => void) | null = null + listeners: Record void)[]> = {} + constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) } + addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) } + emit(event: unknown) { + const value = event as { type: string } + for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) }) + } + close() {} + } + Object.assign(window, { EventSource: TestEventSource, __workStreams: streams }) + }, { fontSize }) + await page.route('**/api/**', async route => { + const request = route.request() + const url = new URL(request.url()) + const path = url.pathname + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false } + else if (path === '/api/projects') body = [project] + else if (path === '/api/conversations') body = conversations + else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1] + else if (/^\/api\/conversations\/c[12]$/.test(path)) { + const id = path.split('/').at(-1)! + body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [ + { id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp }, + { id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp }, + ] : [], runs: [], activeHitl: [], lastSeq: 10 } + } else if (path === '/api/conversations/c1/cards') body = { cards } + else if (path === '/api/conversations/c2/cards') body = { cards: [] } + else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null + else if (path === '/api/projects/p1/artifacts') body = [artifact] + else if (path === '/api/artifacts/doc1/content') { + await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return + } else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] } + else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }] + else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 } + else if (/\/messages$/.test(path) && request.method() === 'POST') { + sent = request.postDataJSON() + body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } } + } else if (path === '/api/cards/confirm1/actions/confirm') { + actionCount++ + if (holdAction) await new Promise(resolve => { releaseAction = resolve }) + cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card) + body = { card: cards.find(card => card.id === 'confirm1') } + } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) + }) + await page.goto('/') + await expect(page.locator('.chat-title')).toHaveText('整理项目材料') + if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible() + return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() }, + async emit(type: StreamEvent['type'], payload: unknown, seq: number) { + await page.evaluate(({ type, payload, seq }) => { + const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] } + const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1')) + if (!stream) throw Error('conversation stream unavailable') + stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 }) + }, { type, payload, seq }) + }, + } +} + +test('keeps real summary and details accessible with right pane closed', async ({ page }) => { + const state = await fixture(page) + await expect(page.locator('.right-panel')).toBeHidden() + await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible() + await expect(page.getByText('private-material-key')).toHaveCount(0) + await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click() + await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click() + await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible() + await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12) + await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11) + await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成') + expect(state.errors).toEqual([]) +}) + +test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }) + const state = await fixture(page) + await page.getByRole('button', { name: '打开工作', exact: true }).click() + await expect(page.locator('.right-panel .acard')).toHaveCount(1) + await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0) + await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible() + if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true }) + expect(state.errors).toEqual([]) +}) + +test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }) + const tabbed = work('work1', 'active', { body: [...progress.body!, { + type: 'tabs', id: 'materials', tabs: { tabs: [ + { id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] }, + { id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] }, + ] }, + }] }) + const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })]) + await page.getByRole('button', { name: '打开工作', exact: true }).click() + const card = page.locator('.right-panel [data-card-id="work1"]') + await card.getByRole('button', { name: '查看依据与范围', exact: true }).click() + const tab = card.getByRole('tab', { name: '第二份材料', exact: true }) + await tab.click() + await tab.focus() + const details = card.locator('.acard-detail-body') + await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420) + const physical = await details.evaluate(element => { + Object.assign(window, { __workReading: element }) + element.scrollTop = 137 + return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop } + }) + expect(physical.scrollHeight).toBeGreaterThan(physical.height) + expect(physical.top).toBe(137) + await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12) + await expect(card.locator('.acard-status')).toHaveText('已完成') + await expect(tab).toHaveAttribute('aria-selected', 'true') + expect(await details.evaluate(element => ({ + retained: (window as typeof window & { __workReading: Element }).__workReading === element, + top: element.scrollTop, + }))).toEqual({ retained: true, top: 137 }) + expect(await tab.evaluate(element => document.activeElement === element)).toBe(true) + expect(state.errors).toEqual([]) +}) + +test('shares submitting feedback between conversation and work confirmations', async ({ page }) => { + const state = await fixture(page, [confirmation]) + await page.getByRole('button', { name: '打开工作', exact: true }).click() + state.hold() + await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click() + await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled() + await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled() + expect(state.actionCount).toBe(1) + state.release() + await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0) + expect(state.errors).toEqual([]) +}) + +test('opens the one file preview and preserves reading during background updates', async ({ page }) => { + const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] }) + const state = await fixture(page, [result]) + await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click() + await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name) + await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对') + await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11) + await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name) + await page.getByRole('button', { name: '展开预览', exact: true }).click() + await expect(page.locator('.main')).toBeHidden() + await page.getByRole('button', { name: '返回对话', exact: true }).click() + await expect(page.locator('.main')).toBeVisible() + expect(state.errors).toEqual([]) +}) + +test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => { + const state = await fixture(page, []) + await page.locator('input[type=file]').first().setInputFiles([ + { name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') }, + { name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') }, + { name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') }, + ]) + await expect(page.locator('.attach-chip')).toHaveCount(3) + await page.getByRole('button', { name: '发送', exact: true }).click() + await expect.poll(() => state.sent?.attachmentIds.length).toBe(3) + expect(state.sent?.text).toBe('') + await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排') + await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled() + await page.getByRole('button', { name: '加入队列', exact: true }).click() + await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible() + await expect(page.locator('.input-status')).toContainText('排队') + expect(state.errors).toEqual([]) +}) + +for (const width of [1440, 1024, 736, 390, 320]) { + test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => { + await page.setViewportSize({ width, height: 960 }) + const state = await fixture(page, [progress], 18) + await page.getByRole('button', { name: '打开工作', exact: true }).click() + await expect(page.locator('.right-panel')).toBeVisible() + await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible() + if (width < 1024) { + await page.getByRole('button', { name: '返回对话', exact: true }).click() + await expect(page.locator('.main')).toBeVisible() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible() + await page.getByRole('button', { name: '返回对话', exact: true }).click() + } + const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth })) + expect(layout.scroll).toBeLessThanOrEqual(layout.width) + expect(state.errors).toEqual([]) + }) +} diff --git a/src/App.tsx b/src/App.tsx index ab4dffc..147723f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { SettingsModal } from './components/SettingsModal' import { ProjectSettingsModal } from './components/ProjectSettingsModal' import { TopBar } from './components/TopBar' import { workspaceEntries } from './components/Workspaces' +import { selectWorkbenchLayout } from './workbenchLayout' +import './companion-shell.css' // 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。 // 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。 @@ -24,38 +26,79 @@ const RIGHT_MIN = 280, RIGHT_MAX = 560 const PREVIEW_MIN = 420, PREVIEW_MAX = 1100 export function App() { - useEffect(() => { - const h = (e: KeyboardEvent) => workspaceShortcuts(e) - window.addEventListener('keydown', h, true) - return () => window.removeEventListener('keydown', h, true) - }, []) - const ui = useStore(s => s.ui) // 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开 // 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。 const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0) + const previewOpenIntent = useStore(s => s.previewOpenIntent) const rightTab = useStore(s => s.ui.rightTab) const toast = useStore(s => s.toast) const trackerRunId = useStore(s => s.ui.trackerRunId) // 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。 - const filesWide = rightTab === 'files' && previewOpen - const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth - const previewExpanded = filesWide && ui.previewExpanded + const filesWide = rightTab === 'files' && ui.filesView === 'preview' && previewOpen + const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth) + const [contextOpen, setContextOpen] = useState(false) + const layout = selectWorkbenchLayout({ ...ui, width: viewportWidth, contextOpen, previewOpen }) const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length) // authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。 const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking') const [mustChangeOwner, setMustChangeOwner] = useState('') + useEffect(() => { + const onResize = () => setViewportWidth(window.innerWidth) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + // Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice. + useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent]) + useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact]) + + const returnToConversation = () => { + setContextOpen(false) + store.setUI(layout.compact ? { rightCollapsed: true, previewExpanded: false } : { previewExpanded: false }) + } + const openContext = () => { + if (!layout.compact) store.toggleLeft() + else if (contextOpen) returnToConversation() + else setContextOpen(true) + } + const openWork = () => { + setContextOpen(false) + store.openInbox() + store.setUI({ inboxFilter: 'all', previewExpanded: false }) + } + const openPending = () => { setContextOpen(false); store.focusPending() } + const openFiles = () => { setContextOpen(false); store.openFiles() } + const conversationOpened = () => { if (layout.compact) returnToConversation() } + + // Keep conversation/context shortcuts available even when their pane is hidden. + useEffect(() => { + const h = (e: KeyboardEvent) => { + if (authState === 'ok' && (e.metaKey || e.ctrlKey)) { + const key = e.key.toLowerCase() + if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return } + if (key === 'b') { e.preventDefault(); openContext(); return } + } + workspaceShortcuts(e) + } + window.addEventListener('keydown', h, true) + return () => window.removeEventListener('keydown', h, true) + }, [authState, layout.compact, contextOpen]) + useEffect(() => { ;(async () => { try { const r = await auth.fetch('/api/health') if (r.status === 401) { setAuthState('login'); return } - // 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。 - // 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401), - // key 持有者清 key 回登录页,改走密码登录完成改密。 + // 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定 + //(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立 + // set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的 + // 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必 + // 401),key 持有者清 key 回登录页,改走密码登录完成改密。 const me = await api.me().catch(() => null) - if (me?.mustChangePassword) { + const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword + if (gated) { if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return } setMustChangeOwner(me.owner); setAuthState('mustchange'); return } @@ -71,10 +114,14 @@ export function App() { // me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有 // 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单 // 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。 - store.refreshMe() - store.refreshProjects() - store.refreshProfiles() - store.refreshPlugins() + let active = true + store.refreshMe().then(() => { + if (!active) return + void store.refreshProjects() + void store.refreshProfiles() + void store.refreshPlugins() + }) + return () => { active = false } }, [authState]) const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null) @@ -82,10 +129,10 @@ export function App() { const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => { e.preventDefault() // 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。 - const wide = side === 'right' && rightTab === 'files' && previewOpen + const wide = side === 'right' && filesWide const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth' drag.current = { - side, startX: e.clientX, startW: ui[key], overshoot: false, + side, startX: e.clientX, startW: side === 'left' ? layout.leftWidth : layout.rightWidth, overshoot: false, min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN, max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX, key, @@ -115,15 +162,29 @@ export function App() { window.addEventListener('mouseup', up) } + const onDividerKey = (side: 'left' | 'right') => (e: React.KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return + e.preventDefault() + const wide = side === 'right' && filesWide + const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth' + const min = side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN + const max = side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX + const current = side === 'left' ? layout.leftWidth : layout.rightWidth + const delta = (e.key === 'ArrowRight' ? 16 : -16) * (side === 'left' ? 1 : -1) + store.setUI({ [key]: Math.min(max, Math.max(min, current + delta)) }) + } + if (authState === 'login') { return setAuthState('ok')} onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} /> } if (authState === 'mustchange') { + // 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true: + // 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。 return { await store.refreshMe().catch(() => undefined); setAuthState('ok') }} + onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }} onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} /> } if (authState !== 'ok') { @@ -132,22 +193,20 @@ export function App() { const tools = workspaceEntries() return ( -
- -
- {!ui.leftCollapsed ? ( - <> -
- +
+ +
+ + {layout.leftDivider &&
} - +
+ +
- {!ui.rightCollapsed ? ( - <> -
-
- -
- - ) : ( -
-
- - -
-
- )} + {layout.rightDivider &&
} +
diff --git a/src/api.ts b/src/api.ts index 5ee4594..d8bb8f4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ // API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 import { auth } from './auth' +import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, @@ -14,11 +15,16 @@ const BASE = '' * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } -async function json(res: Response): Promise { +async function checked(res: Response): Promise { if (!res.ok) { const text = await res.text() throw new Error(`${res.status}: ${text}`) } + return res +} + +async function json(res: Response): Promise { + await checked(res) return res.json() as Promise } @@ -26,7 +32,7 @@ export const api = { listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), - deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }), + deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked), listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 @@ -42,20 +48,37 @@ export const api = { projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(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; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + auth.beginSession() + return me }), - logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }), + // 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定; + // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 + redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { + if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + auth.beginSession() + return me + }), + logout: () => { + const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }) + auth.clear() + return request + }, changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> + return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }> }), listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)), createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), - resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), + // ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)---- + adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), + adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), + adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), @@ -116,7 +139,11 @@ export const api = { getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r => json<{ schema: SettingDesc[]; defaults: Record; user: Record; project: Record }>(r)), putUserSettings: (values: Record) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), - putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), + putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked), + // P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。 + getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r => + json<{ schema: SettingDesc[]; defaults: Record; system: Record }>(r)), + putSystemSettings: (values: Record) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()), listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()), @@ -141,6 +168,11 @@ export const api = { // ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ---- // receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。 + rdmProjectCommand: async (project: Project, id: string, params: Record = {}): Promise => { + const error = rdmProjectCommandError(project, id) + if (error) throw new Error(error) + return api.rdmCommand(id, { ...params, projectId: rdmCommandProjectId(project) }) + }, rdmCommand: (id: string, params: Record = {}): Promise => auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/auth.ts b/src/auth.ts index 993728e..0ccf069 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -17,11 +17,22 @@ function pickStorage(): KV { } const storage = pickStorage() +const identityListeners = new Set<() => void>() +function identityChanged() { identityListeners.forEach(listener => listener()) } export const auth = { apiKey(): string { return storage.getItem(KEY) ?? '' }, - setKey(key: string) { storage.setItem(KEY, key.trim()) }, - clear() { storage.removeItem(KEY) }, + setKey(key: string) { + const next = key.trim() + if (next === this.apiKey()) return + storage.setItem(KEY, next) + identityChanged() + }, + clear() { storage.removeItem(KEY); identityChanged() }, + onIdentityChange(listener: () => void) { + identityListeners.add(listener) + return () => { identityListeners.delete(listener) } + }, // 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端 // 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。 beginSession() { this.clear() }, @@ -33,7 +44,7 @@ export const auth = { headers.set('Authorization', `Bearer ${key}`) return fetch(input, { ...init, headers }) }, - // 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。 + // 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。 logout() { this.clear() try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ } diff --git a/src/companion-conversation.css b/src/companion-conversation.css new file mode 100644 index 0000000..5402689 --- /dev/null +++ b/src/companion-conversation.css @@ -0,0 +1,84 @@ +.work-companion-chat .chat-header { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + padding: 12px 20px; +} +.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.work-companion-chat .chat-header > .mk-col-root { position: relative; } +.work-companion-chat .chat-header > .mk-col-root > .mk-col { + position: absolute; + right: 0; + top: calc(100% + 8px); + width: min(28rem, calc(100vw - 40px)); + z-index: 30; +} +.companion-settings-trigger, .companion-operation-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + color: var(--text-dim); + font-size: var(--fs-ui-sm); + border-radius: var(--radius-sm); +} +.companion-settings-trigger:hover, .companion-operation-trigger:hover { background: var(--bg-hover); color: var(--text); } +.companion-settings-body { + padding: 16px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); +} +.companion-setting { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; color: var(--text); font-size: var(--fs-ui-caption); } +.companion-setting > span:first-child { flex: 1; min-width: 8rem; } +.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); } +.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; } +.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; } +.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); } +.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; } +.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; } +.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); } +.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; } +.companion-compose-spacer { flex: 1; } +.companion-add-material { position: relative; display: inline-flex; align-items: center; gap: 4px; padding: 4px; cursor: pointer; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); } +.companion-add-material:hover { background: var(--bg-hover); color: var(--text); } +.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } +.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); } +.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); } +.companion-operation-trigger { margin-bottom: 8px; } +.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); } +.companion-operation-body .companion-setting { margin-bottom: 8px; } +.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; } +.work-companion-chat .input-status-note { color: var(--text-dim); font-size: var(--fs-ui-sm); } +.companion-reconnect, .companion-upload-status, .companion-action-feedback { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); line-height: 1.5; } +.companion-upload-error { padding: 8px 0; color: var(--err-text); font-size: var(--fs-ui-caption); overflow-wrap: anywhere; } +.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; } +.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); } +.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; } +.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; } +.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); } +.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; } +.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; } +.companion-example:hover { color: var(--text); background: var(--bg-hover); } +.conversation-work { margin: 24px 0 16px; min-width: 0; } +.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); } +.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; } +.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; } +.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); } +.conversation-work .acard { margin: 0; } +.work-companion-chat .live-bar { flex-wrap: wrap; } +.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; } +@media (max-width: 540px) { + .work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; } + .companion-input-hint { display: none; } + .companion-empty { min-height: 200px; } + .work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); } +} + +.work-companion-chat .msg.user .bubble { background: var(--bg-active); } +.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; } +.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); } +.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; } +.work-companion-chat .msg-tools { gap: 4px; } diff --git a/src/companion-shell.css b/src/companion-shell.css new file mode 100644 index 0000000..3ee248d --- /dev/null +++ b/src/companion-shell.css @@ -0,0 +1,130 @@ +/* Responsive host chrome; the canonical theme and UI type scale stay in @ui. */ +.app.companion-app { + height: 100dvh; + min-width: 0; + max-width: 100%; +} + +.companion-app .app-body { min-width: 0; overflow: hidden; } +.companion-app .left-nav, +.companion-app .main, +.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; } +.companion-app .left-nav { transition: none; } +.companion-app .left-nav[hidden], +.companion-app .right-panel[hidden], +.companion-app .shell-context-content[hidden], +.companion-app .icon-only[hidden] { display: none !important; } + +.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; } +.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; } +.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; } +.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; } +.companion-topbar .brand-mark { + background: var(--primary-sidebar); + color: var(--primary-sidebar-foreground); + box-shadow: none; + border-radius: var(--radius-sm) !important; +} +.shell-navigation { display: flex; align-items: center; gap: 4px; } +.shell-nav-button, +.shell-return { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 32px; + padding: 4px 12px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-sidebar-dim); + font-size: var(--fs-ui-caption); + cursor: pointer; + white-space: nowrap; +} +.shell-nav-button:hover, +.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); } +.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); } +.shell-nav-button svg, +.shell-return svg { flex: none; } +.shell-back-icon { transform: rotate(180deg); } +.shell-pending { + padding: 0 4px; + border-radius: var(--radius-xs); + background: var(--bg-active); + color: var(--text-sidebar); + font-size: var(--fs-ui-xs); + font-variant-numeric: tabular-nums; +} + +.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; } +.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; } +.companion-app .tsb-projrow:hover { background: var(--bg-hover); } +.companion-app .tsb-projrow-name { flex: 1; } +.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); } +.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; } +.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; } +.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; } +.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; } +.companion-app .tsb-newtask-caret { width: 36px; flex: none; } +.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; } +.companion-app .tsb-orgrow { padding-bottom: 4px; } +.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; } +.companion-app .tsb-task-open { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + gap: 8px; + min-height: 40px; + padding: 4px 0; + border: none; + background: none; + color: inherit; + text-align: left; + cursor: pointer; +} +.companion-app .tsb-task-open > svg { flex: none; } +.companion-app .tsb-task-tm { color: var(--text-dim); } +.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; } +.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; } +.companion-app .tsb-row-menu { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex: none; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-dim); + cursor: pointer; +} +.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); } +.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; } +.companion-app .tsb-tool { min-height: 36px; } +.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); } +.companion-app .tsb-user-sub { color: var(--text-dim); } +.companion-app .tsb-user { min-height: 44px; height: auto; } +.companion-app .tsb-menu { + min-width: min(216px, calc(100vw - 16px)); + max-width: min(360px, calc(100vw - 16px)); + max-height: calc(100dvh - 16px); + overflow-y: auto; +} +.companion-app .tsb-mi { overflow-wrap: anywhere; } + +.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; } +.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); } +.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; } +.companion-app.compact .shell-nav-button > svg { display: none; } +.companion-app.compact .shell-return { min-height: 36px; padding: 4px 8px; font-size: var(--fs-ui-sm); } +.companion-app.compact .left-nav, +.companion-app.compact .main, +.companion-app.compact .right-panel { flex: 1 1 100%; width: 100%; } +.companion-app.compact .tsb-tool { min-height: 40px; } +.companion-app.compact .tsb-footer { padding: 8px 12px; } +.companion-app.compact .tsb-row-acts, +.companion-app.compact .tsb-task:focus-within .tsb-row-acts { display: none; } diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index 87f1846..ae9762a 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -7,7 +7,7 @@ import { useState } from 'react' import { store, useStore } from '../store' import { EmptyState, Icon, type IconName } from '@ui' -import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections' +import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' import { RdmAuditWorkspace } from './RdmAuditWorkspace' import { RdmRolesPanel } from './adminRdmSections' import type { AdminZoneSection } from '../api' @@ -31,6 +31,8 @@ const SECTIONS: AdminSectionDef[] = [ // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, + // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 + { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => }, { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => }, { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => }, ] diff --git a/src/components/ArtifactsPanel.tsx b/src/components/ArtifactsPanel.tsx index d570cd8..3466dd4 100644 --- a/src/components/ArtifactsPanel.tsx +++ b/src/components/ArtifactsPanel.tsx @@ -59,7 +59,7 @@ export function ArtifactsPanel() {
)} {artifacts.length === 0 - ?
暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载
+ ?
暂无文件,可添加材料或在对话中生成文档
: visible.length === 0 ?
没有匹配「{q}」的文件
: groups.map(g => g.versions.length === 1 diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 72b4948..2cbd1a6 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -13,6 +13,8 @@ import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' import { docBridge } from './Workbench' import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts' import { ProcessTimeline } from './ProcessTimeline' +import { ConversationWork } from './ConversationWork' +import '../companion-conversation.css' function hasFiles(dt: DataTransfer | null): boolean { return !!dt && Array.from(dt.types).includes('Files') @@ -28,9 +30,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { return !!findArtifact(id))} /> case 'thinking': - // 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。 - //(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。) - return + return null case 'tool_call': return 🛠 {part.toolCall?.name} case 'tool_result': { @@ -105,18 +105,6 @@ function findCard(id: string) { return cardCache[id] } -// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。 -function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) { - return ( -
- ▸思考过程}> -
{part.text}
-
-
- ) -} - // 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。 function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) { const [menu, setMenu] = useState(false) @@ -189,10 +177,14 @@ function useElapsed(startedAt: number): string { // 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。 function LiveBar({ live }: { live: LiveState }) { const elapsed = useElapsed(live.startedAt) + const phase = livePhaseLabel(live) + const label = phase.startsWith('正在编写') ? '正在准备操作' + : phase.startsWith('正在运行') ? '正在处理' + : phase === '思考中' ? '正在梳理' : phase return ( - {livePhaseLabel(live)} + {label} 已工作 {elapsed} ) @@ -211,15 +203,14 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat const split = useMemo(() => splitTimeline(parts, finished), [parts, finished]) const showProcess = showProcessInline(live ?? null, autoCollapse) const memKey = `process:${msg.runId || msg.id}` - const touched = !!(msg.runId && touchedProcess.has(msg.runId)) + const touched = touchedProcess.has(memKey) const [open, setOpen] = useState(() => { - if (live && !touched) return true if (touched) return getPersistedOpen(memKey) return showProcess }) useEffect(() => { - if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true) - }, [live, msg.runId]) + if (!touchedProcess.has(memKey)) setOpen(showProcess) + }, [memKey, showProcess]) const extras = live ? [] : citedArtifactCards(split.final) const cards = live ? [] : dedupeDeliverables(split.final) const lastText = (() => { @@ -229,13 +220,13 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat const streamingText = !!(live && live.phase === 'responding' && lastText >= 0 && !live.items.some(it => it.kind === 'tool' && it.state !== 'done')) const toolCount = split.timeline.filter(p => p.type === 'tool_call').length - const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt) + const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}` const timeline = ( } /> ) const toggleProcess = (next: boolean) => { - if (msg.runId) touchedProcess.add(msg.runId) + touchedProcess.add(memKey) setPersistedOpen(memKey, next) setOpen(next) } @@ -246,7 +237,7 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat 助手 {live && }
- {split.timeline.length > 0 && ( + {split.timeline.some(part => part.type !== 'thinking') && (
@@ -268,7 +259,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat {extras.map(art => ( ))} - {live && } + {live && Object.keys(live.agentNotes).length > 0 && 协作记录}>} {!live && ctxObs && } {convId && }
@@ -282,25 +274,32 @@ function SessionNoteField({ convId, note }: { convId: string; note: string }) { setValue(e.target.value)} - onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} /> + onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => { + if (store.getState().currentConvId === convId) return store.openConversation(convId) + }) }} /> ) } function MessageTools({ msg, convId }: { msg: Message; convId: string }) { const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n') - const refresh = () => { void store.openConversation(convId) } + const refresh = () => { if (store.getState().currentConvId === convId) void store.openConversation(convId) } return (
- - - {msg.role === 'assistant' && ( - - )} - {msg.variantGroup && msg.excluded && ( - - )} + + +
+ + {msg.role === 'assistant' && ( + + )} + {msg.variantGroup && msg.excluded && ( + + )} + {msg.role === 'user' && } +
+
{msg.variantGroup && 候选 {(msg.variantIndex ?? 0) + 1}}
) @@ -349,16 +348,32 @@ function LiveAgentNotes({ live }: { live: LiveState }) { function HITLCardView({ card }: { card: HITLCard }) { const [free, setFree] = useState('') - const [submitting, setSubmitting] = useState(false) + const actionState = useStore(s => s.cardActionStates?.[`hitl:${card.id}`]) + const submitting = actionState !== undefined + // wfcompile.doScript 使用固定首行 + 脚本预览;其他多行问题可能包含 + // 用户必须理解的业务后果,保持原样,不凭内容猜测其信息层级。 + const scriptPrefix = '运行这段脚本?\n' + const hasScriptPreview = card.kind === 'confirm' && card.question.startsWith(scriptPrefix) + const question = hasScriptPreview ? scriptPrefix.trimEnd() : card.question + const detail = [hasScriptPreview ? card.question.slice(scriptPrefix.length) : '', card.detail] + .filter(value => value?.trim()).join('\n\n') const answer = async (resp: { choiceId?: string; freeText?: string }) => { - setSubmitting(true) + if (store.getState().cardActionStates?.[`hitl:${card.id}`]) return await store.resolveHITL(card.id, resp) - setSubmitting(false) } return (
-
{card.question}
- {card.detail &&
{card.detail}
} +
{question}
+ {card.blocked &&
+ 操作受限:{card.blockReason || '当前操作条件尚未满足'} +
} + {detail &&
▸操作详情}> +
{detail}
+
} + {actionState &&
+ {actionState === 'unknown' ? '正在核实处理结果' : '正在提交你的选择'} +
} {card.choices && (
{card.choices.map(c => ( @@ -373,7 +388,7 @@ function HITLCardView({ card }: { card: HITLCard }) { {card.allowFreeInput && (
setFree(e.target.value)} - placeholder={card.freeInputHint || '自由输入…'} onKeyDown={e => { if (e.key === 'Enter' && free.trim()) answer({ freeText: free.trim() }) }} /> + disabled={submitting} placeholder={card.freeInputHint || '补充你的要求'} onKeyDown={e => { if (e.key === 'Enter' && free.trim() && !submitting) void answer({ freeText: free.trim() }) }} />
)} @@ -450,6 +465,13 @@ export function NoticeMessage({ msg }: { msg: Message }) { } export function Conversation() { + const conversationId = useStore(s => s.currentConvId) + const projectId = useStore(s => s.currentProjectId) + // 本地草稿、附件、折叠设置与滚动跟随属于当前会话,切换时一起重置。 + return +} + +function ConversationSession() { const currentConvId = useStore(s => s.currentConvId) const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId)) const messages = useStore(s => s.messages) @@ -462,17 +484,25 @@ export function Conversation() { const artifacts = useStore(s => s.artifacts) const profiles = useStore(s => s.profiles) const cards = useStore(s => s.cards) - const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目') - - const runtimeMock = useStore(s => s.runtimeMock) + const currentProjectId = useStore(s => s.currentProjectId) + const cardActionStates = useStore(s => s.cardActionStates) const [text, setText] = useState('') const [attachmentIds, setAttachmentIds] = useState([]) const [attachmentNames, setAttachmentNames] = useState([]) const [dragDepth, setDragDepth] = useState(0) + const [uploading, setUploading] = useState(0) + const [uploadError, setUploadError] = useState('') + const composeActive = useRef(true) + const inputRef = useRef(null) const bodyRef = useRef(null) const stickRef = useRef(true) const [showJump, setShowJump] = useState(false) + useEffect(() => { + composeActive.current = true + return () => { composeActive.current = false } + }, []) + artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a])) cardCache = Object.fromEntries(cards.map(c => [c.id, c])) @@ -492,8 +522,30 @@ export function Conversation() { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }) }, [messages.length, live?.items, live?.phase, activeHitl.length]) + const composeIsCurrent = () => { + const state = store.getState() + return composeActive.current && state.currentConvId === currentConvId && state.currentProjectId === currentProjectId + } + const addMaterials = async (files: File[]) => { + if (!files.length) return + setUploadError('') + setUploading(count => count + files.length) + for (const file of files) { + if (!composeIsCurrent()) return + const id = await store.uploadAttachment(file) + if (!composeIsCurrent()) return + setUploading(count => Math.max(0, count - 1)) + if (id) { + setAttachmentIds(ids => [...ids, id]) + setAttachmentNames(names => [...names, file.name]) + } else { + setUploadError(message => [message, `「${file.name}」未能添加,请重新选择文件`].filter(Boolean).join(';')) + } + } + } + const send = async () => { - if (!text.trim() && attachmentIds.length === 0) return + if (uploading > 0 || (!text.trim() && attachmentIds.length === 0)) return const t = text const ids = attachmentIds const names = attachmentNames @@ -507,54 +559,54 @@ export function Conversation() { if (!currentConvId) { return ( -
-
-
⌘
-
选择或创建一个会话开始
- {runtimeMock - ?
mock 模式演示指令:#hitl · #agents · #doc · #error
- :
演示模式可在设置里打开,无需模型密钥
} +
+
+ +
从一件需要完成的工作开始
+
告诉助手你的目标,或准备好需要一起阅读的材料
+
) } return ( -
0 ? 'is-dragging' : ''}`} +
0 ? 'is-dragging' : ''}`} onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }} onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }} onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))} - onDrop={async e => { + onDrop={e => { if (!e.dataTransfer?.files?.length) return e.preventDefault() setDragDepth(0) - for (const f of Array.from(e.dataTransfer.files)) { - const id = await store.uploadAttachment(f) - if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) } - } + void addMaterials(Array.from(e.dataTransfer.files)) }}> {dragDepth > 0 &&
释放以上传资料
}
- {projectName}
{conversation?.title ?? '会话'}
- { if (!currentConvId) return await api.setConversationProfile(currentConvId, e.target.value) - await store.openConversation(currentConvId) + if (store.getState().currentConvId === currentConvId) await store.openConversation(currentConvId) }}> {profiles.map(p => )} - - - - {!connected && 重连中} + 实际读取范围还需项目设置允许
+ + +
{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}
{currentConvId && } - {currentConvId && } {anchored.map(c => ( store.cardAction(card, actionId), + actionState: card => cardActionStates?.[card.id], onLocate: card => store.openInbox(card.id), }} /> ))} @@ -600,10 +652,16 @@ export function Conversation() { {showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live && } {activeHitl.map(c => )} - {messages.length === 0 &&
+ + {messages.length === 0 && !live && cards.length === 0 && activeHitl.length === 0 &&
-
发送消息开始对话
- {runtimeMock &&
mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help
} +
这次想完成什么
+
说说目标,也可以先添加材料
+
+ {['帮我整理这些材料中的重点和缺项', '一起比较这两个方案,说明各自适合的情况', '帮我把已有想法整理成一份草稿'].map(example => ( + + ))} +
}
{showJump && ( @@ -616,59 +674,59 @@ export function Conversation() {
+ {!connected &&
正在重新连接,最新进展待确认
} {/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队; 后台任务在回合结束后仍在执行,完成后回到对话汇报。 */} {(busy || bgRunning > 0) && (
{busy - ? 助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''} - : {bgRunning} 个后台任务进行中} - {busy ? '输入仍可排队' : '完成后回到对话汇报'} - - + ? 助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''} + : {bgRunning} 项后台工作} + {busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}
)} {attachmentNames.length > 0 && (
{attachmentNames.map((n, i) => {n} - { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}> + )}
)} + {uploading > 0 &&
正在添加 {uploading} 份材料,完成后即可发送
} + {uploadError &&
{uploadError}
} + 操作设置} bodyClassName="companion-operation-body"> + +

计划模式只进行读取与规划;自动改文件模式允许文件修改。重要操作仍按当前权限规则确认

+
{text.startsWith('/') && setText(c)} />}
- - -