Compare commits

...
Author SHA1 Message Date
agent-exec-transport 3c22d0957a agent-exec transport f9152d30e9410417157ea4a3a9a926ba/e09f009fa9f5
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 36s
2026-10-20 01:48:41 +00:00
agent-exec-transport e296d38ec2 agent-exec transport 0bde562073461c828c740d52a55a8b5d/a5fbfeb4311f 2026-10-20 01:19:46 +00:00
weichen 259021d530 Merge pull request 'Merge the duplicate rdmManagement import in adminRdmSections' (#113) from fix/nonblocking-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 39s
2026-10-19 14:31:28 +00:00
agent-exec-transport 7d43ef6ff7 agent-exec transport a4840fd95e17330e094af504afeba096/59a2059a0705
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 36s
2026-10-19 14:25:36 +00:00
weichen 9baf96d937 Merge pull request 'Merge the accepted super-admin changes into main' (#112) from feature/super-admin-merge into main
governance / governance (push) Successful in 16s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:43:13 +00:00
weichen dab16052cf Merge feature/super-admin history onto main
governance / governance (pull_request) Successful in 40s
ci / test-and-build (pull_request) Successful in 54s
2026-10-19 09:40:42 +00:00
agent-exec-transport ce15f7977b agent-exec transport 548284a3917af91d24d89efd66e5ec7c/d0c96d333a18 2026-10-19 09:40:34 +00:00
weichen f560201f8b Merge pull request 'Verify progressive material states in a real browser' (#79) from feature/fast-slow-u2-u3 into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:20:39 +00:00
agent-exec-transport 6f8e7ec604 agent-exec transport 9f91fe4ba0c0b0412d4616549f170f87/cff15f49e8bf
governance / governance (pull_request) Successful in 17s
ci / test-and-build (pull_request) Successful in 37s
2026-10-19 05:25:04 +00:00
agent-exec-transport aa49e0dbf3 agent-exec transport 3646d6259c27d42bf24f52c61364d67e/bea155d33d2e
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 30s
2026-10-19 05:10:37 +00:00
weichen b4fec2fdf4 Merge feature/fast-slow-u2-u3 history after syncing with main
governance / governance (pull_request) Successful in 20s
ci / test-and-build (pull_request) Successful in 35s
2026-10-19 03:38:49 +00:00
agent-exec-transport 164ea66039 agent-exec transport 9f1c4114b4dc96cd086583d1c03cd7a4/07c6c40e6ebc 2026-10-19 03:37:22 +00:00
weichen 1544781c28 Verify queued and failed material states in browser
Why: Users must see truthful first replies when work queues or cannot start.

Evidence: RED and GREEN HTTP browser receipts plus Frontend 269 tests and production build.

Impact: Adds two task-owned Playwright scenarios; production UI is unchanged.

Refs: U3 r0.5 independent review finding
2026-10-14 09:13:25 +00:00
weichen cfabed8fae Cover safe material intake across browser paths
Why: The first reply and task reuse need end-to-end checks across long and small material inputs.

Evidence: Dev evidence receipts for ten Playwright scenarios, Frontend tests, and build.

Impact: Adds browser-only coverage; production UI and API behavior are unchanged.

Refs: U3 r0.5 implementation plan Task 4
2026-10-14 08:40:21 +00:00
weichen c9d5acf089 Count batched failure notices in browser validation
Why: One system reminder can contain multiple failed child notices, so the browser test must wait for the whole batch rather than one message per child.

Evidence: The first post-merge HTTP/Playwright run failed only at this false one-to-one assumption; all six task-owned browser cases passed after counting notice entries in the persisted reminder.

Impact: Test-only correction; no production UI behavior change.

Refs: U3 r0.4 Task 6 review closure
2026-10-14 04:06:22 +00:00
weichen 139f2ce654 Merge current Frontend main into U3 browser checks
Why: Keep the approved RDM export controls and the U3 material browser coverage in one component candidate.

Evidence: Changes in store.ts touch separate state fields and behavior; full Frontend tests/build and task-owned browser cases will be rerun after this merge.

Impact: Includes upstream RDM export UI without changing U3 source or notice contracts.

Refs: Dev U3 r0.4 Task 6 final review synchronization
2026-10-14 04:04:05 +00:00
weichen 01ec12e77c Wait for settled material notices in browser checks
Why: A failed task card can appear before its notification turn, and the second finding must be observed while work is still running.

Evidence: The strengthened browser checks first exposed missing fixture notice text and a zero-width second-stage window; after fixture correction all six task-owned HTTP/Playwright scenarios passed. Frontend 265 unit tests and build passed.

Impact: Test coverage only; no production UI change.

Refs: U3 r0.4 independent review Important 5
2026-10-14 03:57:38 +00:00
weichen 34950997f8 Cover progressive material outcomes in browser tests
Why: The r0.4 acceptance must show running findings, zero and partial failure, and a source-bound choice in the real UI.

Evidence: Six task-owned HTTP/Playwright cases passed; npm test (265 tests) and npm run build passed.

Impact: Browser test coverage only; no production UI change.

Refs: U3 r0.4 Task 6
2026-10-14 03:34:23 +00:00
weichen 006c815675 Check pending membership in the real HTTP work view
Why: The U3 browser gate must show a host-generated pending question for underdetermined source counts.

Evidence: The new assertion failed against the old mechanism fixture while existing source-navigation and focus-flow tests remained observable; Backend fixture now emits structured premises.

Impact: Browser test inputs and assertion only; no production UI change.

Refs: Backend 756679d; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 17:02:03 +00:00
weichen 9d7681cec4 Keep generated evidence helpers module-local
Why: The Core mirror must satisfy the Frontend unused-export guard without changing the candidate shape.

Evidence: Dev make contracts regenerated the file; Frontend knip 51 <= 52 and build passed.

Impact: Type-only export visibility change.

Refs: Core b474394; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:49:56 +00:00
weichen 5bc2e12c01 Sync U3 evidence candidate types from Core
Why: Frontend generated types must follow the single Core checkpoint contract.

Evidence: Dev make contracts generated the mirror; npm test and npm run build passed.

Impact: Type-only mirror update; persisted task cards and reading behavior stay unchanged.

Refs: Core c722c16; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:34:07 +00:00
weichen 64ba640b77 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 15:49:29 +00:00
weichen 329bdf53d6 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 10:19:21 +00:00
weichen 0bbccbe39e Merge current login recovery browser coverage
Why: Preserve current main before publishing the U2/U3 candidate.

Evidence: Exact main object fetched; browser-only upstream changes.

Impact: Retain both sets of browser coverage.

Refs: Agent-System-Dev U2/U3 delivery
2026-10-13 05:28:02 +00:00
weichen 3087b0621c Assert literal source records in work checkpoint browser flows
Why: Material records now display validated source quotations rather than unverified model paraphrases.

Evidence: Existing browser coverage retains refresh, changed-version, preview and focus checks; the updated assertions target actual source text.

Impact: Browser-only expectations change; product bundle remains the independently reviewed version.

Refs: Approved R05 and host material-record guard
2026-10-13 04:31:50 +00:00
weichen 4d1d64a83e Merge commit '9fc7da2ace8578d87d2601adcbf831e0b955aa68' into feature/fast-slow-u2-u3 2026-10-13 04:06:22 +00:00
weichen 861c45edb5 Keep delayed source responses behind the current preview intent
Why: Late source requests must not replace a newer preview or survive conversation navigation.

Evidence: Frontend typecheck, 221 unit tests, knip ratchet and build passed; delayed-response regressions reproduced the old failure.

Impact: The existing store owns source requests and invalidates stale responses on preview and context changes.

Refs: Agent-System-Dev feature-fast-slow-u2-u3-plan; independent review finding 4
2026-10-13 03:55:04 +00:00
weichen c63f6c7470 Gate checkpoint browser tests on an explicit owned instance
Why: Unrelated browser suites should skip this opt-in runtime acceptance instead of importing an absent manifest.

Evidence: Playwright discovers the two tests without an instance; explicit owned-instance replay passed previously.

Impact: Preserves strict manifest verification when invoked.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:16:25 +00:00
weichen 75a420210d Exercise task recovery and source reading through real HTTP
Why: DOM fixtures alone cannot verify persisted checkpoints and source version handling.

Evidence: SELF source navigation and changed-version checks pass; reading replay is being repeated after the deferred runtime port fix.

Impact: Adds an explicit task-owned instance browser suite without paid model calls.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:08:33 +00:00
weichen 175210f4bb Open processed source versions through the existing preview
Why: Source context must remain attached to the bytes being read.

Evidence: SELF frontend tests and production build pass.

Impact: Carries an optional expected hash and preserves one preview location.

Refs: fast-slow-u2-u3 Task 5 source-version acceptance
2026-10-13 02:51:07 +00:00
weichen b00fa4433b Merge commit 'dcd2699933eff51c06193cac7beb15c3d20a01ff' into feature/fast-slow-u2-u3 2026-10-13 02:44:19 +00:00
weichen 8be95c8807 Keep critical coverage and correction notices in conversation work
Why: A folded auxiliary panel must not hide material limits or withdrawn conclusions.

Evidence: SELF 213 frontend tests, type checks, knip ratchet, and bundle build pass.

Impact: Prioritizes existing task cards without opening files or changing focus.

Refs: fast-slow-u2-u3 Task 5
2026-10-13 02:36:57 +00:00
weichen 54fe339e65 Recover task projections without regressing work or reading state
Why: Replayed events, snapshot races, and source navigation must preserve authoritative work and user focus.

Evidence: SELF make evidence records 213 frontend tests, type checks, knip ratchet, and production build.

Impact: Uses the existing store for task revisions and gap replay; opens sources in the existing preview with version context.

Refs: fast-slow-u2-u3 Task 5; generated public contract types account for the measured knip baseline increase
2026-10-13 01:59:40 +00:00
weichen 82c08bd983 Consume persistent work contracts
Why: keep the workbench wire types aligned with Core.

Evidence: TypeScript check passed on the synchronized contract.

Impact: generated types only.

Refs: U2/U3 Task1
2026-10-13 01:09:31 +00:00
27 changed files with 614 additions and 519 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
run: |
set -euo pipefail
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
+5 -5
View File
@@ -1,5 +1,5 @@
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
@@ -51,17 +51,17 @@ async function adminLogin(page: Page) {
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
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()
const row = zone.locator('tr', { hasText: USER })
await row.getByRole('button', { name: '重置临时密码' }).click()
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
const password = await temporaryPassword.inputValue()
+202
View File
@@ -0,0 +1,202 @@
import { test, expect } from '@playwright/test'
import { readFileSync, readlinkSync } from 'node:fs'
import { createHash } from 'node:crypto'
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
const ids:string[]=[]
for(let i=0;i<(options.small?1:3);i++){
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
}
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
let sendBody:any
try{
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
expect(sent.ok()).toBeTruthy()
sendBody=await sent.json()
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
}finally{
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
}
if(awaitTask)await expect.poll(async()=>{
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
return out.cards.find((c:any)=>c.task)?.task.executionState
}).toBe(scenario.endsWith('failure')?'failed':'completed')
return {project,conv,ids,sendRunId:sendBody.run.id}
}
async function waitForFailedWorkCard(request:any,seed:any){
await expect.poll(async()=>{
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
const card=out.cards.find((c:any)=>c.task)
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
}).toBe(true)
}
async function expectOnlySendRun(request:any,seed:any){
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
}
async function open(page:any,seed:any){
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
}
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
const seed=await setup(request,'fast-ack',false)
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
await open(page,seed)
const reply=page.getByText(/已收到 3 份材料.*待确认/)
await expect(reply).toBeVisible()
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
await page.setViewportSize({width:390,height:844})
await expect(reply).toBeVisible()
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
await page.reload()
await expect(reply).toBeVisible()
})
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
await open(page,seed)
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('running and completed resends reuse one material card',async({page,request})=>{
const seed=await setup(request,'slow-stages',false)
const resend=async(key:string)=>{
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
expect(response.ok()).toBeTruthy()
const body=await response.json()
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
}
await resend('active-resend-'+Date.now())
await open(page,seed)
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
await expect.poll(async()=>{
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
return cards.find((c:any)=>c.task)?.task.executionState
}).toBe('completed')
await resend('completed-resend-'+Date.now())
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
await open(page,seed)
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
await page.getByRole('button',{name:/查看来源/}).first().click()
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
const source=card.task.findings[0].sources[0]
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
expect(errors).toEqual([])
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
})
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
const seed=await setup(request);await open(page,seed)
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
const tabs=await page.locator('.tab-strip').textContent()
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
await expect.poll(async()=>{
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
return cards.filter((c:any)=>c.task?.executionState==='completed').length
}).toBe(2)
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
})
test('two sourced stages appear while the material task is still running',async({page,request})=>{
const seed=await setup(request,'slow-stages',false)
await open(page,seed)
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
})
for(const scenario of ['zero-failure','partial-failure'] as const){
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
const seed=await setup(request,scenario)
await waitForFailedWorkCard(request,seed)
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
const work=page.locator('[data-conversation-work]')
await expect(work).toContainText('执行失败')
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
else await expect(work).toContainText('上线六月')
await expect(page.locator('.work-choice')).toHaveCount(0)
await expect(page.locator('.hitl-choice')).toHaveCount(0)
await expectOnlySendRun(request,seed)
})
}
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
const seed=await setup(request,'choice')
await open(page,seed)
const work=page.locator('[data-conversation-work]')
await expect(work.locator('.work-choice')).toHaveCount(3)
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
await page.reload()
await expect(work.locator('.work-choice')).toHaveCount(3)
})
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
const seed=await setup(request,'normal',false,{startFailure:true})
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
})
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
const seed=await setup(request,'queue-hold',false)
const sendAnother=async(label:string,hold:boolean)=>{
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
})
expect(uploaded.ok()).toBeTruthy()
const artifactId=(await uploaded.json()).artifactId
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
expect(sent.ok()).toBeTruthy()
const runId=(await sent.json()).run.id
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
}
await sendAnother('占用二',true)
await sendAnother('占用三',true)
await sendAnother('等待四',false)
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
expect(cards).toHaveLength(4)
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
})
+1 -1
View File
@@ -1,3 +1,3 @@
{
"exports": 51
"exports": 58
}
+12 -32
View File
@@ -28,17 +28,9 @@ export interface UserRoleBinding {
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required'
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
export interface RecoveryProposal {
id: string; userId: string; owner: string
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
version: number; generation: number; createdBy: string
createdAt: string; expiresAt: string
}
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
@@ -53,6 +45,12 @@ export interface ManagedAccountRecord extends AccountRecord {
initializationState: string; initializationGeneration: number
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
export interface AdminIssuedOneTimePassword {
owner: string
id: string
oneTimePassword: string
mustChangePassword: boolean
}
export interface ExecEnvView {
configured: boolean
@@ -136,14 +134,6 @@ export const api = {
auth.beginSession()
return me
}),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/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; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
@@ -157,7 +147,7 @@ export const api = {
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
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, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
@@ -170,21 +160,9 @@ export const api = {
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
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)),
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
// ---- 本人:资料(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
@@ -221,6 +199,8 @@ export const api = {
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
listCards: (conversationId: string) =>
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
+1 -2
View File
@@ -16,7 +16,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码'
account.passwordSet ? '已设置密码' : '待设置密码'
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
@@ -233,7 +233,6 @@ export function AccountUsersPanel() {
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './LoginPage'
import { humanChangePasswordError } from './loginErrors'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
+4 -4
View File
@@ -30,15 +30,15 @@ function ProfilesPanelWrapper() {
return <ProfilesSection profiles={profiles} />
}
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
function UsersSection() {
const isAdmin = useStore(s => !!s.me?.isAdmin)
return <>
<AccountUsersPanel />
{isAdmin && <>
<div className="az-section-head" style={{ marginTop: 18 }}>
<h3>登录凭据与恢复</h3>
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
<h3>登录凭据</h3>
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
</div>
<UsersPanel />
</>}
@@ -46,7 +46,7 @@ function UsersSection() {
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
+3 -1
View File
@@ -29,7 +29,9 @@ export function ConversationWork() {
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
+7 -47
View File
@@ -1,11 +1,13 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
import { Icon, BrandMark } from '@ui'
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from './loginErrors'
function Err({ msg }: { msg: string }) {
if (!msg) return null
@@ -74,12 +76,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
)
}
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
@@ -96,8 +92,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
!isForcedPasswordMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录。')
return
}
setMode(me.passwordChangeMode)
@@ -113,7 +109,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
await submitForcedPassword(mode, current, next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -136,12 +132,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
{mode === 'current_required' && <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
</>}
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
@@ -155,34 +146,3 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
</div>
)
}
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+9 -88
View File
@@ -1,12 +1,9 @@
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
import { recoveryStatusLabel } from '../recovery'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
const refreshProposal = async (id: string) => {
const updated = await api.getMyRecoveryProposal(id)
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
return updated
}
const confirm = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
} catch (error) {
const current = await refreshProposal(proposal.id).catch(() => null)
setErr(current?.status === 'confirmed'
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
} finally { setBusy(false) }
}
const rotate = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
} catch (error) {
await refreshProposal(proposal.id).catch(() => null)
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
} finally { setBusy(false) }
}
const redeem = async () => {
if (!grant) return
setBusy(true); setErr(''); setNotice('')
try {
await api.redeemLoginKey(grant.value)
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
} catch (error) {
const session = await api.me().catch(() => null)
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
location.reload()
return
}
await refreshProposal(grant.proposalId).catch(() => null)
setGrant(null)
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
} finally { setBusy(false) }
}
return <SettingsGroup title="我的账号与恢复">
return <SettingsGroup title="我的账号">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
@@ -121,21 +56,7 @@ export function MyAccountSettings() {
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
<div className="ad-row">
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
</div>
</div>)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
{grant && <div className="ad-reset">
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
</div>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
+7 -4
View File
@@ -24,6 +24,7 @@ export function PreviewPane() {
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
@@ -35,12 +36,12 @@ export function PreviewPane() {
setContent(null)
if (!art || !isTextKind(art)) return
let cancelled = false
fetch(api.artifactContentUrl(art.id))
.then(r => r.text())
fetch(contentUrl)
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
.then(text => { if (!cancelled) setContent(text) })
.catch(() => { if (!cancelled) setContent('(读取失败)') })
return () => { cancelled = true }
}, [art?.id])
}, [art?.id,contentUrl])
if (!active) return null
if (active.id.startsWith('surface:')) {
@@ -96,6 +97,7 @@ export function PreviewPane() {
await api.saveArtifactContent(art.id, content ?? '')
setDirty(false)
store.toast('已保存')
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
}}>保存</Btn>}
</>
)}
@@ -108,6 +110,7 @@ export function PreviewPane() {
</>
)}
</div>
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
<div className="preview-pane-body">
{editing && textKind ? (
<textarea className="search-input preview-pane-editor" value={content ?? ''}
@@ -118,7 +121,7 @@ export function PreviewPane() {
id: art.id,
name: art.name,
mime: art.mime,
contentUrl: api.artifactContentUrl(art.id),
contentUrl,
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
+1
View File
@@ -14,6 +14,7 @@ import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
export const docBridge: DocBridge = {
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
+2 -5
View File
@@ -11,17 +11,14 @@ import { Btn, Collapsible, Icon, Picker } from '@ui'
import {
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
} from '../rdmManagement'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import {
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
type SearchFieldsChoice,
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
personLabel, roleOptionsPayload,
} from '../rdmManagement'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
interface MemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
+33 -157
View File
@@ -1,23 +1,20 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useRef, useState } from 'react'
import { api, type RecoveryProposal } from '../api'
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
recoveryStatusLabel, type RecoveryAttempt,
} from '../recovery'
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
@@ -29,27 +26,12 @@ export function UsersPanel() {
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [createAsAdmin, setCreateAsAdmin] = useState(false)
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
const [busy, setBusy] = useState(false)
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
// 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。
const proposalSeq = useRef(0)
// 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。
const recoveryAttempt = useRef<RecoveryAttempt | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshProposals = async (userId: string) => {
const seq = proposalSeq.current
try {
const statuses = await api.listAdminRecoveryProposals(userId)
if (seq === proposalSeq.current) setProposals(statuses)
} catch (e) { if (seq === proposalSeq.current) setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
@@ -64,86 +46,16 @@ export function UsersPanel() {
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const resetPassword = async (u: AdminUserRow) => {
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminResetUserPassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential(oneTimePasswordCredential(out))
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const setAdmin = async (u: AdminUserRow, isAdmin: boolean) => {
setErr(''); setNotice(''); setBusy(true)
try {
await api.adminSetUserAdmin(u.userId, u.stateVersion, isAdmin)
setNotice(isAdmin ? `已任命 ${u.owner} 为管理员` : `已撤销 ${u.owner} 的管理员身份`)
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户状态已变化,请刷新后重试' : String(e)) } finally { setBusy(false) }
}
const openProposals = async (u: AdminUserRow) => {
proposalSeq.current++
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
await refreshProposals(u.userId)
}
const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) }
const propose = async () => {
if (!propFor) return
const target = propFor
const seq = proposalSeq.current
const stillSelected = () => seq === proposalSeq.current
setErr(''); setNotice(''); setBusy(true)
const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID())
recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey }
try {
const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey)
recoveryAttempt.current = null
const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null)
if (!stillSelected()) return
if (statuses) setProposals(statuses)
setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status))
} catch (e) {
const message = String(e)
if (message.includes('409')) {
recoveryAttempt.current = null
await resetPassword(target)
return
}
if (!stillSelected()) return
if (recoveryProposalRejected(message)) {
recoveryAttempt.current = null
setErr(recoveryProposalErrorMessage(message))
return
}
// 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。
try {
const statuses = await api.listAdminRecoveryProposals(target.userId)
if (!stillSelected()) return
setProposals(statuses)
if (statuses.length > 0) {
setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`)
} else {
setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。')
}
} catch {
if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。')
}
} finally { setBusy(false) }
}
const revokeProposal = async (p: RecoveryProposal) => {
if (!propFor) return
setErr(''); setNotice(''); setBusy(true)
try {
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
await refreshProposals(propFor.userId)
setNotice('提案已撤销')
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
@@ -159,65 +71,29 @@ export function UsersPanel() {
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <div className="ad-reset">
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
</div>}
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{propFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
</div>
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
<table className="ad-table">
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
<tbody>
{proposals.map(p => (
<tr key={p.id}>
<td className="ad-mono">{p.id}</td>
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
<td className="ad-dim">{p.createdBy}</td>
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
)}</td>
</tr>
))}
</tbody>
</table>
)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && <span className="ad-row">
{policy.resetPassword ? <>
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
</> : <span className="ad-dim">{policy.protectedReason}</span>}
<AdminAppointmentButton me={me} account={u} busy={busy} onChange={isAdmin => setAdmin(u, isAdmin)} />
</span>}</td>
</tr>
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (policy.resetPassword
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
)}</td>
</tr>
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+21
View File
@@ -0,0 +1,21 @@
import type { AdminIssuedOneTimePassword } from '../api'
export interface GeneratedCredential {
owner: string
password: string
kind: 'initial' | 'one-time'
}
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
}
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
return <div className="ad-reset">
<p className="ad-hint">{credential.kind === 'initial'
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
</div>
}
+27
View File
@@ -0,0 +1,27 @@
import { api, type PasswordChangeMode } from '../api'
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
}
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
if (mode === 'current_required') {
return <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
</>
}
if (mode === 'grant_setup' || mode === 'one_time_password') {
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
}
return null
}
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
}
+27 -1
View File
@@ -150,6 +150,7 @@ export interface ActivityCard {
id: ID; projectId: ID; conversationId?: ID;
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
task?: TaskSnapshot;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
@@ -351,7 +352,7 @@ export interface PluginPageDecl {
export interface ParsedDocument {
meta: {
filename: string; mime?: string; parser: string; confidence?: number;
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
parsedAt: string;
};
@@ -414,3 +415,28 @@ export interface SurfaceMeta {
conversationId?: ID;
updatedAt: string;
}
// Persistent work projection; host-owned identities and revisions.
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
export interface TaskPhase { id: string; label: string; observedAt?: string }
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
export interface TaskSnapshot {
inputs?: TaskSourceRef[]
limitations?: string[]
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
}
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
export interface UIAction { id: string; label: string; style?: string; command?: string }
+2 -1
View File
@@ -3,13 +3,14 @@
export interface PreviewTab {
id: string
name: string
sourceContext?: {notice:string;quote:string;expectedHash?:string}
}
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
const i = tabs.findIndex(t => t.id === tab.id)
if (i >= 0) {
const next = tabs.slice()
next[i] = { ...next[i], name: tab.name || next[i].name }
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
return { tabs: next, activeId: tab.id }
}
return { tabs: [...tabs, tab], activeId: tab.id }
-92
View File
@@ -1,92 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
} from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
// 两种语义必须分开表达。
describe('requiresCurrentPassword', () => {
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
})
describe('humanChangePasswordError', () => {
it('后端 current password incorrect 映射为当前密码不正确', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
})
describe('humanRedeemError', () => {
it('401/409/429 语义分离', () => {
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
})
})
describe('recoveryStatusLabel', () => {
it('覆盖全部五种提案状态', () => {
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
expect(recoveryStatusLabel[status]).toBeTruthy()
}
})
})
describe('recoveryProposalHint', () => {
it('pending/confirmed 给出下一步,终态不给', () => {
expect(recoveryProposalHint('pending')).toContain('本人确认')
expect(recoveryProposalHint('confirmed')).toContain('新密码')
expect(recoveryProposalHint('completed')).toBe('')
})
})
describe('recoveryProposalNotice', () => {
it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => {
expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认')
expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力')
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认')
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力')
for (const status of ['completed', 'revoked', 'expired'] as const) {
const notice = recoveryProposalNotice('alice', status)
expect(notice).toContain(recoveryStatusLabel[status])
expect(notice).toContain('核对下方记录')
expect(notice).not.toContain('请该用户')
}
})
})
describe('recoveryAttemptKey', () => {
const fresh = () => 'new-key'
it('同一账号同一状态版本的重试沿用未确定结果的 key', () => {
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key')
})
it('无未确定请求、换账号或状态版本变化时生成新 key', () => {
expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key')
expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
})
})
describe('recoveryProposalRejected', () => {
it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => {
expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true)
expect(recoveryProposalRejected('Error: 404: not found')).toBe(true)
expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false)
expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false)
})
})
describe('recoveryProposalErrorMessage', () => {
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
})
it('未识别错误剥状态码原样透出', () => {
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
})
})
-50
View File
@@ -1,50 +0,0 @@
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
import type { RecoveryProposal } from './api'
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
pending: '待本人确认',
confirmed: '已确认,待兑换设密',
completed: '已完成',
revoked: '已撤销',
expired: '已过期',
}
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
return ''
}
/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey
* 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */
export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string {
if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力`
if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力`
return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录`
}
export interface RecoveryAttempt { userId: string; stateVersion: number; key: string }
/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key,
* 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */
export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string {
return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh()
}
/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */
export function recoveryProposalRejected(raw: string): boolean {
return /\b4\d\d:/.test(raw)
}
export function recoveryProposalErrorMessage(raw: string): string {
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
}
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
if (raw.includes('403')) return '需要管理员身份'
if (raw.includes('404')) return '用户不存在或已被移除'
return raw.replace(/^\d{3}:\s*/, '')
}
+74 -10
View File
@@ -23,6 +23,7 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
}
import type {
ActivityCard,
TaskSourceRef,
AgentProfile,
Artifact,
Conversation,
@@ -232,7 +233,8 @@ function syncFilesEntry(hasTabs: boolean) {
function setUI(patch: Partial<UIState>) {
const ui = { ...state.ui, ...patch }
state = { ...state, ui }
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
applyTheme(ui.theme)
applyFontSize(ui.fontSize)
@@ -298,6 +300,9 @@ let projectsRequest = 0
let tasksRequest = 0
let meRequest = 0
let streamSeq = 0
let streamHasWatermark = false
let gapReconnectPending = false
let streamPending = new Map<number, StreamEvent>()
let detailRequest = 0
let detailSeq = 0
let detailVersion = 0
@@ -353,12 +358,24 @@ function scheduleTerminalReconcile() {
// Snapshot queries may overlap events. Only rows changed after the request began
// override that snapshot; a newer request/context invalidates the old query entirely.
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
return incoming
}
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
const a=current.task,b=incoming.task
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
return incoming
}
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
const freshIds = new Set(fresh.map(c => c.id))
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
}
function hitlSource(card: ActivityCard): string | undefined {
@@ -530,7 +547,7 @@ export const store = {
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
@@ -621,7 +638,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
@@ -719,15 +736,21 @@ export const store = {
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
sse?.close()
sse = connectEvents(convId, since ?? state.lastSeq, {
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
})
},
applyEvent(e: StreamEvent) {
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
if(streamHasWatermark&&e.seq>streamSeq+1){
if(streamPending.size<1000)streamPending.set(e.seq,e)
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
return
}
streamSeq = e.seq
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
const previousSeq = state.lastSeq
// Running-turn replay reconstructs transient output, but cannot regress the
// persistent card/plan/status snapshot already returned by the detail query.
@@ -878,6 +901,7 @@ export const store = {
}
case 'artifact.created':
case 'artifact.updated': {
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
if (p?.artifact) {
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
@@ -1074,6 +1098,7 @@ export const store = {
if (card.conversationId && card.conversationId !== state.currentConvId) return
cardVersions[card.id] = ++cardVersion
delete cardPatches[card.id]
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
const i = state.cards.findIndex(c => c.id === card.id)
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
set({ cards })
@@ -1085,8 +1110,10 @@ export const store = {
const request = ++cardsRequest
const started = cardVersion
try {
const snapshot = await api.listCards(convId)
const out = await api.listCardsSnapshot(convId)
const snapshot=out.cards??[]
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
set({ cards })
settleCardFeedback(cards)
@@ -1288,7 +1315,8 @@ export const store = {
applyProjectEvent(e: StreamEvent) {
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
projectSeq = e.seq
const upsert = (card: ActivityCard) => {
const upsert = (incoming: ActivityCard) => {
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
if (card.projectId !== state.currentProjectId) return
projectCardVersions[card.id] = ++projectCardVersion
const list = store.getState().projectCards
@@ -1401,6 +1429,41 @@ export const store = {
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
*/
async openTaskSource(cardId: string, source: TaskSourceRef) {
const conversationId = state.currentConvId
const projectId = state.currentProjectId
const epoch = conversationEpoch
const intent = state.previewOpenIntent + 1
set({ previewOpenIntent: intent })
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
&& epoch === conversationEpoch && intent === state.previewOpenIntent
try {
const out = await api.taskSource(cardId, source.id)
if (!isCurrent()) return
if (!out.artifact) {
store.toast('来源当前不可用,正在核实工作记录')
if (conversationId) void store.refreshCards(conversationId)
return
}
store.requestTaskSourcePreview(out.artifact, {
notice: out.versionState === 'changed'
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
quote: out.source.quote ?? '',
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
})
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
} catch {
if (!isCurrent()) return
store.toast('来源当前无法读取,正在核实工作记录')
if (conversationId) void store.refreshCards(conversationId)
}
},
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
store.requestPreview(a)
const conv=previewTabsKey(state.currentConvId)
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
},
requestPreview(a: Artifact) {
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
@@ -1452,7 +1515,7 @@ export const store = {
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id } })
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
},
closePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
@@ -1460,6 +1523,7 @@ export const store = {
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
previewOpenIntent:state.previewOpenIntent+1,
})
if (next.tabs.length === 0) setUI({ previewExpanded: false })
syncFilesEntry(next.tabs.length > 0)
+3 -3
View File
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'
import { credentialLabel } from '../src/components/AccountUsersPanel'
describe('account directory credential state', () => {
it('shows pending trusted-owner initialization before password state', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
it('shows whether password is ready without legacy initialization labels', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
})
})
+53 -13
View File
@@ -1,7 +1,17 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import {
ForcedPasswordModeSection,
isForcedPasswordMode,
requiresCurrentPassword,
submitForcedPassword,
} from '../src/components/passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from '../src/components/loginErrors'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -31,19 +41,49 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(登录恢复新增文案)', () => {
it('并发凭据变化给出重新登录指引', () => {
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
})
})
describe('one_time_password 强制改密流程', () => {
afterEach(() => {
auth.clear()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('识别 one_time_password 模式且无需当前密码字段', () => {
expect(isForcedPasswordMode('one_time_password')).toBe(true)
expect(requiresCurrentPassword('one_time_password')).toBe(false)
const html = renderToStaticMarkup(createElement(ForcedPasswordModeSection, {
mode: 'one_time_password',
current: '',
onCurrentChange: () => undefined,
}))
expect(html).not.toContain('forced-current')
expect(html).toContain('无需输入当前密码')
})
it('提交 one_time_password 改密时走 /api/auth/password', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({
owner: 'alice',
name: 'Alice',
isAdmin: false,
mustChangePassword: false,
reloginRequired: true,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchSpy)
const changeSpy = vi.spyOn(api, 'changePassword')
await submitForcedPassword('one_time_password', 'should-be-ignored', 'new-password-123')
expect(changeSpy).toHaveBeenCalledWith('', 'new-password-123')
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/password', expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: '', next: 'new-password-123' }),
}))
})
})
+37
View File
@@ -0,0 +1,37 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { GeneratedCredentialPanel, oneTimePasswordCredential } from '../src/components/oneTimePasswordUi'
describe('管理员一次性密码发放', () => {
afterEach(() => {
auth.clear()
vi.unstubAllGlobals()
})
it('调用精确 one-time-password 端点并使用 POST', async () => {
const response = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', fetchSpy)
const out = await api.adminIssueOneTimePassword('alice')
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { method: 'POST' })
expect(out).toEqual(response)
})
it('展示后端返回的 oneTimePassword', () => {
const credential = oneTimePasswordCredential({ owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true })
const html = renderToStaticMarkup(createElement(GeneratedCredentialPanel, {
credential,
onClose: () => undefined,
}))
expect(html).toContain('otp-0001')
expect(html).toContain('一次性登录密码仅显示一次')
})
})
+60 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import { store, mergeTaskSnapshot } from '../src/store'
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
function deferred<T>() {
@@ -32,6 +32,7 @@ beforeEach(async () => {
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api,'listCardsSnapshot').mockImplementation(async id=>({cards:await api.listCards(id)}))
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
@@ -518,3 +519,61 @@ describe('shared action feedback', () => {
expect(store.getState().cards).toEqual([])
})
})
it('keeps a newer task result when an older projection arrives in a later stream event',()=>{
const task=(revision:number,executionState:string)=>({...card(),task:{schemaVersion:1,taskId:'t',attemptId:'a1',cardId:'task',projectId:'p',conversationId:'a',ownerInstanceId:'i',executionEpoch:1,revision,sourceSeq:revision,streamScope:'conversation:a',title:'整理',executionState,outcomeCompleteness:'partial',deliveryState:'available',phase:{id:'reading',label:'读取材料'}}}) as ActivityCard
store.applyEvent(event(1,'card.created',{card:task(9,'completed')}))
store.applyEvent(event(2,'card.updated',{card:task(8,'running')}))
expect(store.getState().cards[0].task?.revision).toBe(9)
expect(store.getState().cards[0].task?.executionState).toBe('completed')
})
it('does not lose an event behind a detected sequence gap',async()=>{
vi.mocked(api.listCardsSnapshot).mockResolvedValue({cards:[],replaySeq:0})
await store.refreshCards('a')
store.applyEvent(event(2,'card.updated',{card:card('later')}))
expect(store.getState().lastSeq).toBe(0)
store.applyEvent(event(1,'card.created',{card:card('earlier')}))
await Promise.resolve()
expect(store.getState().cards.map(c=>c.id)).toEqual(['later','earlier'])
expect(store.getState().lastSeq).toBe(2)
})
it('rejects old revisions and foreign attempts in the task merge contract',()=>{
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'a',revision:8}).revision).toBe(9)
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'b',revision:10}).attemptId).toBe('a')
})
it('keeps the newer explicit preview when a source response arrives late',async()=>{
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
const {docBridge}=await import('../src/components/Workbench')
const opening=docBridge.onOpenTaskSource!('card',source)
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
await opening
expect(store.getState().previewActive.a).toBe('b')
})
it('rejects a source response after leaving and returning to the same conversation',async()=>{
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
const opening=store.openTaskSource('card',source)
await store.openConversation('b');await store.openConversation('a')
store.requestPreview({id:'new',name:'New',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
await opening
expect(store.getState().previewActive.a).toBe('new')
})
it('keeps the activated tab when a source request is still pending',async()=>{
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
store.requestPreview({id:'c',name:'C',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
const opening=store.openTaskSource('card',source);store.activatePreview('b')
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
await opening
expect(store.getState().previewActive.a).toBe('b')
})