界面跟上点名接入的新规矩:设置页标出已停用的接入、选不了别的停用接入,接入表变了画布重新校验,问题面板和修复预览不露键名

点名停用的接入以前照常调用,现在运行时照实报错,界面要提前说:
- 设置页门控、裁判、核查三格:存着的接入已停用和「不存在」一样在那一格下面写明后果;下拉里别的停用接入列着但选不了(只留已选着的那个);模型一格不再写「留空则使用」一个调不了的接入的默认模型。服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条连同原因一起写,不再截掉「请在……中启用它」。
- 画布校验现在读接入表:编排页记下校验时的接入表签名(id、名称、启用与否),从设置里改完接入回来、或目录刷新后接入表变了,就重新校验一次,不再停着一句过时的「已停用」。
- 问题面板、修复预览里点名接入的字段写「模型接入」「成员「分析师」的模型接入」「工作流默认设置 · 模型」,不露 provider、model 键名;修复预览给列表项写名字。同一节点上自己的接入和成员的接入是两处问题,合并、找修复都连字段一起认,计数不再少算。

检查:check-publish 加点名接入的修复(一处一行、候选、预览、应用、390px 不溢出)、问题面板字段叫法、在设置里删了接入回到画布重新校验;check-manage 加设置页已停用接入的三格说明、选项、按项拒收;check-evidence 里全图默认的 model 改按「模型」核对(不借裁判叫法的原意不变)。
This commit is contained in:
ThearchyHelios
2026-10-06 15:14:40 +08:00
parent 3c51c40e9d
commit 384af7a5f2
11 changed files with 435 additions and 49 deletions
+8 -4
View File
@@ -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 ? '错误' : '提示'} />
<span className={clsx('min-w-0 flex-1', err ? 'text-fg' : 'text-dim')}>{p.message}</span>
{where && (
<span className="flex shrink-0 items-center gap-0.5 text-faint">
<span className="flex shrink-0 items-center gap-0.5 text-faint" data-problem-field="">
<CornerDownRight size={9} aria-hidden />{where}
</span>
)}
@@ -615,13 +615,17 @@ function UpgradeValue({ value, field, nameOf, className, ...data }: {
)
}
const SUB_LABEL: Record<string, string> = { condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示' }
const SUB_LABEL: Record<string, string> = {
condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示', provider: PROVIDER_REF_TEXT.memberField,
}
/** 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏)。不写的话露出键名 */
const FIELD_FALLBACK: Record<string, string> = { provider: PROVIDER_REF_TEXT.field }
function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record<string, any>): 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」一眼就知道是谁
+11 -7
View File
@@ -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<string, any> } | 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 && <FixPreview pf={pf} labelOf={labelOf} nameOf={nameOf}
typeOf={(id) => (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) && <SaveTrouble pf={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<string, any> | undefined
}) {
const dirty = useStudio((s) => s.dirty)
const p = pf.preview!
@@ -703,7 +707,7 @@ function FixPreview({ pf, labelOf, nameOf, typeOf }: {
<ul className="space-y-1" data-fix-changes="">
{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 (
<li key={i} className="leading-snug" data-fix-change={c.fix_id}>
<span className="[overflow-wrap:anywhere]">
+23 -4
View File
@@ -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<string, number>()
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<string, string> = {
/** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */
const EXTRA_FIELDS: Record<string, string> = {
workflow_version: '固定版本', upgrade_policy: '上游发布新版本时', defaults: '工作流默认设置',
// 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法
provider: PROVIDER_REF_TEXT.field,
}
/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */
const SUB_KEYS: Record<string, string> = {
...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<string, string> = {
+20
View File
@@ -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
/**
* 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩:
* 只出预览,人点「应用」才走现有的保存存成草稿;不发布
+53 -19
View File
@@ -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<unknown>(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<typeof saved>) => {
setSaveError(null)
setFieldError(null)
setDraft({ ...cur, ...patch })
}
const editVerify = (patch: Partial<VerifyDraft>) => {
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() {
<div id="pref-gate-label" className="text-xs font-medium">{TOOL_GATE_TEXT.label}</div>
<p id="pref-gate-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{TOOL_GATE_TEXT.hint}</p>
<div className="grid grid-cols-2 gap-3">
<Field label={TOOL_GATE_TEXT.provider} htmlFor="pref-gate-provider"
error={gateProviderMissing ? `模型接入「${cur.gateProvider}」不存在:门控模型无法调用,所有工具调用将转为人工审批` : undefined}>
<Field label={TOOL_GATE_TEXT.provider} htmlFor="pref-gate-provider" error={gateProviderError}>
{(p) => (
<select {...p} className="field" value={cur.gateProvider} onChange={(e) => pickGateProvider(e.target.value)}>
<option value="">
{TOOL_GATE_TEXT.providerDefault}{defaultProvider ? `(当前为 ${defaultProvider.name})` : ''}
</option>
{gateProviderMissing && <option value={cur.gateProvider}>{cur.gateProvider}(不存在)</option>}
{providers.map((pv) => (
<option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : '(已停用)'}</option>
))}
<ProviderOptions providers={providers} current={cur.gateProvider} />
</select>
)}
</Field>
<Field label={TOOL_GATE_TEXT.model} htmlFor="pref-gate-model"
hint={gateProvider?.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}>
hint={gateProvider?.enabled && gateProvider.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.gateModel} list="pref-gate-models"
@@ -976,8 +1004,7 @@ function PrefsTab() {
<p id="pref-judge-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{JUDGE_SETTING_TEXT.differ}</p>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={JUDGE_SETTING_TEXT.provider} htmlFor="pref-judge-provider"
error={judgeProviderMissing ? `模型接入「${cur.judgeProvider}」不存在:裁判调用将失败,所有结论句记为未裁判` : undefined}
<Field label={JUDGE_SETTING_TEXT.provider} htmlFor="pref-judge-provider" error={judgeProviderError}
hint={judgeByModelHint ? (
<span data-judge-by-model={judgeOwner ? (judgeOwner.enabled ? 'found' : 'disabled') : 'default'}
style={judgeOwner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{judgeByModelHint}</span>
@@ -986,14 +1013,12 @@ function PrefsTab() {
<select {...p} className="field" value={cur.judgeProvider} onChange={(e) => pickJudgeProvider(e.target.value)}>
<option value="">{judgeByModel ? JUDGE_SETTING_TEXT.providerFromModel : JUDGE_SETTING_TEXT.providerDefault}</option>
{judgeProviderMissing && <option value={cur.judgeProvider}>{cur.judgeProvider}(不存在)</option>}
{providers.map((pv) => (
<option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : '(已停用)'}</option>
))}
<ProviderOptions providers={providers} current={cur.judgeProvider} />
</select>
)}
</Field>
<Field label={JUDGE_SETTING_TEXT.model} htmlFor="pref-judge-model"
hint={judgeProvider?.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}>
hint={judgeProvider?.enabled && judgeProvider.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.judgeModel} list="pref-judge-models"
@@ -1048,7 +1073,8 @@ function PrefsTab() {
</section>
)}
{values.verify && <VerifySection cur={verifyCur} errors={verifyErrs} onEdit={editVerify} refresh={verifySaves} />}
{values.verify && <VerifySection cur={verifyCur} onEdit={editVerify} refresh={verifySaves}
errors={fieldError?.field === 'verify.provider' ? { ...verifyErrs, provider: fieldError.message } : verifyErrs} />}
</>
)}
@@ -1076,11 +1102,14 @@ function PrefsTab() {
</span>
</span>
{saveError != null && (
<span className="text-2xs text-[var(--err)]" role="alert">保存失败:{humanizeError(saveError).title}</span>
<span className="text-2xs text-[var(--err)]" role="alert" data-prefs-save-error="">
{/* 连同原因:服务端的说明常是两三句,只留标题会把「请在……中启用它」这类下一步截掉 */}
保存失败:{errorMessage(saveError)}
</span>
)}
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null) }}>放弃</button>
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null); setFieldError(null) }}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
@@ -1100,6 +1129,11 @@ function PrefsTab() {
)
}
/** 服务端按项拒收时(422 带 field)那一项在页面上是哪个控件:点名的模型接入的三格 */
const PROVIDER_FIELD_IDS: Record<string, string> = {
'run.tool_gate_provider': 'pref-gate-provider', 'judge.provider': 'pref-judge-provider', 'verify.provider': 'pref-verify-provider',
}
/** 裁判那一组在草稿里的键:它们改了才写 judge 一组 */
const JUDGE_DRAFT = new Set<string>(['judgeProvider', 'judgeModel', 'jClaims', 'jCost', 'jTimeout', 'jClick', 'jDaily'])
+20
View File
@@ -78,6 +78,15 @@ function matches(e: KeyboardEvent, combo: string): boolean {
/** 改图的快捷键。正式运行期间按了要说为什么没反应,不能安静地什么都不做 */
const EDIT_SHORTCUTS = new Set<StudioShortcutId>(['undo', 'redo', 'paste', 'duplicate', 'layout'])
/**
* 接入表的签名:只看校验用得上的(id、名称、启用与否)。画布校验会查节点点名的接入是不是不存在、已停用(后端
* engine/provider_refs.py),接入在设置里启用、停用、改名、删除之后,眼前的问题清单就过时了。记在模块里而不是
* 组件里:去设置页改完再回来,编排页是重新挂上的,要和离开前校验时的那份比
*/
let checkedProviders: string | null = null
const providerSig = (providers: { id: string; name: string; enabled: boolean }[]) =>
providers.map((p) => `${p.id}\u0000${p.name}\u0000${p.enabled ? 1 : 0}`).join('\u0001')
export function StudioPage() {
const { workflowId } = useParams()
const [params, setParams] = useSearchParams()
@@ -87,6 +96,8 @@ export function StudioPage() {
const workflows = useCatalog((s) => s.workflows)
const refresh = useCatalog((s) => s.refresh)
const catalogLoaded = useCatalog((s) => s.loaded)
// 选签名(字符串)而不是整张表:目录每刷新一次表都是新数组,内容没变就不该重新校验
const providers = useCatalog((s) => providerSig(s.providers))
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
// 只订阅数量,不订阅整个 nodes——后者每拖一下都会让整页重渲染
@@ -241,6 +252,15 @@ export function StudioPage() {
resolve(workflowId)
}, [workflowId, workflow, workflows, catalogLoaded, dirty, load, navigate, resolve, location.state])
// 接入表变了(在设置里启用、停用、改名、删除了接入):画布上的问题清单可能已经过时,重新校验一次。
// 第一次拿到接入表时只记下来——打开画布本身就会校验,后端校验时读的是库里的接入表,不靠这里
useEffect(() => {
if (!catalogLoaded) return
const changed = checkedProviders !== null && checkedProviders !== providers
checkedProviders = providers
if (changed && useStudio.getState().nodes.length) void useStudio.getState().validate()
}, [providers, catalogLoaded])
// 断开期间没取到的那张,连上之后再取一次。有未保存的改动就不动它
useOnReconnect(() => {
if (loadError && loadError.id === workflowId && !useStudio.getState().dirty) resolve(loadError.id)
+26 -6
View File
@@ -4,8 +4,8 @@ import { AlertTriangle } from 'lucide-react'
import { get } from '../api/http'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { Field, SectionBar } from '../components/ui'
import { VERIFY_SETTING_TEXT } from '../lib/terms'
import type { VerifySpend } from '../types'
import { PROVIDER_REF_TEXT, VERIFY_SETTING_TEXT } from '../lib/terms'
import type { Provider, VerifySpend } from '../types'
/*
* 设置页「偏好设置」里「核查」一节:双盲复算、反方审稿、审批质询共用的核查模型、各自的开关和上限、每日金额上限
@@ -163,6 +163,10 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0]
const provider = cur.provider ? providers.find((p) => p.name === cur.provider) : undefined
const providerMissing = !!cur.provider && providers.length > 0 && !provider
// 点名停用的接入以前照常调用,现在核查时照实报「已停用」、记缺口:和「不存在」一样在这一格下面说。服务端按这一项
// 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句
const providerError = errors.provider ?? (providerMissing ? S.providerGone(cur.provider)
: provider && !provider.enabled ? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}` : undefined)
const modelName = cur.model.trim()
const byModel = !cur.provider && !!modelName
const notSet = !cur.provider && !modelName
@@ -221,8 +225,7 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
<p id="pref-verify-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{S.differ}</p>
)}
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<Field label={T.provider} htmlFor={fieldId('provider')}
error={providerMissing ? S.providerGone(cur.provider) : undefined}
<Field label={T.provider} htmlFor={fieldId('provider')} error={providerError}
hint={byModelHint ? (
<span data-verify-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{byModelHint}</span>
@@ -231,11 +234,12 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
<select {...p} className="field" value={cur.provider} onChange={(e) => pickProvider(e.target.value)}>
<option value="">{byModel ? S.providerFromModel : S.providerDefault}</option>
{providerMissing && <option value={cur.provider}>{S.providerGoneOption(cur.provider)}</option>}
{providers.map((pv) => <option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : S.providerOff}</option>)}
<ProviderOptions providers={providers} current={cur.provider} />
</select>
)}
</Field>
<Field label={T.model} htmlFor={fieldId('model')} hint={provider?.default_model ? S.modelOf(provider.default_model) : undefined}>
<Field label={T.model} htmlFor={fieldId('model')}
hint={provider?.enabled && provider.default_model ? S.modelOf(provider.default_model) : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.model} list="pref-verify-models" spellCheck={false}
@@ -290,6 +294,22 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
)
}
/**
* 接入下拉里的各家。停用的照列(带「已停用」),但选不了:选了运行时也调不了,保存时服务端同样会拒收。
* 已经选着的那一个(存着的值后来被停用了)照常显示,下面那一格写明它用不了
*/
export function ProviderOptions({ providers, current }: { providers: Provider[]; current: string }) {
return (
<>
{providers.map((pv) => (
<option key={pv.id} value={pv.name} disabled={!pv.enabled && pv.name !== current}>
{pv.name}{pv.enabled ? '' : S.providerOff}
</option>
))}
</>
)
}
/** 三项各一组:标题是服务端叫法表里那一组的叫法,下面一句说它做什么 */
function PartGroup({ part, children }: { part: Part; children: ReactNode }) {
return (
+2
View File
@@ -1384,6 +1384,8 @@ export interface PublishFix {
code: string
kind: 'auto' | 'choice' | 'assist'
node_id?: string | null
/** 这条修复落在哪个字段:点名的模型接入一处一条(同一节点上的几处按它区分) */
field?: string | null
label: string
preview?: { field?: string | null; before?: unknown; after?: unknown } | null
/** handoff:选了这一项就是「交给 Copilot」(G4 的「它在做计算」)。只认这个标记,不认值——