diff --git a/frontend/src/canvas/ProblemsPanel.tsx b/frontend/src/canvas/ProblemsPanel.tsx index 7f543d05..3d965f6b 100644 --- a/frontend/src/canvas/ProblemsPanel.tsx +++ b/frontend/src/canvas/ProblemsPanel.tsx @@ -13,7 +13,7 @@ import { import { PublishPreflightPane } from './PublishDialog' import { humanizeError } from '../lib/errors' import { formatShortcut } from '../lib/keys' -import { PUBLISH_FIX_TEXT, UPGRADE_TEXT } from '../lib/terms' +import { PROVIDER_REF_TEXT, PUBLISH_FIX_TEXT, UPGRADE_TEXT } from '../lib/terms' import { useCatalog } from '../store/catalog' import { EDIT_LOCK_TEXT, upgradeUnsaved, useEditLock, useStudio, type UpgradeFlow } from '../store/studio' import { Spinner, useRadioGroup } from '../components/ui' @@ -232,7 +232,7 @@ function ProblemRow({ problem: p, active, onLocate, onDeleteEdge }: { aria-label={err ? '错误' : '提示'} /> {p.message} {where && ( - + {where} )} @@ -615,13 +615,17 @@ function UpgradeValue({ value, field, nameOf, className, ...data }: { ) } -const SUB_LABEL: Record = { condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示' } +const SUB_LABEL: Record = { + condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示', provider: PROVIDER_REF_TEXT.memberField, +} +/** 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏)。不写的话露出键名 */ +const FIELD_FALLBACK: Record = { provider: PROVIDER_REF_TEXT.field } function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record): string { if (!field) return '' if (field.key === 'label') return '节点名称' const def = type ? NODE_DEFS[type as keyof typeof NODE_DEFS] : undefined - const base = def?.fields.find((f) => f.key === field.key)?.label ?? field.key + const base = def?.fields.find((f) => f.key === field.key)?.label ?? FIELD_FALLBACK[field.key] ?? field.key if (field.index == null) return base const sub = (field.sub && SUB_LABEL[field.sub]) || '' // 成员有名字就叫名字:「第 2 个成员」还得数,「成员 writer」一眼就知道是谁 diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 10b5a867..2ba3c3f8 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -272,12 +272,13 @@ const unsavedApply = (a: ApplyState) => a.status === 'failed' || (a.status === ' const unsavedFix = (pf: PreflightState) => !!pf.apply && unsavedApply(pf.apply) /** - * 节点 id → 名字和类型。跟着 issues 换新(改名、改配置之后校验都会重跑),不订阅 nodes: - * 问题面板开着时拖一下节点,这一整块不必跟着每帧重渲染 + * 节点 id → 名字、类型和配置。跟着 issues 换新(改名、改配置之后校验都会重跑),不订阅 nodes: + * 问题面板开着时拖一下节点,这一整块不必跟着每帧重渲染。配置给修复预览认列表里的那一项(成员「分析师」) */ -function useNodeLookup(): (id: string) => { label: string; type: string } | undefined { +function useNodeLookup(): (id: string) => { label: string; type: string; config?: Record } | undefined { const tick = useStudio((s) => s.issues) - const map = useMemo(() => new Map(useStudio.getState().nodes.map((n) => [n.id, { label: n.data.label, type: n.data.nodeType }])), + const map = useMemo(() => new Map(useStudio.getState().nodes.map((n) => [n.id, + { label: n.data.label, type: n.data.nodeType, config: n.data.config }])), // eslint-disable-next-line react-hooks/exhaustive-deps [tick]) return useCallback((id: string) => map.get(id), [map]) @@ -544,7 +545,8 @@ export function Preflight({ pf, gate, onLocate, stale }: { )} {pf.preview && (id ? nodeOf(id)?.type : undefined)} />} + typeOf={(id) => (id ? nodeOf(id)?.type : undefined)} + configOf={(id) => (id ? nodeOf(id)?.config : undefined)} />} {/* 修复落到画布上却没存上,预览又已经收起了(换过等级):「重试保存」照样得在 */} {pf.preview?.status !== 'ready' && unsavedFix(pf) && } @@ -656,9 +658,11 @@ function FixControl({ fix, pf }: { fix: PublishFix; pf: PreflightState }) { } /** 修复预览:逐项「节点 · 字段:原值 → 新值」,没采用的写原因,Copilot 的问题原样摆出来 */ -function FixPreview({ pf, labelOf, nameOf, typeOf }: { +function FixPreview({ pf, labelOf, nameOf, typeOf, configOf }: { pf: PreflightState; labelOf: (id?: string | null) => string; nameOf: NodeNameOf typeOf: (id?: string | null) => string | undefined + /** 列表里的某一项(agents[0]、fields[1])写那一项的名字,不写「第 1 项」 */ + configOf?: (id?: string | null) => Record | undefined }) { const dirty = useStudio((s) => s.dirty) const p = pf.preview! @@ -703,7 +707,7 @@ function FixPreview({ pf, labelOf, nameOf, typeOf }: {
    {result.changes.map((c, i) => { const where = c.node_title || labelOf(c.node_id) || T.whole - const field = fixFieldLabel(c.field, typeOf(c.node_id)) + const field = fixFieldLabel(c.field, typeOf(c.node_id), configOf?.(c.node_id)) return (
  • diff --git a/frontend/src/canvas/issues.ts b/frontend/src/canvas/issues.ts index 316d8123..f8ed2b64 100644 --- a/frontend/src/canvas/issues.ts +++ b/frontend/src/canvas/issues.ts @@ -11,7 +11,9 @@ * 那一栏,定位落到工具上(见 toolBindingOf)。 */ import { ApiError } from '../api/client' -import { APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, UPGRADE_TEXT, nodeTypeLabel } from '../lib/terms' +import { + APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, PROVIDER_REF_TEXT, UPGRADE_TEXT, nodeTypeLabel, +} from '../lib/terms' import { NODE_DEFS } from './nodeDefs' import type { FlowNode } from '../store/studio' import type { @@ -244,6 +246,12 @@ function normalizeIssue(raw: any): ValidationIssue | null { } } +/** + * 点名的模型接入不存在或已停用(后端 engine/provider_refs.py)。一处一条问题、一条修复:同一个协作节点上自己的接入和 + * 成员的接入是两处,合并、找修复都要连字段一起认 + */ +export const isProviderRefCode = (code?: string | null): boolean => code === 'provider.missing' || code === 'provider.disabled' + function normalizeFix(raw: any): PublishFix | null { if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || !raw.id) return null const kind = raw.kind === 'choice' || raw.kind === 'assist' ? raw.kind : raw.kind === 'auto' ? 'auto' : null @@ -259,6 +267,7 @@ function normalizeFix(raw: any): PublishFix | null { code: asText(raw.code), kind, node_id: raw.node_id ?? null, + ...(typeof raw.field === 'string' && raw.field ? { field: raw.field } : {}), label: asText(raw.label) || raw.id, preview: raw.preview && typeof raw.preview === 'object' ? raw.preview : null, options, @@ -318,7 +327,10 @@ export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix if (issue.fix) return fixes.find((f) => f.id === issue.fix) if (!issue.code) return undefined const same = fixes.filter((f) => f.code === issue.code) - const exact = same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null)) + // 点名的接入一处一条修复:同一节点上的几处按字段认 + const exact = (isProviderRefCode(issue.code) + ? same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null) && (f.field ?? null) === (issue.field ?? null)) + : undefined) ?? same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null)) if (exact) return exact return issue.node_id == null ? same[0] : same.find((f) => f.node_id == null) } @@ -326,13 +338,16 @@ export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix /** * 同一处问题只列一行:validate 和门禁常对同一处各报一条(同 code、同节点,说法不同), * 列两行就是两组一样的修复控件、「会被拦下 2 处」。合成的那一行取最重的级别、第一条 error 的说法, - * 其余说法收进 others(悬停可见)。没有 code 的认不出是不是同一处,照原样各列一行 + * 其余说法收进 others(悬停可见)。没有 code 的认不出是不是同一处,照原样各列一行。 + * 点名的模型接入连字段一起认:协作节点自己的接入和成员的接入是两处问题、各有各的修复,不合成一行 */ export function mergeIssues(issues: ValidationIssue[]): { issue: ValidationIssue; others: string[] }[] { const out: { issue: ValidationIssue; others: string[] }[] = [] const at = new Map() for (const issue of issues) { - const key = issue.code ? `${issue.code}\u0000${issue.node_id ?? ''}` : null + const key = issue.code + ? `${issue.code}\u0000${issue.node_id ?? ''}${isProviderRefCode(issue.code) ? `\u0000${issue.field ?? ''}` : ''}` + : null const i = key != null ? at.get(key) : undefined if (i == null) { if (key != null) at.set(key, out.length) @@ -382,11 +397,15 @@ const CONTRACT_KEYS: Record = { /** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */ const EXTRA_FIELDS: Record = { workflow_version: '固定版本', upgrade_policy: '上游发布新版本时', defaults: '工作流默认设置', + // 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法 + provider: PROVIDER_REF_TEXT.field, } /** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */ const SUB_KEYS: Record = { ...CONTRACT_KEYS, approval: '审批策略', claims: '未附依据的结论句', on_uncited: '未附依据时', name: '名称', value: '取值', expression: '表达式', + // 点名的接入:成员的、全图默认里的(连同全图默认里结论句裁判的那一份) + provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model, judge: PROVIDER_REF_TEXT.judge, } /** 契约 claims 的 on_uncited:没挂依据的结论句怎么算 */ const ON_UNCITED_LABEL: Record = { diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8ea7b9fb..7cd003a1 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -919,6 +919,26 @@ export const PUBLISH_FIX_TEXT = { whole: '整个工作流', } as const +/** + * 点名的模型接入不存在或已停用(后端 providers/factory.named_provider、engine/provider_refs.py)。以前点名的接入不存在时 + * 运行悄悄换成演示模型、点名停用的照常调用;现在运行照实报错,设置页、画布校验、发布前检查都提前说出来 + */ +export const PROVIDER_REF_TEXT = { + /** 节点配置里 provider 这一项的叫法:多数节点的检查器里没有这一栏,问题面板、修复预览里照样要写得出来 */ + field: '模型接入', + /** 协作成员、修复预览里点号后面那一段:「成员「分析师」的模型接入」「工作流默认设置 · 模型接入」 */ + memberField: '的模型接入', + model: '模型', + judge: '结论句裁判', + /** 设置页那一格下面:存着的接入不存在、已停用时先说状态,再说这一项因此会怎样(各组自己的后果) */ + missing: (name: string) => `模型接入「${name}」不存在`, + disabled: (name: string) => `模型接入「${name}」已停用`, + gateBroken: '门控模型无法调用,所有工具调用将转为人工审批', + judgeBroken: '裁判调用将失败,所有结论句记为未裁判', + // 和核查一组「不存在」那句(VERIFY_SETTING_TEXT.section.providerGone)同一个后果 + verifyBroken: '核查调用将失败', +} as const + /** * 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩: * 只出预览,人点「应用」才走现有的保存存成草稿;不发布 diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 61849480..fa0179de 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, Check, Cpu, Download, KeyRound, Monitor, Moon, Plug, Plus, Settings as SettingsIcon, Sun, X, } from 'lucide-react' import clsx from 'clsx' -import { api } from '../api/client' +import { ApiError, api } from '../api/client' import type { ProviderDraft } from '../api/client' import { modelOptions, providerOfModel, useCatalog, useOnReconnect } from '../store/catalog' import { @@ -13,7 +13,7 @@ import { promptDialog, SectionBar, Skeleton, Spinner, TabPanel, Tabs, toast, useRadioGroup, useTabRoute, } from '../components/ui' import { setThemePref, useTheme } from '../components/CommandPalette' -import { humanizeError } from '../lib/errors' +import { errorMessage } from '../lib/errors' import { formatNumber, shortLabel } from '../lib/format' import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } from '../lib/health' import type { HealthRecord } from '../lib/health' @@ -22,9 +22,13 @@ import type { Provider } from '../types' import { normalizeTheme } from '../lib/theme' import { useLeaveGuard } from '../lib/leave' import { localActor, setLocalActor } from '../lib/actor' -import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms' +import { + AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, PROVIDER_REF_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText, +} from '../lib/terms' import type { ThemePref } from '../lib/theme' -import { VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft } from './VerifySettings' +import { + ProviderOptions, VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft, +} from './VerifySettings' // 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。 // 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去 @@ -622,6 +626,8 @@ function PrefsTab() { const [saving, setSaving] = useState(false) const [savedFlash, setSavedFlash] = useState(0) const [saveError, setSaveError] = useState(null) + // 服务端按项拒收(点名的模型接入不存在、已停用:422 带 field):整句落到那一格下面,焦点移过去 + const [fieldError, setFieldError] = useState<{ field: string; message: string } | null>(null) // 今天裁判花了多少:只读,放在每日上限旁边。老后端没有这个接口就不显示 const loadSpend = () => api.settings.judgeSpend().then(setSpend, () => setSpend(null)) @@ -709,10 +715,12 @@ function PrefsTab() { const edit = (patch: Partial) => { setSaveError(null) + setFieldError(null) setDraft({ ...cur, ...patch }) } const editVerify = (patch: Partial) => { setSaveError(null) + setFieldError(null) setVerifyDraft({ ...verifyCur, ...patch }) } @@ -724,6 +732,7 @@ function PrefsTab() { if (!values || !dirty || invalid) return setSaving(true) setSaveError(null) + setFieldError(null) const actor = cur.actor.trim() if (actor !== savedActor.current) { // 写不进去(隐私模式)时署名只在这一次会话里有效;setLocalActor 会通知导航底部的首字 @@ -775,6 +784,15 @@ function PrefsTab() { // 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少 setDraft({ ...cur, actor }) setSaveError(e) + const at = e instanceof ApiError && e.field ? PROVIDER_FIELD_IDS[e.field] : undefined + if (at) { + setFieldError({ field: (e as ApiError).field!, message: (e as ApiError).message }) + requestAnimationFrame(() => { + const el = document.getElementById(at) + el?.scrollIntoView({ block: 'center' }) + el?.focus() + }) + } } finally { setSaving(false) } @@ -783,16 +801,29 @@ function PrefsTab() { const scopeOptions = [...new Set(['default', ...scopes.map((s) => s.scope), cur.scope])] const collectionNames = collections.map((c) => c.collection) const collectionMissing = !collectionNames.includes(cur.collection) + /** + * 接入那一格下面的报错:服务端按这一项拒收的话照它的整句;否则存着(或选着)的接入不存在、已停用时说清这一项会怎样。 + * 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到 + */ + const providerError = (field: string, name: string, found: Provider | undefined, broken: string): string | undefined => { + if (fieldError?.field === field) return fieldError.message + if (!name || !providers.length) return undefined + if (!found) return `${PROVIDER_REF_TEXT.missing(name)}:${broken}` + return found.enabled ? undefined : `${PROVIDER_REF_TEXT.disabled(name)}:${broken}` + } // 门控模型留空时后端的挑法(resolve_provider):第一个启用的真实接入,没有就第一个启用的 const enabledProviders = providers.filter((p) => p.enabled) const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0] const gateProvider = cur.gateProvider ? providers.find((p) => p.name === cur.gateProvider) : defaultProvider const gateProviderMissing = !!cur.gateProvider && providers.length > 0 && !gateProvider + // 点名的接入不存在、已停用都调不了(停用的以前照常调用,现在运行时照实报错):两种都在这一格下面说 + const gateProviderError = providerError('run.tool_gate_provider', cur.gateProvider, gateProvider, PROVIDER_REF_TEXT.gateBroken) const gateModels = (gateProvider?.models ?? []).map((m) => m.id) // 裁判模型的接入:接入和模型都留空时后端依次用 Copilot 的模型、默认接入(judge_model_spec)。只填了模型时 // 这一级写了就整组用这一级,按模型名找接入(resolve_provider)——不再跟随 Copilot,留空那一项不能还叫「跟随 Copilot」 const judgeProvider = cur.judgeProvider ? providers.find((p) => p.name === cur.judgeProvider) : undefined const judgeProviderMissing = !!cur.judgeProvider && providers.length > 0 && !judgeProvider + const judgeProviderError = providerError('judge.provider', cur.judgeProvider, judgeProvider, PROVIDER_REF_TEXT.judgeBroken) const judgeModelName = cur.judgeModel.trim() const judgeByModel = !cur.judgeProvider && !!judgeModelName // 裁判模型没有单独配置(接入、模型都留空):后端依次用助手的模型、默认接入 @@ -895,22 +926,19 @@ function PrefsTab() {
    {TOOL_GATE_TEXT.label}

    {TOOL_GATE_TEXT.hint}

    - + {(p) => ( )} + hint={gateProvider?.enabled && gateProvider.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}> {(p) => ( <> {JUDGE_SETTING_TEXT.differ}

    )}
    - {judgeByModelHint} @@ -986,14 +1013,12 @@ function PrefsTab() { )} + hint={judgeProvider?.enabled && judgeProvider.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}> {(p) => ( <> )} - {values.verify && } + {values.verify && } )} @@ -1076,11 +1102,14 @@ function PrefsTab() { {saveError != null && ( - 保存失败:{humanizeError(saveError).title} + + {/* 连同原因:服务端的说明常是两三句,只留标题会把「请在……中启用它」这类下一步截掉 */} + 保存失败:{errorMessage(saveError)} + )} + onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null); setFieldError(null) }}>放弃