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) }}>放弃