From dcce17de2d40e0fc24f74d06bc74ad07d08764fd Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 11:53:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A9=E6=89=8B=E9=9D=A2=E6=9D=BF=E5=92=8C?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=98=BE=E7=A4=BA=20SQL=20=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=EF=BC=9A=E8=87=AA=E6=9F=A5=E4=B8=8E=E4=BA=A4=E4=BB=98=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=86=99=E4=B8=AD=E6=96=87=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=90=8D=E3=80=81=E7=BA=A7=E5=88=AB=E3=80=81=E6=B6=89=E5=8F=8A?= =?UTF-8?q?=E7=9A=84=E8=A1=A8=E5=92=8C=E5=88=97=E4=B8=8E=20SQL=20=E7=89=87?= =?UTF-8?q?=E6=AE=B5=EF=BC=8C=E4=B8=8D=E6=8C=A1=E8=BF=90=E8=A1=8C=E7=9A=84?= =?UTF-8?q?=E6=8F=90=E9=86=92=E5=8D=95=E5=88=97=E4=B8=80=E7=BB=84=EF=BC=9B?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E9=9D=A2=E6=9D=BF=E3=80=81=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=89=8D=E6=A3=80=E6=9F=A5=E5=92=8C=E5=8A=A9=E6=89=8B=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E5=AE=9A=E4=BD=8D=E3=80=8D=E8=90=BD=E5=88=B0=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E9=87=8C=E7=9A=84?= =?UTF-8?q?=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/canvas/InspectorSheet.tsx | 19 +++++++ frontend/src/canvas/PublishDialog.tsx | 8 ++- frontend/src/pages/StudioPage.tsx | 9 +++- frontend/src/run/AssistantPanel.tsx | 69 +++++++++++++++--------- frontend/src/run/decode.ts | 74 +++++++++++++++++++++++--- 5 files changed, 143 insertions(+), 36 deletions(-) diff --git a/frontend/src/canvas/InspectorSheet.tsx b/frontend/src/canvas/InspectorSheet.tsx index 4dce4062..98746721 100644 --- a/frontend/src/canvas/InspectorSheet.tsx +++ b/frontend/src/canvas/InspectorSheet.tsx @@ -77,9 +77,28 @@ export function revealField(nodeId: string, field?: FieldRef | string | null, op const to = target.querySelector(FILLABLE) ?? target.querySelector(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('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) => { diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 469070c5..a83efba5 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -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 }: {
- { if (issue.node_id) { onLocate(issue.node_id); onClose() } }} /> + { if (issue.node_id) { onLocate(issue); onClose() } }} />
) @@ -564,6 +566,8 @@ function PreflightRow({ issue, others, fix, where, pf, onLocate }: { aria-label={err ? '错误' : '提示'} /> {where && !String(issue.message).includes(`「${where}」`) && 「{where}」} + {/* 对照数据目录的 SQL 检查:先写中文规则名,不露规则编号 */} + {isSqlCheckCode(issue.code) && {sqlRuleLabel(issue.code)}:} {issue.message} {issue.node_id && } diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index 74d0bf4b..cf4ea57d 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -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 && ( 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)) + }} /> )} ) diff --git a/frontend/src/run/AssistantPanel.tsx b/frontend/src/run/AssistantPanel.tsx index a36b1492..95105b1e 100644 --- a/frontend/src/run/AssistantPanel.tsx +++ b/frontend/src/run/AssistantPanel.tsx @@ -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 ? ( - )} - - ))} - +
+ {title &&
{title}
} +
    + {issues.map((x, i) => { + const sql = issueSqlCheck(x) + return ( +
  • +
    + {x.nodeId && 「{x.label ?? x.nodeId}」} + {sql ?
    : x.message} +
    + {x.nodeId && ( + + )} +
  • + ) + })} +
+
) } diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 5494e74b..7be48eb3 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -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 + 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':