From 1294095d1ea6b0ed085d2f4d2a2f87804c6100f6 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:56:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=201B=20=E5=89=8D=E7=AB=AF=EF=BC=9A=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=92=8C=E9=97=AE=E6=95=B0=E6=8D=AE=E9=A1=B5=E7=9A=84=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E9=87=8C=E6=98=BE=E7=A4=BA=E3=80=8C=E5=8F=82=E8=80=83?= =?UTF-8?q?=E4=BA=86=20N=20=E5=BC=A0=E8=A1=A8=E3=80=8D=EF=BC=8C=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E6=8C=89=E6=95=B0=E6=8D=AE=E6=BA=90=E5=88=97=E5=87=BA?= =?UTF-8?q?=E8=A1=A8=E5=90=8D=EF=BC=8C=E6=8C=91=E8=A1=A8=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E5=86=99=E6=98=8E=E5=8F=AA=E6=8F=90=E4=BE=9B=E4=BA=86?= =?UTF-8?q?=E8=A1=A8=E5=90=8D=E5=8F=8A=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot_context.py | 10 +-- frontend/src/dev/preview.tsx | 11 +++- frontend/src/run/__tests__/synthetic.ts | 35 ++++++++++ frontend/src/run/decode.ts | 87 +++++++++++++++++++++++++ frontend/src/store/studio.ts | 8 ++- scripts/check-chat.mjs | 13 ++++ scripts/check-decode.mjs | 43 ++++++++++++ scripts/check-stream.mjs | 37 +++++++++++ scripts/check-studio.mjs | 9 +++ 9 files changed, 246 insertions(+), 7 deletions(-) diff --git a/backend/app/api/copilot_context.py b/backend/app/api/copilot_context.py index 3a5e3aa2..d2893c1f 100644 --- a/backend/app/api/copilot_context.py +++ b/backend/app/api/copilot_context.py @@ -10,7 +10,7 @@ - all:放得下详细摘要的库,照旧每张表一行带字段,额外带上数据目录和表之间的连接条件; - model:大库,挑表成功:挑中的表讲透,其余只列名字; -- fallback:大库,挑表失败(模型不可用、报错、超时、回复读不出、返回为空或返回的表都不存在),退回以前的 +- fallback:大库,挑表失败(模型不可用、报错、超时、回复无法识别、返回为空或返回的表都不存在),退回以前的 「只列表名」。挑表只是让模型少猜,**绝不能挡住生成**:没有它,模型照旧可以先用 db_schema 查字段。 现有工作流(画布上改图时的原图、问数据追问时上一轮的图)的 SQL 里查过哪些表是确定的事实:大库里这些表一律 @@ -269,7 +269,7 @@ async def plan_context(sources: list[Any], *, graph: Mapping[str, Any] | None = class _Unreadable(ValueError): - """模型的回复读不出表名。""" + """模型的回复里识别不出表名。""" def _picked(raw: Any) -> list[tuple[str | None, str]] | None: @@ -316,7 +316,7 @@ async def _ask(model: Any, messages: list[tuple[str, str]]) -> list[tuple[str | return parsed parsed = _picked(message_text(await model.ainvoke(messages))) if parsed is None: - raise _Unreadable("模型的回复不是约定的格式,读不出要用的表") + raise _Unreadable("模型的回复不是约定的格式,无法识别要用的表") return parsed @@ -386,13 +386,13 @@ class ContextPlan: reason: str | None = None chosen: list[tuple[int, str]] = [] if model is None: - reason = unavailable or "没有可用的模型" + reason = unavailable or "未配置可用的模型" else: try: picks = await asyncio.wait_for(_ask(model, self._pick_messages(need)), timeout=PICK_TIMEOUT_S) chosen = self._resolve(picks) if not picks: - reason = "模型没有挑出任何表" + reason = "模型未挑出任何表" elif not chosen: reason = "模型挑出的表在表结构中都不存在" except asyncio.TimeoutError: diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index 2b484041..fb93b9f5 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -13,7 +13,7 @@ import mdSamples from '../run/__tests__/markdown-samples.json' import evidenceFixture from '../run/__tests__/evidence-doc.json' import evidenceJudge from '../run/__tests__/evidence-judge.json' import { - CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop, + CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop, markupRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun, } from '../run/__tests__/synthetic' import '../index.css' @@ -323,6 +323,8 @@ function synthetic(name: string): StreamTurn[] { // 问数据的一轮:先建图、再执行。执行开始后「规划」收成一行 const plan = decodeCopilot([ { op: 'heartbeat', phase: 'planning', elapsed_ms: 6400 }, + { op: 'context', elapsed_ms: 2100, sources: [ + { source: 'warehouse', tables: ['v_device_kpi', 'dim_device'], selected_by: 'model', total: 53 }] }, { op: 'thinking', delta: '要先查表结构,再按设备汇总 KPI。' }, { op: 'plan', summary: '取数 → 汇总 → 出结论' }, { op: 'add_node', node: { id: 'q', type: 'tool', label: '查询设备 KPI' } }, @@ -337,6 +339,13 @@ function synthetic(name: string): StreamTurn[] { graph: { nodes: [{ id: 'q', type: 'tool', data: { label: '查询设备 KPI' } }, { id: 'o', type: 'output', data: { label: '成果' } }] } }] } + case 'copilot-context': + // 大库按需求挑表:过程里一行「参考了 N 张表」,展开按数据源分组列表名 + return [{ id: 'copilot-context', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布', + steps: decodeCopilot(COPILOT_CONTEXT, { context: 'canvas' }) }] + case 'copilot-fallback': + return [{ id: 'copilot-fallback', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布', + steps: decodeCopilot(COPILOT_CONTEXT_FALLBACK, { context: 'canvas' }) }] case 'copilot': return [{ id: 'copilot', question: '做一个每天的出勤日报', phase: 'done', status: '已应用到画布,仍有 2 处问题待处理', diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts index b3231f76..bb1f0887 100644 --- a/frontend/src/run/__tests__/synthetic.ts +++ b/frontend/src/run/__tests__/synthetic.ts @@ -395,6 +395,41 @@ export const COPILOT_TOOLS_DROPPED = [ ] }, ] +/** + * 助手按需求挑表(context 操作,copilot_context.DatasourceContext.op):大库挑了 3 张(含补进来的关联表), + * 小库全部表都带字段。挑表用了 3 秒多,先到一拍心跳,再是 context,然后照常思考、起草、自查 + */ +export const COPILOT_CONTEXT = [ + { op: 'model', model: 'claude-sonnet-4' }, + { op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 }, + { op: 'context', elapsed_ms: 3420, sources: [ + { source: 'scenic', tables: ['visits', 'channels', 'channel_visits'], selected_by: 'model', total: 51 }, + { source: 'shop', tables: ['orders', 'customers'], selected_by: 'all', total: 2 }, + ] }, + { op: 'heartbeat', phase: 'planning', elapsed_ms: 6000 }, + { op: 'thinking', delta: '入园记录不带渠道,要经 channel_visits 连到渠道表,再按渠道汇总上月的入园人次。' }, + { op: 'plan', summary: '取数 → 出结论' }, + { op: 'add_node', node: { id: 'q', type: 'tool', label: '查询各渠道入园人次' } }, + { op: 'add_node', node: { id: 'o', type: 'output', label: '成果' } }, + { op: 'done', explanation: '两步' }, + { op: 'check', status: 'passed', repaired: 0 }, + { op: 'final', graph: { nodes: [{ id: 'q' }, { id: 'o' }] } }, +] + +/** 挑表失败(超时):只给了表名,原因随 context 带来;生成照常进行 */ +export const COPILOT_CONTEXT_FALLBACK = [ + { op: 'model', model: 'claude-sonnet-4' }, + { op: 'heartbeat', phase: 'planning', elapsed_ms: 18000 }, + { op: 'context', elapsed_ms: 20004, sources: [ + { source: 'scenic', tables: [], selected_by: 'fallback', total: 51, reason: '挑选数据表超过 20 秒未完成' }, + ] }, + { op: 'plan', summary: '先查表结构,再取数' }, + { op: 'add_node', node: { id: 's', type: 'tool', label: '查看表结构' } }, + { op: 'add_node', node: { id: 'o', type: 'output', label: '成果' } }, + { op: 'done', explanation: '两步' }, + { op: 'final', graph: { nodes: [{ id: 's' }, { id: 'o' }] } }, +] + /** Copilot 操作流:思考被心跳打断、自查修一轮没修好、模型写了不存在的节点类型 */ export const COPILOT_STUCK = [ { op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 }, diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 6b0dde52..33d729af 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -2404,6 +2404,83 @@ function labelsOf(ops: CopilotOp[], fallback?: (id: string) => string | undefine return (id) => map.get(id) ?? fallback?.(id) } +/** context 操作里一个数据源的那一项(服务端 copilot_context.DatasourceContext.op) */ +export interface CopilotContextSource { + source: string + /** 模型看得到全部字段的表(全名)。只给了表名的不在这里 */ + tables: string[] + /** model:按需求挑的;all:库不大,全部表都带字段;fallback:没能挑出来,只给了表名 */ + selectedBy: 'model' | 'all' | 'fallback' + /** 这个库一共几张表 */ + total?: number + /** 没能挑出来的原因 */ + reason?: string +} + +/** 挑表失败时的说法:模型这一轮只看到表名,字段要它自己去查 */ +export const CONTEXT_FALLBACK_TEXT = '未能按需求挑选,已提供全部表名' + +function contextSources(op: CopilotOp): CopilotContextSource[] { + const list: unknown[] = Array.isArray(op.sources) ? op.sources : [] + return list + .filter((x): x is Record => !!x && typeof x === 'object' && typeof (x as any).source === 'string') + .map((x) => { + const total = num(x.total) + return { + source: x.source, + tables: Array.isArray(x.tables) ? x.tables.map(String) : [], + selectedBy: x.selected_by === 'model' || x.selected_by === 'fallback' ? x.selected_by : 'all', + ...(total != null ? { total } : {}), + ...(typeof x.reason === 'string' && x.reason ? { reason: x.reason } : {}), + } + }) +} + +/** 一个库在展开区里的那一行:怎么来的 + 表名 */ +function contextLine(g: CopilotContextSource): string { + const names = g.tables.join('、') + const of = g.total != null ? `共 ${g.total} 张表` : '' + switch (g.selectedBy) { + case 'all': + return `「${g.source}」全部 ${g.tables.length} 张表:${names}` + case 'model': + return g.tables.length + ? `「${g.source}」按需求${g.total != null ? `从 ${g.total} 张表中` : ''}挑出 ${g.tables.length} 张:${names}` + : `「${g.source}」${of ? `${of},` : ''}未挑中与需求相关的表,已提供全部表名` + default: + return `「${g.source}」${of ? `${of},` : ''}${CONTEXT_FALLBACK_TEXT}` + + (g.tables.length ? `;现有工作流用到的 ${g.tables.length} 张表附带全部字段:${names}` : '') + } +} + +/** + * 助手这一轮参考了哪些表(context 操作)→ 过程里的一行。 + * + * 大库放不下全部字段,服务端先按需求挑表,挑中的表给模型全部字段。这件事要看得见:生成的 SQL 用错了表, + * 先得知道模型当时看到的是哪几张。标题只说几张(「参考了 8 张表」),展开按数据源分组列表名;挑表失败时 + * 标题直说只给了表名,原因放在展开区。认不出的形状返回 null:不出这一行,也不报错。 + */ +export function copilotContext(op: CopilotOp): { title: string; sub?: string; detail: string; ms?: number } | null { + const groups = contextSources(op) + if (!groups.length) return null + const count = groups.reduce((n, g) => n + g.tables.length, 0) + const failed = groups.filter((g) => g.selectedBy === 'fallback') + // 有表带着字段(小库,或者现有工作流用到的表)时标题仍说几张,没挑成的事放副标题 + const sub = count && failed.length + ? failed.length === groups.length ? CONTEXT_FALLBACK_TEXT + : `「${failed.map((g) => g.source).join('」「')}」${CONTEXT_FALLBACK_TEXT}` + : undefined + const reasons = [...new Set(failed.map((g) => g.reason).filter(Boolean))] + // 挑过表才有 elapsed_ms:挑表是生成之前多出来的一次模型调用,没挑成(比如超时)也要让人看到等了多久 + const ms = num(op.elapsed_ms) + return { + title: count ? `参考了 ${count} 张表` : CONTEXT_FALLBACK_TEXT, + ...(sub ? { sub } : {}), + detail: [...groups.map(contextLine), ...reasons.map((r) => `原因:${r}`)].join('\n'), + ...(ms != null && ms >= 10 ? { ms } : {}), + } +} + /** 用了限定范围之外的数据源:自查交回模型改也改不掉时,人要知道是范围的事,不是图写错了 */ const OUT_OF_SCOPE_NEXT = '本轮限定了查询的数据源,但工作流使用了范围之外的数据库。请取消限定后重新提问,或将所需数据库加入查询范围' @@ -2508,6 +2585,16 @@ export function decodeCopilot(ops: CopilotOp[], opts?: { ...(elapsed ? { ms: elapsed, meta: formatDuration(elapsed) } : {}) }) break } + case 'context': { + // 这一轮参考了哪些表:标题说几张,展开看按数据源分组的表名 + const ctx = copilotContext(op) + if (!ctx) break + add({ id: `cx-${i}`, seq: i, kind: 'schema', status: 'done', code: 'copilot_context', + title: ctx.title, detail: ctx.detail, + ...(ctx.sub ? { sub: ctx.sub } : {}), + ...(ctx.ms != null ? { ms: ctx.ms, meta: formatDuration(ctx.ms) } : {}) }) + break + } case 'plan': add({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done', title: clip(String(op.summary ?? '已完成规划'), 120) }) diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index e8478261..3ea731c1 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -12,7 +12,7 @@ import { humanizeError } from '../lib/errors' import { formatShortcut } from '../lib/keys' import { UPGRADE_TEXT } from '../lib/terms' import type { CopilotOp } from '../run/decode' -import { endingOf, reduceTeam, settleTeam } from '../run/decode' +import { copilotContext, endingOf, reduceTeam, settleTeam } from '../run/decode' import { activeEdgesOf, applyDerived } from '../run/derive' import { emptyTrace, finalizeTrace, foldEvent, isActivePhase, isSettled, isTerminal, runStatusOf, @@ -1888,6 +1888,12 @@ export const useStudio = create((set, get) => ({ }, }) break + case 'context': { + // 服务端按需求挑了表:浮条和轮次卡上先说参考了几张表,展开的过程里有表名(decodeCopilot) + const ctx = copilotContext(op) + if (ctx) set({ copilot: { ...s.copilot, lastOp: ctx.title } }) + break + } case 'plan': set({ copilot: { ...s.copilot, phase: 'planning', lastOp: op.summary ?? '规划中' } }) break diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index ee36a40e..40fd88e5 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -472,6 +472,9 @@ async function fakeApi(route) { } const ops = [ { op: 'heartbeat', phase: 'planning', elapsed_ms: 800 }, + // 服务端按需求挑了表(copilot_context):过程里多一行「参考了 N 张表」 + { op: 'context', elapsed_ms: 1200, sources: [ + { source: 'shop', tables: ['orders', 'order_items'], selected_by: 'model', total: 48 }] }, { op: 'plan', summary: '查一下再回答' }, ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), { op: 'done', explanation: '一张三步的图' }, @@ -1024,6 +1027,16 @@ for (const theme of THEMES) { check('完整答案取到了,meta 里没有「没取全」', !meta?.clipped, JSON.stringify(meta?.clipped)) await page.getByText('展开全部').first().click().catch(() => {}) check('完整结尾在页面上', await shows(page, '【完整结尾】')) + // 建图时服务端说了这一轮参考了哪些表:收在「规划」里,点开是一行,再点开看表名 + check('建图的 context 操作原样记进这一轮', t.ops.some((o) => o.op === 'context')) + const turnCard = page.locator('[data-turn]').last() + await turnCard.locator('button', { hasText: '规划' }).first().click() + const ctxRow = turnCard.locator('[data-step-code="copilot_context"]') + check('点开「规划」看到参考了几张表', await ctxRow.waitFor({ timeout: 3000 }).then(() => true, () => false) + && (await ctxRow.innerText()).includes('参考了 2 张表')) + await ctxRow.locator('button').first().click() + check('再点开看到按数据源分组的表名', await ctxRow.getByText('「shop」按需求从 48 张表中挑出 2 张:orders、order_items') + .waitFor({ timeout: 3000 }).then(() => true, () => false)) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() }) diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 056f54d2..0cd1c512 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -621,6 +621,49 @@ await section('从 SQL 认表名', async () => { check('不是查询就不硬认', g('这不是 SQL') === null) }) +await section('Copilot:这一轮参考了哪些表(context 操作)', async () => { + // 大库先按需求挑表,挑中的表给模型全部字段。生成的 SQL 用错了表时,先得知道模型当时看到的是哪几张 + const steps = mod.decodeCopilot(synthetic.COPILOT_CONTEXT, { context: 'canvas' }) + const row = steps.find((s) => s.code === 'copilot_context') + check('一行说清参考了几张表', row?.title === '参考了 5 张表', row?.title) + check('展开按数据源分组列出表名', !!row?.detail?.includes('「scenic」按需求从 51 张表中挑出 3 张:visits、channels、channel_visits') + && row.detail.includes('「shop」全部 2 张表:orders、customers'), row?.detail) + check('挑表的耗时挂在行尾', row?.ms === 3420 && !!row?.meta, `${row?.ms} ${row?.meta}`) + check('是看表结构那一类、标着规划阶段', row?.kind === 'schema' && row?.stage === 'plan' && row?.status === 'done') + check('排在思考之前,心跳仍然只更新那一条阶段行', + steps.indexOf(row) < steps.findIndex((s) => s.kind === 'think') + && steps.filter((s) => s.kind === 'lifecycle' && s.id.startsWith('hb-')).length === 1, + steps.map((s) => `${s.kind}:${s.title}`).join(' | ')) + check('收尾后没有还在转的行', !steps.some((s) => s.status === 'running'), + steps.filter((s) => s.status === 'running').map((s) => s.title).join(',')) + check('结局不受影响', mod.copilotOutcome(synthetic.COPILOT_CONTEXT).kind === 'built') + + const fb = mod.decodeCopilot(synthetic.COPILOT_CONTEXT_FALLBACK, { context: 'chat' }).find((s) => s.code === 'copilot_context') + check('挑表失败:标题直说只给了表名', fb?.title === '未能按需求挑选,已提供全部表名', fb?.title) + check('挑表失败:原因和库的规模放在展开区', !!fb?.detail?.includes('原因:挑选数据表超过 20 秒未完成') + && fb.detail.includes('「scenic」共 51 张表'), fb?.detail) + check('挑表失败也说等了多久', fb?.ms === 20004, String(fb?.ms)) + + const seeded = mod.copilotContext({ op: 'context', sources: [ + { source: 'scenic', tables: ['stores'], selected_by: 'fallback', total: 51, reason: '模型未挑出任何表' }] }) + check('没挑成但现有工作流的表带着字段:标题说几张、副标题说没挑成', + seeded?.title === '参考了 1 张表' && seeded?.sub === '未能按需求挑选,已提供全部表名' + && seeded.detail.includes('现有工作流用到的 1 张表附带全部字段:stores'), JSON.stringify(seeded)) + const mixed = mod.copilotContext({ op: 'context', sources: [ + { source: 'a', tables: ['x'], selected_by: 'all', total: 1 }, + { source: 'b', tables: [], selected_by: 'fallback', total: 80, reason: 'r' }] }) + check('只有一个库没挑成:副标题点名是哪个库', mixed?.sub === '「b」未能按需求挑选,已提供全部表名', mixed?.sub) + const none = mod.copilotContext({ op: 'context', sources: [ + { source: 'a', tables: ['x'], selected_by: 'model', total: 60 }, + { source: 'b', tables: [], selected_by: 'model', total: 70 }] }) + check('挑表成功但某个库一张没挑中:说清只给了表名', !!none?.detail.includes('「b」共 70 张表,未挑中与需求相关的表,已提供全部表名'), + none?.detail) + check('形状不对不出行、不报错', mod.decodeCopilot([ + { op: 'context' }, { op: 'context', sources: 'x' }, { op: 'context', sources: [null, 3, { tables: ['t'] }] }, + ]).length === 0) + check('认不出的操作照旧忽略', mod.decodeCopilot([{ op: 'something_new', x: 1 }]).length === 0) +}) + await section('Copilot:心跳穿插、回话、少了一步、报错', async () => { const steps = mod.decodeCopilot(synthetic.COPILOT_STUCK, { context: 'canvas' }) check('心跳穿插的思考仍然并成一条', steps.filter((s) => s.kind === 'think').length === 1, diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 10075035..97c0aa77 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -694,6 +694,43 @@ await section('长表名、Copilot 结局、问数据的分段', async () => { await q.page.close() }) +await section('参考了哪些表:画布右栏和问数据页(context 操作)', async () => { + const c = await open('syn=copilot-context&dense=1', { w: 380, h: 800, name: 'copilot-context' }) + const row = c.page.locator('[data-step-code="copilot_context"]') + check('画布右栏:一行说参考了几张表', await row.count() === 1 && (await row.innerText()).includes('参考了 5 张表'), + await row.count() ? await row.innerText() : '没有这一行') + check('表名收在展开区,不铺满窄栏', !(await row.innerText()).includes('channel_visits')) + await row.locator('button').first().click() + await c.page.waitForTimeout(150) + const expanded = await row.innerText() + check('展开看到按数据源分组的表名', expanded.includes('「scenic」按需求从 51 张表中挑出 3 张') + && expanded.includes('channel_visits') && expanded.includes('「shop」全部 2 张表'), expanded.slice(0, 160)) + const overflow = await c.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check('展开后不撑破 380px 窄栏', overflow <= 0, `${overflow}px`) + check('没有运行时报错', c.errors.length === 0, c.errors.join(' | ')) + await c.page.close() + + const f = await open('syn=copilot-fallback&dense=1', { w: 380, h: 800, name: 'copilot-fallback' }) + const fb = f.page.locator('[data-step-code="copilot_context"]') + check('挑表失败:写明只给了表名', await fb.count() === 1 && (await fb.innerText()).includes('未能按需求挑选,已提供全部表名')) + await fb.locator('button').first().click() + await f.page.waitForTimeout(150) + check('挑表失败:展开看到原因', (await fb.innerText()).includes('原因:挑选数据表超过 20 秒未完成')) + await f.page.close() + + const q = await open('syn=chat', { w: 1100, h: 800, name: 'chat-context' }) + check('问数据页:执行开始后这一行随「规划」收起', !(await q.page.locator('body').innerText()).includes('参考了 2 张表')) + await q.page.locator('button', { hasText: '规划' }).first().click() + await q.page.waitForTimeout(200) + const qrow = q.page.locator('[data-step-code="copilot_context"]') + check('问数据页:点开「规划」看到参考了几张表', await qrow.count() === 1 && (await qrow.innerText()).includes('参考了 2 张表')) + await qrow.locator('button').first().click() + await q.page.waitForTimeout(150) + check('问数据页:展开看到表名', (await qrow.innerText()).includes('v_device_kpi、dim_device')) + check('问数据页:没有运行时报错', q.errors.length === 0, q.errors.join(' | ')) + await q.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() diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index f99f3caa..2d427ffe 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -1025,6 +1025,9 @@ await section('助手改图:一步撤销、回执、只回话、失败退回 } state.stream = [ { op: 'model', model: 'fake-model' }, + // 服务端按需求挑了表(copilot_context):过程里多一行「参考了 N 张表」 + { op: 'context', elapsed_ms: 1500, sources: [ + { source: 'shop', tables: ['orders', 'order_items', 'customers'], selected_by: 'model', total: 48 }] }, { op: 'plan', summary: '加一步润色' }, { op: 'update_node', id: 'answer', config: finalGraph.nodes[3].data.config }, { op: 'add_node', node: { id: 'polish', type: 'llm', label: '润色', config: { prompt: '{{ nodes.answer.text }}' } } }, @@ -1041,6 +1044,12 @@ await section('助手改图:一步撤销、回执、只回话、失败退回 const t1 = await S(page, () => window.__studio.getState().copilotTurns.at(-1)) check('开始了就告诉输入框(runCopilot 返回 true)', began === true, String(began)) check('收到 final:这一轮是「已应用」', t1.outcome === 'applied', t1.outcome) + const ctxRow = page.locator('[data-step-code="copilot_context"]').last() + check('助手栏的过程里一行说参考了几张表', await ctxRow.waitFor({ timeout: 3000 }).then(() => true, () => false) + && (await ctxRow.innerText()).includes('参考了 3 张表')) + await ctxRow.locator('button').first().click() + check('点开看到按数据源分组的表名', await ctxRow.getByText('「shop」按需求从 48 张表中挑出 3 张:orders、order_items、customers') + .waitFor({ timeout: 3000 }).then(() => true, () => false)) check('diff 记下新增和改动', t1.diff?.added.includes('polish') && t1.diff?.changed.includes('answer'), JSON.stringify(t1.diff)) check('final.issues 进了这一轮(少了一步)', t1.issues?.some((i) => i.code === 'unknown_node_type')) const pos1 = await S(page, () => Object.fromEntries(window.__studio.getState().nodes.map((n) => [n.id, n.position])))