mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
界面跟上点名接入的新规矩:设置页标出已停用的接入、选不了别的停用接入,接入表变了画布重新校验,问题面板和修复预览不露键名
点名停用的接入以前照常调用,现在运行时照实报错,界面要提前说: - 设置页门控、裁判、核查三格:存着的接入已停用和「不存在」一样在那一格下面写明后果;下拉里别的停用接入列着但选不了(只留已选着的那个);模型一格不再写「留空则使用」一个调不了的接入的默认模型。服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条连同原因一起写,不再截掉「请在……中启用它」。 - 画布校验现在读接入表:编排页记下校验时的接入表签名(id、名称、启用与否),从设置里改完接入回来、或目录刷新后接入表变了,就重新校验一次,不再停着一句过时的「已停用」。 - 问题面板、修复预览里点名接入的字段写「模型接入」「成员「分析师」的模型接入」「工作流默认设置 · 模型」,不露 provider、model 键名;修复预览给列表项写名字。同一节点上自己的接入和成员的接入是两处问题,合并、找修复都连字段一起认,计数不再少算。 检查:check-publish 加点名接入的修复(一处一行、候选、预览、应用、390px 不溢出)、问题面板字段叫法、在设置里删了接入回到画布重新校验;check-manage 加设置页已停用接入的三格说明、选项、按项拒收;check-evidence 里全图默认的 model 改按「模型」核对(不借裁判叫法的原意不变)。
This commit is contained in:
@@ -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」一眼就知道是谁
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩:
|
||||
* 只出预览,人点「应用」才走现有的保存存成草稿;不发布
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 的「它在做计算」)。只认这个标记,不认值——
|
||||
|
||||
@@ -1447,7 +1447,8 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R
|
||||
check('……null 写「不限」(没写才是「(空)」)、金额带单位', previews[1] === '不限' && previews[2] === '0.05 美元', JSON.stringify(previews.slice(1, 3)))
|
||||
check('……补预算按整份 judge 记时一键一行', previews[3] === '裁判模型:x;金额上限(美元):不限', previews[3])
|
||||
check('……证据相矛盾时写选项文字', previews[4] === '不予出具', previews[4])
|
||||
check('……别处的 model 不借用裁判的叫法', previews[5] === '工作流默认设置 · model', previews[5])
|
||||
// 不借「裁判模型」,写通用的「模型」(以前露键名 model;点名接入的修复会清掉全图默认里的模型,预览里要写得出叫法)
|
||||
check('……别处的 model 不借用裁判的叫法', previews[5] === '工作流默认设置 · 模型', previews[5])
|
||||
if (SHOTS) {
|
||||
await jf.scrollIntoViewIfNeeded()
|
||||
for (const theme of ['dark', 'light']) {
|
||||
|
||||
@@ -589,6 +589,70 @@ await section('设置 · 门控模型', async () => {
|
||||
await old.close()
|
||||
})
|
||||
|
||||
await section('设置 · 点名的接入已停用或不存在', async () => {
|
||||
// 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了;
|
||||
// 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
|
||||
const PROVIDERS = [
|
||||
{ id: 'check-ref-p1', name: 'demo-main', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'big-model',
|
||||
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
|
||||
{ id: 'check-ref-p2', name: 'demo-off', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'off-model',
|
||||
models: [{ id: 'off-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
|
||||
{ id: 'check-ref-p3', name: 'demo-spare', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'spare-1',
|
||||
models: [{ id: 'spare-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
|
||||
]
|
||||
let stored = await get('/settings')
|
||||
stored = {
|
||||
...stored,
|
||||
run: { ...(stored.run ?? {}), tool_gate_provider: 'demo-off', tool_gate_model: null },
|
||||
judge: { ...(stored.judge ?? {}), provider: 'demo-off', model: null },
|
||||
verify: { ...(stored.verify ?? {}), provider: 'demo-off', model: null },
|
||||
}
|
||||
const REFUSED = '「门控模型 · 接入」填写的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入;留空表示使用默认接入的默认模型'
|
||||
let refuse = true
|
||||
const { page, sent, errors, close } = await open('/settings/prefs', {
|
||||
handlers: [
|
||||
[/^GET \/settings$/, (route) => json(stored)(route)],
|
||||
[/^PUT \/settings$/, (route, { body }) => {
|
||||
if (refuse) {
|
||||
refuse = false
|
||||
return route.fulfill({ status: 422, contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: REFUSED, code: 'settings_provider_invalid', field: 'run.tool_gate_provider' }) })
|
||||
}
|
||||
stored = { ...stored, ...body.values }
|
||||
return json(stored)(route)
|
||||
}],
|
||||
[/^GET \/providers$/, json(PROVIDERS)],
|
||||
],
|
||||
})
|
||||
const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '')
|
||||
check('门控模型:存着的接入已停用,那一格下面说清', (await said('pref-gate-provider')).includes('模型接入「demo-off」已停用:门控模型无法调用,所有工具调用将转为人工审批'),
|
||||
await said('pref-gate-provider'))
|
||||
check('证据裁判:同样说清', (await said('pref-judge-provider')).includes('模型接入「demo-off」已停用:裁判调用将失败'), await said('pref-judge-provider'))
|
||||
check('核查模型:同样说清', (await said('pref-verify-provider')).includes('模型接入「demo-off」已停用:核查调用将失败'), await said('pref-verify-provider'))
|
||||
check('……模型那一格不再写「留空则使用 off-model」', !(await page.locator('[data-gate-model]').innerText()).includes('off-model'))
|
||||
const gate = page.locator('#pref-gate-provider')
|
||||
const off = (name) => gate.locator(`option[value="${name}"]`).evaluate((o) => o.disabled)
|
||||
check('下拉:选着的停用接入照常显示,别的停用接入列着但选不了', !(await off('demo-off')) && await off('demo-spare') && !(await off('demo-main')))
|
||||
await shot(page, 'prefs-provider-disabled')
|
||||
await gate.selectOption('demo-main')
|
||||
check('换成启用的接入:那一格的说明消失,刚才那个停用接入也选不回去了', !(await said('pref-gate-provider')) && await off('demo-off'))
|
||||
await page.getByRole('button', { name: /保存设置/ }).click()
|
||||
await page.waitForTimeout(500)
|
||||
check('服务端按项拒收:整句落到那一格下面', (await said('pref-gate-provider')) === REFUSED, await said('pref-gate-provider'))
|
||||
check('……焦点移到那一格', await gate.evaluate((el) => el === document.activeElement))
|
||||
const bar = await page.locator('[data-prefs-save-error]').innerText().catch(() => '')
|
||||
check('……保存条写到原因,不截在第一句', bar.startsWith('保存失败:') && bar.includes('请在「设置 → 模型接入」中启用它'), bar)
|
||||
await gate.selectOption('')
|
||||
check('再改这一项:服务端那句说明收起', !(await said('pref-gate-provider')))
|
||||
await page.getByRole('button', { name: /保存设置/ }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values?.run ?? {}
|
||||
check('……再保存:照常写进去', put.tool_gate_provider === null && (await page.locator('[data-prefs-save-error]').count()) === 0,
|
||||
`provider=${JSON.stringify(put.tool_gate_provider)}`)
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('设置 · 智能体护栏', async () => {
|
||||
// 默认步数、token 预算、金额预算(后端 engine/guards.py)。预算留空 = 不限,存 null
|
||||
let stored = await get('/settings')
|
||||
|
||||
+206
-8
@@ -2,6 +2,8 @@
|
||||
// 先预览再「应用并重新检查」、问题面板里的「发布前检查」、老后端(两个接口 404)照旧。
|
||||
// 另有一键升级为可追溯结构(可点击证据五期):问题面板的快速修复、升级预览、应用走现有的保存、
|
||||
// 从记录页横幅跳过来(?upgrade=1)。升级接口和校验给的那条建议同样在浏览器里伪造。
|
||||
// 还有点名的模型接入不存在或已停用(K1):一处一行、各有一组要人选的修复,问题面板不露键名,
|
||||
// 在设置里删了接入回到画布要重新校验。接入表、校验同样伪造。
|
||||
//
|
||||
// 不写库:工作流、保存、发布、publish-check、autofix 全在浏览器里用 page.route 伪造。
|
||||
// 伪造的检查接口按请求里的图现算(下面的 lint),所以修完、存完、再查,结论会真的变;
|
||||
@@ -162,6 +164,35 @@ const SQLWF = {
|
||||
edges: chain('in', 'sum', 'done'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 点名的模型接入(K1):「提问」点的接入不存在,协作节点自己的接入已停用、成员「分析师」点的接入不存在。
|
||||
* 接入表在 open() 里伪造(state.providers),校验和门禁都按它现算
|
||||
*/
|
||||
const PROV = {
|
||||
nodes: [
|
||||
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
|
||||
node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-old', model: 'old-model' }),
|
||||
node('team', 'supervisor', 480, '协作', { goal: '复核', provider: 'demo-off', agents: [{ name: '分析师', provider: 'demo-old', tools: [] }] }),
|
||||
node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }),
|
||||
],
|
||||
edges: chain('in', 'ask', 'team', 'done'),
|
||||
}
|
||||
/** 点名的接入现在好好的(启用着):在设置里删掉它之后回到画布,校验要重新查出来 */
|
||||
const PROV_OK = {
|
||||
nodes: [
|
||||
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
|
||||
node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-main' }),
|
||||
node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ nodes.ask.text }}' }] }),
|
||||
],
|
||||
edges: chain('in', 'ask', 'done'),
|
||||
}
|
||||
const PROVIDERS = [
|
||||
{ id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model',
|
||||
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
|
||||
{ id: 'pv-off', name: 'demo-off', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'tiny-1',
|
||||
models: [{ id: 'tiny-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
|
||||
]
|
||||
|
||||
const wf = (id, name, graph, extra = {}) => ({
|
||||
id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false,
|
||||
status: 'draft', published_version: null, published_by: null, run_count: 0,
|
||||
@@ -179,6 +210,8 @@ const FAKES = {
|
||||
'pf-legacy': wf('pf-legacy', '__upgrade_check_legacy__', LEGACY, { status: 'published', published_version: 3 }),
|
||||
'pf-ask': wf('pf-ask', '__upgrade_check_ask__', ASK),
|
||||
'pf-sql': wf('pf-sql', '__publish_check_sql__', SQLWF),
|
||||
'pf-prov': wf('pf-prov', '__publish_check_provider__', PROV),
|
||||
'pf-provok': wf('pf-provok', '__publish_check_provider_ok__', PROV_OK),
|
||||
}
|
||||
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x))
|
||||
@@ -186,15 +219,54 @@ const who = (n) => `「${n.data.label}」`
|
||||
/** 指标候选的写法照后端 autofix._metric_options:「名称(id)」,提示写来自哪个口径卡 */
|
||||
const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.name}(${x.id})`, hint: `口径卡「${m.data.label}」` }))
|
||||
|
||||
/**
|
||||
* 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix._plan_provider:一处一条问题(字段 provider、
|
||||
* agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复
|
||||
*/
|
||||
function providerLint(graph, providers, level) {
|
||||
const issues = []
|
||||
const fixes = []
|
||||
const refs = []
|
||||
const kind = { llm: '模型调用', supervisor: '多 Agent 协作' }
|
||||
for (const n of graph.nodes) {
|
||||
const c = n.data.config ?? {}
|
||||
if (c.provider) refs.push({ n, field: 'provider', name: c.provider, who: `${who(n)}(${kind[n.type]})` })
|
||||
;(c.agents ?? []).forEach((a, i) => {
|
||||
if (a.provider) refs.push({ n, field: `agents[${i}].provider`, name: a.provider, who: `${who(n)}(${kind[n.type]})的成员「${a.name}」` })
|
||||
})
|
||||
}
|
||||
for (const r of refs) {
|
||||
const p = providers.find((x) => x.name === r.name)
|
||||
if (p?.enabled) continue
|
||||
const code = p ? 'provider.disabled' : 'provider.missing'
|
||||
const id = `${code}:${r.n.id}:${r.field}`
|
||||
const gate = level === 'error'
|
||||
issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate ? id : null,
|
||||
message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'},`
|
||||
+ '或在「发布前检查」中改用其他接入' })
|
||||
if (gate) {
|
||||
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`,
|
||||
options: [{ value: '', label: '不指定接入', hint: '去掉这一处指定的接入,按上一级的设置或模型名找接入' },
|
||||
...providers.filter((x) => x.enabled).map((x) => ({ value: x.name, label: x.name, hint: null }))] })
|
||||
}
|
||||
}
|
||||
return { issues, fixes }
|
||||
}
|
||||
|
||||
/**
|
||||
* 伪造的门禁:按请求里的图现算,形状照 PF-SPEC(issues 带 code 和 fix,fixes 列修法)。
|
||||
* 已发布档只给提示,受管档有错。修法的 id 是 code:节点(图级的是 code:graph)。
|
||||
* followsNever:当作这张图的工作流默认设置是「全部无需审批」(画布上不带 defaults,按工作流认)
|
||||
*/
|
||||
function lint(graph, level, { followsNever = false } = {}) {
|
||||
function lint(graph, level, { followsNever = false, providers = null } = {}) {
|
||||
const hard = level === 'governed'
|
||||
const issues = []
|
||||
const fixes = []
|
||||
if (providers) {
|
||||
const found = providerLint(graph, providers, 'error')
|
||||
issues.push(...found.issues)
|
||||
fixes.push(...found.fixes)
|
||||
}
|
||||
const add = (n, code, message, fix, isError = true) => {
|
||||
const id = `${code}:${n.id}`
|
||||
issues.push({ level: isError && hard ? 'error' : 'warning', node_id: n.id, code, message, fix: fix ? id : null, field: null })
|
||||
@@ -294,9 +366,9 @@ function lint(graph, level, { followsNever = false } = {}) {
|
||||
}
|
||||
|
||||
/** 伪造的 autofix:在副本上照选中的修法改,逐项记 changes;钉不上的(pf-lib 没发过版时)进 rejected */
|
||||
function autofix(body, { unpublished = false, followsNever = false } = {}) {
|
||||
function autofix(body, { unpublished = false, followsNever = false, providers = null } = {}) {
|
||||
const graph = clone(body.graph)
|
||||
const before = lint(graph, body.level, { followsNever })
|
||||
const before = lint(graph, body.level, { followsNever, providers })
|
||||
const changes = []
|
||||
const rejected = []
|
||||
const applied = []
|
||||
@@ -338,6 +410,15 @@ function autofix(body, { unpublished = false, followsNever = false } = {}) {
|
||||
const v = body.choices?.[id]
|
||||
const to = `{{ nodes.${v}.text }}`
|
||||
change('fields[0].value', c.fields[0].value, to); c.fields[0].value = to
|
||||
} else if (fix.code.startsWith('provider.')) {
|
||||
// 点名的接入:改用选的那个,选「不指定接入」就去掉这一项
|
||||
const v = body.choices?.[id]
|
||||
if (v === undefined) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue }
|
||||
const m = /^agents\[(\d+)\]\.provider$/.exec(fix.field)
|
||||
const holder = m ? c.agents[Number(m[1])] : c
|
||||
change(fix.field, holder.provider ?? null, v || null)
|
||||
if (v) holder.provider = v
|
||||
else delete holder.provider
|
||||
} else if (fix.code === 'contract.required_missing') {
|
||||
const v = body.choices?.[id]
|
||||
if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue }
|
||||
@@ -356,7 +437,7 @@ function autofix(body, { unpublished = false, followsNever = false } = {}) {
|
||||
assist = { ok: true, summary: '把复核团队的轮数收紧到 2 轮;换成固定编排要删节点,按规矩没动它',
|
||||
questions: ['复核团队要换成哪一个固定编排的 Agent?它现在只有一个成员 checker,保留它的职责吗?'] }
|
||||
}
|
||||
const remaining = lint(graph, body.level, { followsNever })
|
||||
const remaining = lint(graph, body.level, { followsNever, providers })
|
||||
return { graph, changes, applied, rejected, handoff, remaining: remaining.issues, assist, ok: remaining.ok, ops: [] }
|
||||
}
|
||||
|
||||
@@ -468,7 +549,10 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
|
||||
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
// validate:'ok' | 'fail'(校验接口 500,后端断开时和保存一起失败)。检查中途改它,下一次校验就按新的回
|
||||
const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade,
|
||||
validate: 'ok' }
|
||||
validate: 'ok', validates: 0, deleted: [] }
|
||||
// 点名接入的那两张图:接入表伪造,校验、门禁、修复都按它现算
|
||||
const providers = id === 'pf-prov' || id === 'pf-provok' ? clone(PROVIDERS) : null
|
||||
state.providers = providers
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
@@ -483,6 +567,23 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
|
||||
? json(route, { detail: '校验服务暂时无法连接' }, 500)
|
||||
: json(route, { ok: true, issues: advice ? upgradeAdvice(route.request().postDataJSON()?.graph ?? { nodes: [], edges: [] }) : [] }))
|
||||
}
|
||||
if (providers) {
|
||||
await page.route(/\/api\/workflows\/validate$/, (route) => {
|
||||
state.validates += 1
|
||||
const graph = route.request().postDataJSON()?.graph ?? { nodes: [], edges: [] }
|
||||
return json(route, { ok: true, issues: providerLint(graph, state.providers, 'warning').issues })
|
||||
})
|
||||
await page.route(/\/api\/providers(\?.*)?$/, (route) => (route.request().method() === 'GET'
|
||||
? json(route, state.providers) : route.fallback()))
|
||||
// 设置里删接入:只改伪造的接入表
|
||||
await page.route(/\/api\/providers\/pv-[a-z]+$/, (route) => {
|
||||
if (route.request().method() !== 'DELETE') return route.fallback()
|
||||
const pid = new URL(route.request().url()).pathname.split('/').pop()
|
||||
state.deleted.push(pid)
|
||||
state.providers = state.providers.filter((x) => x.id !== pid)
|
||||
return json(route, { ok: true })
|
||||
})
|
||||
}
|
||||
await page.route(/\/api\/copilot\/upgrade-evidence$/, async (route) => {
|
||||
const body = route.request().postDataJSON()
|
||||
state.upgrades.push(body)
|
||||
@@ -520,19 +621,19 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
|
||||
const last = state.checks.at(-1)
|
||||
if (!(last && last.key === key && Date.now() - last.at < 300)) state.checks.push({ ...body, key, at: Date.now() })
|
||||
if (state.backend === 'old') return json(route, { detail: 'Not Found' }, 404)
|
||||
return json(route, lint(body.graph ?? state.graph, body.level, { followsNever }))
|
||||
return json(route, lint(body.graph ?? state.graph, body.level, { followsNever, providers: state.providers }))
|
||||
}
|
||||
if (method === 'POST' && sub === 'autofix') {
|
||||
const body = req.postDataJSON()
|
||||
state.autofixes.push(body)
|
||||
if (state.backend !== 'new') return json(route, { detail: 'Not Found' }, 404)
|
||||
if (fixDelay) await new Promise((r) => setTimeout(r, fixDelay))
|
||||
return json(route, autofix(body, { unpublished, followsNever }))
|
||||
return json(route, autofix(body, { unpublished, followsNever, providers: state.providers }))
|
||||
}
|
||||
if (method === 'POST' && sub === 'publish') {
|
||||
const body = req.postDataJSON()
|
||||
state.publishes.push(body)
|
||||
const r = lint(state.graph, body.level, { followsNever })
|
||||
const r = lint(state.graph, body.level, { followsNever, providers: state.providers })
|
||||
// 真后端的 /publish 回包:问题带 code、不带 fix(fix 只有 publish-check 给);老后端连 code 也没有
|
||||
const issues = state.backend === 'old' ? r.issues.map(({ code: _c, fix: _f, ...rest }) => rest)
|
||||
: r.issues.map(({ fix: _f, ...rest }) => rest)
|
||||
@@ -1513,6 +1614,103 @@ await section('升级:?upgrade=1 直达:打开问题面板、开始预览,
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('点名的模型接入:一处一行、各有一组要人选的修复,预览写「模型接入」', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-prov' })
|
||||
await openDialog(page)
|
||||
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0)
|
||||
const rows = dialog(page).locator('[data-preflight-issue^="provider."]')
|
||||
check('三处点名的接入各一行:协作节点自己的接入和成员的接入不合成一行', await rows.count() === 3, `${await rows.count()} 行`)
|
||||
const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
|
||||
check('……结论按三处算:3 处问题将被门禁拦截', summary.includes('3 处问题将被门禁拦截'), summary)
|
||||
const member = dialog(page).locator('[data-fix-choice="provider.missing:team:agents[0].provider"]')
|
||||
const memberText = await member.innerText().catch(() => '')
|
||||
check('每一处一组选项:不指定接入、启用的接入;停用的不在候选里', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 3
|
||||
&& await member.locator('input[type="radio"]').count() === 2 && memberText.includes('不指定接入') && memberText.includes('demo-main')
|
||||
&& !memberText.includes('demo-off'), memberText)
|
||||
check('……标题说清是哪一处', memberText.includes('选择「协作」(多 Agent 协作)的成员「分析师」改用的模型接入'), memberText)
|
||||
check('……不替人选', await member.locator('input:checked').count() === 0)
|
||||
await member.locator('label', { hasText: 'demo-main' }).click()
|
||||
await member.locator('[data-fix-action="choice"]').click()
|
||||
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
|
||||
check('预览请求只修这一处,带着选的接入', JSON.stringify(state.autofixes.at(-1)?.apply) === '["provider.missing:team:agents[0].provider"]'
|
||||
&& state.autofixes.at(-1)?.choices?.['provider.missing:team:agents[0].provider'] === 'demo-main')
|
||||
const line = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '')
|
||||
check('预览写「「协作」 · 团队成员 · 「分析师」 · 模型接入:demo-old → demo-main」,不露键名',
|
||||
/「协作」\s*·\s*团队成员 · 「分析师」 · 模型接入:\s*demo-old\s*demo-main/.test(line) && !/provider/.test(line), line)
|
||||
await dialog(page).locator('[data-fix-apply]').click()
|
||||
await waitFor(page, async () => state.patches.length === 1 && state.checks.length >= 2)
|
||||
const team = cfgOf(state.patches[0]?.graph, 'team')
|
||||
check('应用:成员改用 demo-main,节点自己的接入不动', team?.agents?.[0]?.provider === 'demo-main' && team?.provider === 'demo-off',
|
||||
JSON.stringify(team ?? {}).slice(0, 120))
|
||||
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue^="provider."]').count()) === 2)
|
||||
check('……重查之后剩两处', await dialog(page).locator('[data-preflight-issue^="provider."]').count() === 2)
|
||||
await shoot(page, 'publish-provider-fix', dialog(page))
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('点名的模型接入:问题面板写「模型接入」,不露键名', async () => {
|
||||
const { page, errors } = await open({ id: 'pf-prov' })
|
||||
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
|
||||
await page.keyboard.press('Alt+KeyP')
|
||||
await page.locator('#dock-problems').waitFor()
|
||||
await waitFor(page, async () => (await page.locator('#dock-problems [data-problem-field]').count()) >= 3)
|
||||
const fields = await page.locator('#dock-problems [data-problem-field]').allInnerTexts()
|
||||
check('字段写「模型接入」「成员「分析师」的模型接入」', fields.filter((t) => t.trim() === '模型接入').length === 2
|
||||
&& fields.some((t) => t.trim() === '成员「分析师」的模型接入'), fields.join(' | '))
|
||||
check('……没有一处露出键名 provider', !(await page.locator('#dock-problems').innerText()).includes('provider'))
|
||||
await shoot(page, 'problems-provider', page.locator('section[aria-label="问题"]'))
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('点名的模型接入:在设置里删了接入,回到画布重新校验', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-provok' })
|
||||
const providerIssues = () => page.evaluate(() => window.__studio.getState().issues
|
||||
.filter((i) => String(i.code ?? '').startsWith('provider.')).map((i) => i.message))
|
||||
await waitFor(page, async () => state.validates > 0)
|
||||
check('起初点名的接入启用着:没有接入问题', (await providerIssues()).length === 0)
|
||||
const before = state.validates
|
||||
await page.locator('nav[aria-label="主导航"] a[href="/settings"]').click()
|
||||
await page.locator('[data-provider="demo-main"]').waitFor()
|
||||
await page.getByRole('button', { name: '删除模型接入 demo-main' }).click()
|
||||
await page.getByRole('button', { name: '删除接入' }).click()
|
||||
await waitFor(page, async () => state.deleted.length === 1)
|
||||
await page.waitForTimeout(400)
|
||||
await page.evaluate(() => history.back())
|
||||
await waitFor(page, async () => (await providerIssues()).length > 0, 6000)
|
||||
const said = await providerIssues()
|
||||
check('回到画布:重新校验,查出「demo-main」不存在', state.validates > before && said.some((m) => m.includes('「demo-main」不存在')),
|
||||
`${before} → ${state.validates} · ${said.join(' | ').slice(0, 100)}`)
|
||||
// 接入表没再变:来回切页不重复校验
|
||||
const settled = state.validates
|
||||
await page.locator('nav[aria-label="主导航"] a[href="/settings"]').click()
|
||||
await page.locator('[data-provider="demo-off"]').waitFor()
|
||||
await page.evaluate(() => history.back())
|
||||
await page.waitForFunction(() => window.location.pathname.startsWith('/studio/'))
|
||||
await page.waitForTimeout(1200)
|
||||
check('接入表没变:切回来不重复校验', state.validates === settled, `${settled} → ${state.validates}`)
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('点名的模型接入:390px 下修复选项不伸出弹窗', async () => {
|
||||
const { page } = await open({ id: 'pf-prov', width: 390, height: 820 })
|
||||
await page.getByRole('button', { name: '更多操作' }).click()
|
||||
await page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click()
|
||||
await dialog(page).waitFor()
|
||||
await dialog(page).locator('[data-fix-choice^="provider."]').first().waitFor()
|
||||
const m = await page.evaluate(() => {
|
||||
const d = document.querySelector('[role="dialog"]')
|
||||
const wide = [...d.querySelectorAll('*')].filter((el) => {
|
||||
const b = el.getBoundingClientRect()
|
||||
return b.width > 0 && (b.right > window.innerWidth + 0.5 || b.left < -0.5)
|
||||
}).map((el) => el.tagName + (el.className ? `.${String(el.className).split(' ')[0]}` : ''))
|
||||
const scrollers = [...d.querySelectorAll('*')].filter((el) => el.scrollWidth > el.clientWidth + 1
|
||||
&& !['visible', 'hidden', 'clip'].includes(getComputedStyle(el).overflowX)).length
|
||||
return { wide: wide.slice(0, 4), scrollers, own: d.scrollWidth - d.clientWidth }
|
||||
})
|
||||
check('390px:几组接入选项和长标题都在弹窗里,没有横向滚动条', !m.wide.length && m.scrollers === 0 && m.own <= 0, JSON.stringify(m))
|
||||
await shoot(page, 'publish-provider-390', page)
|
||||
})
|
||||
|
||||
await section('360px 下不横向滚动', async () => {
|
||||
const { page } = await open({ width: 360, height: 780 })
|
||||
// 窄屏上「发布」收在「更多操作」里
|
||||
|
||||
Reference in New Issue
Block a user