助手面板和画布显示 SQL 检查:自查与交付的问题写中文规则名、级别、涉及的表和列与 SQL 片段,不挡运行的提醒单列一组;问题面板、发布前检查和助手的「定位」落到调用工具参数里的 SQL

This commit is contained in:
ThearchyHelios
2026-10-03 11:53:33 +08:00
parent 5d7b652149
commit dcce17de2d
5 changed files with 143 additions and 36 deletions
+19
View File
@@ -77,9 +77,28 @@ export function revealField(nodeId: string, field?: FieldRef | string | null, op
const to = target.querySelector<HTMLElement>(FILLABLE) ?? target.querySelector<HTMLElement>(PRESSABLE)
to?.focus({ preventScroll: true })
}
// 子键没有自己的一行、字段是一段 JSON(调用工具的「参数」,问题落在 args.sql 上):在文字里找到那个键,
// 框内滚到那一行,聚焦时光标放在它的值开头
if (!part && at.sub) revealJsonKey(target, at.sub, !!opts.focus)
}))
}
/** JSON 文字框里的某个键:滚到它那一行;focus 时把光标放在值的开头(不选中,免得一敲键就把整段 SQL 换掉) */
function revealJsonKey(field: HTMLElement, key: string, focus: boolean): void {
const box = field.querySelector<HTMLTextAreaElement>('textarea')
if (!box) return
const m = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:\\s*"?`).exec(box.value)
if (!m) return
const line = box.value.slice(0, m.index).split('\n').length - 1
const height = parseFloat(getComputedStyle(box).lineHeight) || 16
box.scrollTop = Math.max(0, (line - 1) * height)
if (focus) {
const at = m.index + m[0].length
box.focus({ preventScroll: true })
box.setSelectionRange(at, at)
}
}
// 选中 → 面板开不开。放在模块级:选中可能来自别处(问题面板定位、发布弹窗、快捷键),
// 规则只写这一份
useStudio.subscribe((s, prev) => {
+6 -2
View File
@@ -13,6 +13,7 @@ import { useCatalog } from '../store/catalog'
import { Modal, Spinner, toast, useRadioGroup } from '../components/ui'
import { humanizeError } from '../lib/errors'
import { PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL } from '../lib/terms'
import { isSqlCheckCode, sqlRuleLabel } from '../lib/sqlcheck'
import { setLocalActor, useLocalActor } from '../lib/actor'
import {
autoFixIds, choiceReady, contentSig, fixFieldLabel, fixFor, fixValueLines, fixValueText, isMissingEndpoint, mergeIssues,
@@ -42,7 +43,8 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: {
workflow: Workflow
onClose: () => void
onDone: () => void
onLocate: (nodeId: string) => void
/** 定位一条问题:选中节点,能落到具体配置项(调用工具的 SQL)就落到那一项 */
onLocate: (issue: ValidationIssue) => void
}) {
const load = useStudio((s) => s.load)
const dirty = useStudio((s) => s.dirty)
@@ -208,7 +210,7 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: {
</dl>
<div className="mt-3" data-preflight="dialog">
<Preflight pf={pf} gate={gate} onLocate={(issue) => { if (issue.node_id) { onLocate(issue.node_id); onClose() } }} />
<Preflight pf={pf} gate={gate} onLocate={(issue) => { if (issue.node_id) { onLocate(issue); onClose() } }} />
</div>
</Modal>
)
@@ -564,6 +566,8 @@ function PreflightRow({ issue, others, fix, where, pf, onLocate }: {
aria-label={err ? '错误' : '提示'} />
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{where && !String(issue.message).includes(`「${where}」`) && <b className="font-semibold">「{where}」</b>}
{/* 对照数据目录的 SQL 检查:先写中文规则名,不露规则编号 */}
{isSqlCheckCode(issue.code) && <span className="font-medium" data-preflight-rule="">{sqlRuleLabel(issue.code)}:</span>}
{issue.message}
</span>
{issue.node_id && <CornerDownRight size={10} className="mt-px shrink-0 text-faint" aria-hidden />}
+7 -2
View File
@@ -14,7 +14,7 @@ import { LineageLayer } from '../canvas/LineageLayer'
import { VersionsSheet } from '../canvas/VersionsSheet'
import { PublishDialog } from '../canvas/PublishDialog'
import { WorkflowPicker, confirmDiscard, createWorkflow, takeDiscarded } from '../canvas/WorkflowPicker'
import { problemsOf, type Problem } from '../canvas/issues'
import { fieldOfIssue, problemsOf, type Problem } from '../canvas/issues'
import { STUDIO_SHORTCUTS, hintOf, studioShortcut, type StudioShortcutId } from '../canvas/shortcuts'
import { AssistantPanel } from '../run/AssistantPanel'
import { copilotProgress } from '../run/Composer'
@@ -620,7 +620,12 @@ export function StudioPage() {
{publishing && workflow && (
<PublishDialog workflow={workflow} onClose={() => setPublishing(false)}
onDone={() => { setPublishing(false); void refresh() }}
onLocate={(id) => { select(id); focusNode(id) }} />
onLocate={(issue) => {
// 和问题面板同一个落点:选中节点、取景,问题落在某一项配置上(调用工具的 SQL)就翻到那一项
if (!issue.node_id) return
const node = useStudio.getState().nodes.find((n) => n.id === issue.node_id)
revealField(issue.node_id, fieldOfIssue(issue, node))
}} />
)}
</div>
)
+45 -24
View File
@@ -14,13 +14,16 @@ import { splitShrunk } from '../canvas/copilotMerge'
import { confirmDialog, IconButton, StatusBadge, toast } from '../components/ui'
import { formatCost, formatLapse, formatOffset, formatSpan, formatTokens, shortId, NONE } from '../lib/format'
import { statusLabel } from '../lib/status'
import { runClassLabel } from '../lib/terms'
import { runClassLabel, SQL_CHECK_TEXT } from '../lib/terms'
import { sortSqlChecks } from '../lib/sqlcheck'
import type { RunUsage, ToolChange } from '../types'
import { AssistantStream, type StreamTurn } from './AssistantStream'
import { Composer } from './Composer'
import { SqlCheckBody } from './SqlChecks'
import { EvidenceHostContext, type EvidenceHost } from './evidenceHost'
import {
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, SELF_CHECK_ROUNDS, type CopilotIssue, type CopilotOutcome,
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, issueSqlCheck, SELF_CHECK_ROUNDS, type CopilotIssue,
type CopilotOutcome,
} from './decode'
import { ApprovalCard } from './RunPanel'
import { useRunGlance } from './RunHud'
@@ -287,11 +290,13 @@ function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => voi
)
}
const issues = out.check?.status === 'failed' ? out.check.issues : []
// 对照数据目录的 SQL 检查:不挡运行的提醒、提示(错误交回模型改过,没改好的在上面那组)
const sqlNotes = sortSqlChecks(out.sqlChecks)
const { unasked, asked } = toolLossOf(out)
// 画布一处没变(unchanged)就没有可撤的:给了按钮,撤掉的是这一轮之前的那一步
const undo = source.outcome === 'applied'
|| (out.kind === 'built' && source.outcome !== 'reverted' && source.outcome !== 'unchanged')
if (!issues.length && !out.skipped.length && !unasked.length && !asked.length) {
if (!issues.length && !out.skipped.length && !unasked.length && !asked.length && !sqlNotes.length) {
return undo ? (
<button type="button" className="btn btn-xs btn-ghost" onClick={() => undoCopilotTurn(source.id)}>
<Undo2 size={11} aria-hidden /> 撤销这次生成
@@ -304,6 +309,10 @@ function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => voi
<div className="w-full space-y-1.5">
{/* 没修好的问题直接摊开,不折叠:这是此刻唯一要做的事 */}
{issues.length > 0 && <IssueList issues={issues} onLocate={locateIssue} />}
{sqlNotes.length > 0 && (
<IssueList issues={sqlNotes} onLocate={locateIssue} title={SQL_CHECK_TEXT.title}
plain={sqlNotes.every((x) => x.level === 'info')} />
)}
{(unasked.length > 0 || asked.length > 0) && <ToolLossList unasked={unasked} asked={asked} onLocate={locateTools} />}
<div className="flex flex-wrap items-center gap-1.5">
{repairable && (
@@ -378,28 +387,40 @@ function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => voi
)
}
/** 自查没修好的问题,逐条带「定位」。问题落在哪个节点上认得出来才给;写节点名,认不出才写 id */
function IssueList({ issues, onLocate }: { issues: CopilotIssue[]; onLocate: (x: CopilotIssue) => void }) {
/**
* 自查没修好的问题,逐条带「定位」。问题落在哪个节点上认得出来才给;写节点名,认不出才写 id。
* 对照数据目录的 SQL 检查另画级别、中文规则名、涉及的表和列、SQL 片段(和证据面板同一套),「定位」落到 SQL 那一项。
* title 有值时是不挡运行的那一组(SQL 检查的提醒、提示):全是提示时不用提醒色
*/
function IssueList({ issues, onLocate, title, plain = false }: {
issues: CopilotIssue[]; onLocate: (x: CopilotIssue) => void; title?: string; plain?: boolean
}) {
return (
<ul data-issue-list="" className="space-y-0.5 rounded border px-2 py-1.5 text-2xs leading-relaxed"
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))',
background: 'var(--st-waiting-soft)' }}>
{issues.map((x, i) => (
<li key={i} className="flex items-start gap-1.5" data-issue-node={x.nodeId} data-issue-field={x.field}>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]" title={issueLine(x)}>
{x.nodeId && <span className={clsx('text-dim', !x.label && 'mono')}>「{x.label ?? x.nodeId}」</span>}{x.message}
</span>
{x.nodeId && (
<button type="button"
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-dim transition-colors hover:bg-hover hover:text-fg"
title={x.field ? '打开该节点的设置,定位到出问题的配置项' : '在画布上定位这个节点'}
onClick={() => onLocate(x)}>
<Crosshair size={10} aria-hidden /> 定位
</button>
)}
</li>
))}
</ul>
<div data-issue-list={title ? 'sql' : ''} className="rounded border px-2 py-1.5 text-2xs leading-relaxed"
style={plain ? undefined : { borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))', background: 'var(--st-waiting-soft)' }}>
{title && <div className="mb-1 font-medium text-fg">{title}</div>}
<ul className="space-y-1">
{issues.map((x, i) => {
const sql = issueSqlCheck(x)
return (
<li key={i} className="flex items-start gap-1.5" data-issue-node={x.nodeId} data-issue-field={x.field} data-issue-code={x.code}>
<div className="min-w-0 flex-1 [overflow-wrap:anywhere]" title={issueLine(x)}>
{x.nodeId && <span className={clsx('text-dim', !x.label && 'mono')}>「{x.label ?? x.nodeId}」</span>}
{sql ? <div className="mt-0.5"><SqlCheckBody check={sql} /></div> : x.message}
</div>
{x.nodeId && (
<button type="button"
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-dim transition-colors hover:bg-hover hover:text-fg"
title={x.field ? '打开该节点的设置,定位到出问题的配置项' : '在画布上定位这个节点'}
onClick={() => onLocate(x)}>
<Crosshair size={10} aria-hidden /> 定位
</button>
)}
</li>
)
})}
</ul>
</div>
)
}
+66 -8
View File
@@ -1,8 +1,9 @@
import type { RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
import type { RunEvent, SqlCheckItem, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import { JUDGE_TEXT, MERGE_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
import { isSqlCheckCode, sortSqlChecks, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
// types.ts 里;这里再导出一遍,老引用不用改
@@ -2452,8 +2453,14 @@ export interface CopilotIssue {
label?: string
/** 落在节点的哪一项配置上(condition、tools、agents[1].tools):「定位」据此聚焦到检查器里那个输入框 */
field?: string
/** 机读代号:datasource_out_of_scope、tools_dropped…… */
/** 机读代号:datasource_out_of_scope、tools_dropped……;SQL 检查是规则编号(fanout_sum……) */
code?: string
/** error / warning / info。老会话、老后端不给 */
level?: 'error' | 'warning' | 'info'
/** SQL 检查的问题另带:涉及的表和列、SQL 片段 */
table?: string
column?: string
sqlExcerpt?: string
}
/**
@@ -2468,11 +2475,16 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
}
if (v && typeof v === 'object') {
const o = v as Record<string, unknown>
const str = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
return named({
message: String(o.message ?? ''),
...(o.node_id ? { nodeId: String(o.node_id) } : {}),
...(typeof o.field === 'string' && o.field ? { field: o.field } : {}),
...(typeof o.code === 'string' && o.code ? { code: o.code } : {}),
...(str(o.field) ? { field: str(o.field) } : {}),
...(str(o.code) ? { code: str(o.code) } : {}),
...(o.level === 'error' || o.level === 'warning' || o.level === 'info' ? { level: o.level } : {}),
...(str(o.table) ? { table: str(o.table) } : {}),
...(str(o.column) ? { column: str(o.column) } : {}),
...(str(o.sql_excerpt) ? { sqlExcerpt: str(o.sql_excerpt) } : {}),
})
}
const text = String(v ?? '')
@@ -2480,9 +2492,37 @@ function parseIssue(v: unknown, labels?: (id: string) => string | undefined): Co
return named(m ? { nodeId: m[1], message: m[2] || text } : { message: text })
}
/** 一条自查问题写成一行:带节点名(认得出时),认不出才退到 id */
export const issueLine = (x: CopilotIssue): string =>
x.nodeId ? `「${x.label ?? x.nodeId}」${x.message}` : x.message
/**
* 一条自查问题写成一行:带节点名(认得出时),认不出才退到 id。SQL 检查的问题前面写中文规则名、后面写涉及的表和列,
* 不露规则编号
*/
export const issueLine = (x: CopilotIssue): string => {
const where = isSqlCheckCode(x.code) ? sqlCheckWhere({ table: x.table ?? '', column: x.column }) : ''
const body = isSqlCheckCode(x.code)
? `${sqlRuleLabel(x.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
: x.message
return x.nodeId ? `「${x.label ?? x.nodeId}」${body}` : body
}
/** 一条自查问题按 SQL 检查读(界面上画级别、规则名、表和列、SQL 片段);不是 SQL 检查的返回 null */
export function issueSqlCheck(x: CopilotIssue): SqlCheckItem | null {
if (!isSqlCheckCode(x.code)) return null
return {
code: x.code, level: x.level ?? 'error', message: x.message, table: x.table ?? '',
...(x.column ? { column: x.column } : {}), ...(x.sqlExcerpt ? { sql_excerpt: x.sqlExcerpt } : {}),
}
}
/** final.issues 里对照数据目录的 SQL 检查结果(不挡运行的提醒、提示也在里面);自查没修好、已经列过的错误不再算 */
function sqlIssuesOf(ops: CopilotOp[], labels: (id: string) => string | undefined): CopilotIssue[] {
const final = [...ops].reverse().find((o) => o.op === 'final')
const list: unknown[] = final && Array.isArray(final.issues) ? final.issues : []
const failed = [...ops].reverse().find((o) => o.op === 'check' && o.status === 'failed')
const listed = new Set((failed && Array.isArray(failed.issues) ? failed.issues : [])
.map((x: unknown) => parseIssue(x)).map((x: CopilotIssue) => `${x.nodeId ?? ''}|${x.code ?? ''}|${x.message}`))
return list.map((x) => parseIssue(x, labels))
.filter((x) => isSqlCheckCode(x.code) && !listed.has(`${x.nodeId ?? ''}|${x.code ?? ''}|${x.message}`))
}
/**
* 节点 id → 名字。先认这一轮操作流里加过、改过的(问数据页没有画布,只有这一份),
@@ -2784,6 +2824,21 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
add({ id: `cm-${i}-${t}`, seq: i, kind: 'note', status: 'done', level: 'warn',
title: clip(`已跳过一个步骤:节点类型「${t}」不存在`, 80) })
}
// 对照数据目录的 SQL 检查:错误交回模型改过(没改好的在自查那一行),不挡运行的提醒、提示照样要看得见
const sql = sqlIssuesOf(ops.slice(0, i), labels)
if (sql.length) {
const n = { error: 0, warning: 0, info: 0 }
for (const x of sql) n[x.level ?? 'error'] += 1
const nodes = [...new Set(sql.map((x) => x.nodeId).filter(Boolean))]
add({
id: `cs-${i}`, seq: i, kind: 'note', status: 'done', code: 'sql_check',
...(n.error || n.warning ? { level: 'warn' as const } : {}),
title: SQL_CHECK_TEXT.assistantTitle(SQL_CHECK_TEXT.counts(n.error, n.warning, n.info)),
detail: sortSqlChecks(sql).map(issueLine).join('\n'),
next: n.error || n.warning ? SQL_CHECK_TEXT.assistantNext : SQL_CHECK_TEXT.assistantInfoNext,
...(nodes.length === 1 ? { nodeId: nodes[0], fix: 'canvas' as const } : {}),
})
}
// 工具绑定变化单独成一行:改图回执以前只说「修改 1」,模型改提示词时漏写 tools、
// 把查库的工具整个抹掉,要到运行结果不对才发现
const changes = toolChangesOf(op)
@@ -2880,6 +2935,8 @@ export interface CopilotOutcome {
toolChanges: ToolChange[]
/** 自查的「工具被去掉了」警告(tools_dropped):这一轮的要求里没提到去掉 */
dropped: CopilotIssue[]
/** 对照数据目录的 SQL 检查结果(final.issues),自查没修好、已经列在 check 里的不重复 */
sqlChecks: CopilotIssue[]
}
export function copilotOutcome(
@@ -2887,7 +2944,7 @@ export function copilotOutcome(
): CopilotOutcome {
const res: CopilotOutcome = {
kind: running ? 'running' : 'empty', skipped: [], added: 0, updated: 0, removed: 0,
toolChanges: [], dropped: [],
toolChanges: [], dropped: [], sqlChecks: [],
}
const labels = labelsOf(ops, labelOf)
for (const op of ops) {
@@ -2919,6 +2976,7 @@ export function copilotOutcome(
res.toolChanges = toolChangesOf(op)
// 同一批警告 final.issues 里也有一份:自查已经带回来了就不再算一遍
if (!res.dropped.length) res.dropped = droppedOf(op)
res.sqlChecks = sqlIssuesOf(ops, labels)
if (!running) res.kind = 'built'
break
case 'error':