数据目录阶段 4A 前端:画布和问数据页的回答下显示「建议更新数据目录」卡片,逐项列出改前、改后和理由,保存带版本记为人工确认,遇到 409 可重新载入后再保存,也可忽略

This commit is contained in:
ThearchyHelios
2026-10-03 11:40:37 +08:00
parent 1a760726f7
commit 1c806fa3a0
16 changed files with 713 additions and 5 deletions
+64 -1
View File
@@ -371,12 +371,14 @@ const TABLES = {
const log = {
patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [],
deleted: [], gens: [], sourceGets: 0,
deleted: [], gens: [], sourceGets: 0, catalogPatches: [],
}
const ctl = {
slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0,
/** 列表只给前 8 个:筛选框刚好出现,删一个就不够 8 个了 */
few: false, run500: true, reviewDelay: {}, launchFail: true, scope400: false,
/** 目录修改提案第一次保存回 409(别人刚改过这张表) */
patchConflict: false,
/** 回收站里多摆两个(TRASH_MORE) */
trashMore: false,
/** 列表里多摆「护栏」那段的三个会话(GUARD_CONVS) */
@@ -470,6 +472,18 @@ async function fakeApi(route) {
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (String(b.instruction ?? '').includes('表示作废')) {
// 用户说了一条数据事实:服务端核对后转出目录修改提案,只回答、不建图
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 300 },
{ op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [
{ path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' },
value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] },
{ op: 'reply', text: '已整理成数据目录的修改建议,确认后保存即可。' },
]
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 800 },
// 服务端按需求挑了表(copilot_context):过程里多一行「参考了 N 张表」
@@ -483,6 +497,24 @@ async function fakeApi(route) {
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (path === '/datasources/src-shop/catalog/orders/patch' && method === 'POST') {
log.catalogPatches.push(body())
if (ctl.patchConflict) {
ctl.patchConflict = false
return json({ detail: '表「orders」的数据目录刚被修改过,请重新载入后再提交' }, 409)
}
return json({ table_name: 'orders', in_schema: true, notes: {}, version: 4, updated_at: null, updated_by: null,
structure: null, system_notes: false, usage: 0 })
}
if (path === '/datasources/src-shop/catalog/orders/patch/preview' && method === 'POST') {
log.catalogPatches.push({ preview: body() })
return json({ table_name: 'orders', version: 3, problems: [], changes: [
{ path: 'columns.status.codes', before: { 1: '已支付', 2: '已退款' }, before_status: 'confirmed',
after: { 1: '已支付', 2: '已退款', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] })
}
if (path === '/datasources/src-shop/catalog/orders/impact' && method === 'GET') {
return json({ table: 'orders', templates: [] })
}
if (path === '/copilot/review' && method === 'POST') {
const wait = ctl.reviewDelay[body().run_id]
if (wait) await new Promise((r) => setTimeout(r, wait))
@@ -1387,6 +1419,37 @@ for (const theme of THEMES) {
await ctx.close()
})
await section('catalogpatch', '说了一条数据事实:「建议更新数据目录」卡片,保存撞上 409 能重新载入', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0emptg')
ctl.patchConflict = true
const before = log.catalogPatches.length
await send(page, 'status=9 表示作废,统计时要排除')
const card = page.locator('[data-catalog-patch="orders"]')
check('回答下面有「建议更新数据目录」卡片', await card.waitFor({ timeout: 6000 }).then(() => true, () => false)
&& (await card.innerText()).includes('建议更新数据目录'))
check('逐项写改前、改后、理由', (await card.locator('[data-patch-before]').innerText()).includes('1=已支付')
&& (await card.locator('[data-patch-after]').innerText()).includes('9=作废')
&& (await card.locator('[data-patch-reason]').innerText()).includes('status=9'))
const t = (await chatState(page)).byConversation.c0emptg.at(-1)
check('提案原样记进这一轮的操作流,回答照常交付', t.ops.some((o) => o.op === 'catalog_patch') && t.phase === 'done' && t.noQuery === true)
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-conflict]').waitFor({ timeout: 3000 })
check('409:提示这张表刚被修改过', (await card.innerText()).includes('这张表刚被修改过'))
await card.locator('[data-patch-reload]').click()
await card.locator('[data-patch-save]').waitFor({ timeout: 3000 })
check('重新载入后改前按最新的目录写', (await card.locator('[data-patch-before]').innerText()).includes('2=已退款'))
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
const sent = log.catalogPatches.slice(before)
check('先带提案的版本,重新载入后带新版本', sent[0]?.if_version === 2 && !!sent[1]?.preview && sent[2]?.if_version === 3,
JSON.stringify(sent))
check('保存成功写明第几版、给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('第 4 版')
&& (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-shop/orders')
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
await section('approval', '停在审批上:审批卡立刻出现,不等 4 秒轮询', async () => {
const { page, ctx, errors } = await open(theme)
ctl.approvals = []
+29
View File
@@ -691,6 +691,35 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async
check('认不出的操作照旧忽略', mod.decodeCopilot([{ op: 'something_new', x: 1 }]).length === 0)
})
await section('Copilot:建议更新数据目录(catalog_patch 操作)', async () => {
// 用户在助手里说了一条数据事实,服务端核对后转出目录修改提案。过程里记一行,保存、忽略在卡片上做
const cp = await import(await load('/src/run/catalogPatch.ts'))
const steps = mod.decodeCopilot(synthetic.COPILOT_CATALOG_PATCH, { context: 'canvas' })
const row = steps.find((s) => s.code === 'catalog_patch')
check('一行说清建议改哪张表、几项', row?.title === '建议更新「入园记录」的数据目录(3 项)', row?.title)
check('副标题说明确认后才写入', row?.sub === '确认后才写入数据目录', row?.sub)
check('展开逐项写改前 → 改后,用界面上的叫法', !!row?.detail?.includes('列 status 的码值:0=作废、1=有效 → 0=作废、1=有效、9=作废')
&& row.detail.includes('表的有效记录条件:status = 1 → status = 1 AND status <> 9')
&& row.detail.includes('列 visitor_count 的度量类型:可累加 → 可累加'), row?.detail)
check('是看表结构那一类、标着规划阶段', row?.kind === 'schema' && row?.stage === 'plan' && row?.status === 'done')
check('结局仍是「只回答」', mod.copilotOutcome(synthetic.COPILOT_CATALOG_PATCH).kind === 'reply')
const [patch] = cp.catalogPatchesOf(synthetic.COPILOT_CATALOG_PATCH)
check('卡片拿到版本、数据源 id、表和中文名', patch?.version === 3 && patch.sourceId === 'src-scenic' && patch.table === 'visits'
&& patch.tableLabel === '入园记录' && patch.key === '1', JSON.stringify(patch && { ...patch, changes: patch.changes.length }))
check('三项都认、状态照抄', patch?.changes.map((c) => c.state).join(',') === 'change,change,confirm')
check('交回保存的是原样取值', JSON.stringify(cp.patchSubmit(patch.changes)[0]) === '{"path":"columns.status.codes","value":{"9":"作废"},"reason":"用户说明 status=9 表示作废"}')
check('新增关联关系的说法', cp.patchWhere({ path: 'relations.r1', before: null, after: { to_table: 'visits' } }) === '新增指向 visits 的关联关系')
check('关联关系写成一行', cp.patchValueText('relations.r1', { columns: ['visit_id'], to_table: 'visits', to_columns: ['id'],
cardinality: 'many_to_one' }) === 'visit_id → visits.id(多对一)')
check('表类型、度量类型写界面上的叫法', cp.patchValueText('kind', 'fact') === '明细表' && cp.patchValueText('columns.a.measure', 'stock') === '存量')
check('没有值写「未填写」', cp.patchValueText('grain', null) === '未填写')
check('列名里有句点照样认', JSON.stringify(cp.patchTarget('columns.a.b.unit')) === '{"kind":"column","column":"a.b","field":"unit"}')
check('形状不对不出卡片、不出行', cp.catalogPatchesOf([
{ op: 'catalog_patch' }, { op: 'catalog_patch', source: 's', source_id: 'i', table: 't', changes: [{ path: 'nope', after: 1 }] },
]).length === 0 && mod.decodeCopilot([{ op: 'catalog_patch', source: 's', table: 't' }]).length === 0)
})
await section('Copilot:心跳穿插、回话、少了一步、报错', async () => {
const steps = mod.decodeCopilot(synthetic.COPILOT_STUCK, { context: 'canvas' })
check('心跳穿插的思考仍然并成一条', steps.filter((s) => s.kind === 'think').length === 1,
+79
View File
@@ -768,6 +768,85 @@ await section('参考了哪些表:画布右栏和问数据页(context 操作
await q.page.close()
})
await section('建议更新数据目录:卡片的保存、忽略和 409(catalog_patch 操作)', async () => {
// 保存、预览都在浏览器层答掉:预览页没有真的数据源,检查也不许写库
const detail = (version) => ({ table_name: 'visits', in_schema: true, notes: {}, version, updated_at: null, updated_by: '检查脚本',
structure: null, system_notes: false, usage: 0 })
const c = await open('syn=catalog-patch&dense=1', { w: 380, h: 900, name: 'catalog-patch' })
const sent = []
let conflictOnce = true
await c.page.route('**/api/datasources/src-scenic/catalog/visits/patch**', async (route) => {
const req = route.request()
const body = req.postDataJSON()
sent.push({ url: req.url(), body, actor: req.headers()['x-actor'] })
if (req.url().endsWith('/patch/preview')) {
// 重新载入:别人刚把码值补了一个 8,改前改后按最新的算
return route.fulfill({ json: { table_name: 'visits', version: 4, problems: [], changes: [
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废', 8: '退票' }, before_status: 'confirmed',
after: { 1: '有效', 0: '作废', 8: '退票', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
] } })
}
if (conflictOnce) {
conflictOnce = false
return route.fulfill({ status: 409, json: { detail: '表「visits」的数据目录刚被修改过,请重新载入后再提交' } })
}
return route.fulfill({ json: detail(5) })
})
await c.page.route('**/api/datasources/src-scenic/catalog/visits/impact', (route) =>
route.fulfill({ json: { table: 'visits', templates: [] } }))
const card = c.page.locator('[data-catalog-patch="visits"]')
check('卡片在:标题写「建议更新数据目录」', await card.count() === 1 && (await card.innerText()).includes('建议更新数据目录'))
const codes = card.locator('[data-patch-change="columns.status.codes"]')
check('逐项写改到哪儿:「列 status 的码值」', (await codes.innerText()).includes('列 status 的码值'))
check('改前、改后、理由都在', (await codes.locator('[data-patch-before]').innerText()).includes('0=作废、1=有效')
&& (await codes.locator('[data-patch-after]').innerText()).includes('9=作废')
&& (await codes.locator('[data-patch-reason]').innerText()).includes('status=9'))
check('新补的码值加重显示', await codes.locator('[data-code-changed="9"]').count() === 1
&& await codes.locator('[data-code-changed="1"]').count() === 0)
check('值不变的项写明保存即确认', (await card.locator('[data-patch-state="confirm"]').innerText()).includes('值不变,保存即确认'))
check('改前写状态(推断)', (await card.locator('[data-patch-change="valid_filter"] [data-patch-before]').innerText()).includes('(推断)'))
check('过程里也记了一行', (await c.page.locator('[data-step-code="catalog_patch"]').innerText()).includes('建议更新「入园记录」的数据目录(3 项)'))
const overflow = await c.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('卡片不撑破 380px 窄栏', overflow <= 0, `${overflow}px`)
// 第一次保存撞上 409:写明「这张表刚被修改过」,给「重新载入」。署名在设置页填,这里直接写进本机
await c.page.evaluate(() => localStorage.setItem('agentlab_actor', '检查脚本'))
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-conflict]').waitFor({ timeout: 3000 })
check('409:提示这张表刚被修改过', (await card.innerText()).includes('这张表刚被修改过'))
check('保存带着提案对照的版本、原样取值和署名', sent[0]?.body?.if_version === 3
&& JSON.stringify(sent[0]?.body?.changes?.[0]?.value) === '{"9":"作废"}' && !!sent[0]?.actor, JSON.stringify(sent[0]))
await card.locator('[data-patch-reload]').click()
await c.page.waitForFunction(() => document.querySelector('[data-catalog-patch="visits"]')?.getAttribute('data-patch-version') === '4',
null, { timeout: 3000 })
check('重新载入交回的是助手原来的提案', sent[1]?.url.endsWith('/patch/preview')
&& JSON.stringify(sent[1]?.body?.changes?.[0]?.value) === '{"9":"作废"}', JSON.stringify(sent[1]))
check('重新载入后改前按最新的目录写', (await codes.locator('[data-patch-before]').innerText()).includes('8=退票'))
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
check('再保存带新版本', sent[2]?.body?.if_version === 4, JSON.stringify(sent[2]?.body))
check('保存成功:写明第几版,给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('已保存到数据目录(第 5 版)')
&& (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-scenic/visits')
check('保存后不再有保存按钮', await card.locator('[data-patch-save]').count() === 0)
check('没有运行时报错', c.errors.length === 0, c.errors.join(' | '))
await c.page.close()
// 忽略:收成一行,可以重新查看;不发任何请求
const g = await open('syn=catalog-patch&dense=1', { w: 380, h: 900 })
let writes = 0
await g.page.route('**/api/datasources/**', (route) => { writes++; return route.abort() })
const gc = g.page.locator('[data-catalog-patch="visits"]')
await gc.locator('[data-patch-ignore]').click()
check('忽略:收成一行说明', (await gc.getAttribute('data-patch-phase')) === 'ignored'
&& (await gc.innerText()).includes('已忽略这条建议'))
await gc.locator('[data-patch-unignore]').click()
check('重新查看:卡片回来', await gc.locator('[data-patch-save]').count() === 1)
check('忽略不发请求', writes === 0, `${writes}`)
await g.page.close()
})
await section('步骤行和画布联动', async () => {
const { page } = await open('syn=mixed&link=1', { w: 1100, h: 800 })
const row = page.locator('[data-node-id="agent"]').first()
+31
View File
@@ -1112,6 +1112,37 @@ await section('助手改图:一步撤销、回执、只回话、失败退回
check('reply:这一轮记下回答', t2.reply?.includes('input.goal') && t2.outcome === 'answered', t2.outcome)
check('reply:画布没动、不占撤销栈', (await st(page)).nodes.length === b2.nodes.length && (await st(page)).past === b2.past)
// 2b) 用户说了一条数据事实:助手只回答,附一条目录修改提案。卡片在助手栏这一轮里,点保存才写(这里在浏览器层答掉)
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [
{ path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' },
value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] },
{ op: 'reply', text: '已整理成数据目录的修改建议。' },
]
const patchSent = []
await page.route(/\/api\/datasources\/src-shop\/catalog\/orders\/patch$/, (route) => {
patchSent.push(route.request().postDataJSON())
return route.fulfill({ json: { table_name: 'orders', in_schema: true, notes: {}, version: 3, updated_at: null, updated_by: null,
structure: null, system_notes: false, usage: 0 } })
})
await page.route(/\/api\/datasources\/src-shop\/catalog\/orders\/impact$/, (route) =>
route.fulfill({ json: { table: 'orders', templates: [] } }))
const b2b = await st(page)
await S(page, () => window.__studio.getState().runCopilot('status=9 表示作废', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t2b = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('目录修改提案:这一轮照样记下回答', t2b.outcome === 'answered' && t2b.ops.some((o) => o.op === 'catalog_patch'), t2b.outcome)
check('目录修改提案:画布没动', (await st(page)).nodes.length === b2b.nodes.length && (await st(page)).past === b2b.past)
const patchCard = page.locator('[data-catalog-patch="orders"]').last()
check('助手栏里有「建议更新数据目录」卡片', await patchCard.waitFor({ timeout: 3000 }).then(() => true, () => false)
&& (await patchCard.innerText()).includes('列 status 的码值'))
await patchCard.locator('[data-patch-save]').click()
await patchCard.locator('[data-patch-done]').waitFor({ timeout: 3000 })
check('保存带版本和原样取值', patchSent[0]?.if_version === 2 && JSON.stringify(patchSent[0]?.changes?.[0]?.value) === '{"9":"作废"}',
JSON.stringify(patchSent[0]))
check('保存后给到数据目录的入口', (await patchCard.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-shop/orders')
// 3) 从头生成,搭到一半失败:画布退回原样,半成品能 ⇧⌘Z 找回
state.stream = [
{ op: 'model', model: 'fake-model' },