数据目录阶段 1B 前端:画布和问数据页的过程里显示「参考了 N 张表」,展开按数据源列出表名,挑表失败时写明只提供了表名及原因

This commit is contained in:
ThearchyHelios
2026-10-03 10:56:25 +08:00
parent 9e809bafe6
commit 1294095d1e
9 changed files with 246 additions and 7 deletions
+5 -5
View File
@@ -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:
+10 -1
View File
@@ -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 处问题待处理',
+35
View File
@@ -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 },
+87
View File
@@ -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<string, any> => !!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) })
+7 -1
View File
@@ -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<StudioState>((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
+13
View File
@@ -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()
})
+43
View File
@@ -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,
+37
View File
@@ -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()
+9
View File
@@ -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])))