模型接入报错收尾(第 1 轮复核,前端):反方审稿的模型一栏不再多出一句相反的说明;检查器读工作流默认设置里的模型;助手栏保存失败时退回原样并说明、「当前」只写在跟随默认上、原因的去向写「上方」;接入「启用」的说明另起一行;落到演示模型的修复候选加醒目标签

- 检查器:模型一栏的留空说明(modelDefault)只用于节点定义里的模型栏。反方审稿的「反方模型」自己画、只读节点自己的
  配置、留空跟随设置,以前全图默认点名了接入时也被套上「留空则使用接入「X」的默认模型」,和它自己的说明相反;工作流
  默认设置写了模型时,空选项和说明写「使用工作流默认设置的模型「X」」(后端执行时取的就是它,也不报「没有指定模型」),
  不再说成某个接入的默认模型;
- 助手栏「助手使用的模型」:重新选择时保存失败,下拉、原因、齿轮上的红点退回选之前的样子(服务端存着的没变),下面写
  「保存失败,助手使用的模型未更改」,收起再展开后不再留着;只认最后一次选择的回包;选了具体模型时「跟随默认」不写
  「当前」(那时服务端给的 effective_model 是选的那个模型);下拉下面的原因用服务端的 panel_error;
- 设置页接入编辑框:停用的后果另起一行写在复选框下面(aria-describedby 接上,读屏名只是「启用」),不再和「启用」并排
  放在 label 里把它挤成上下两个字;说法收紧为「停用后,该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也
  无法调用」——调不了以后怎样(报错、转人工审批、记未裁判、记缺口)由各组自己那句说,不笼统写「运行时报错」;
- 发布前检查:候选带 demo 标记时,选项名旁加提醒色的「演示模型」标签,说明里那半句照旧(normalizeFix 留下这个标记)。
  types.ts 的 PublishFix 选项加可选的 demo 字段。

检查:check-studio 那一节加反方审稿不套说明(全图默认点名接入、写了模型两种)、工作流默认设置带模型时的空选项和说明、
保存失败退回原样与收起再展开、选具体模型不写「当前」、原因写「上方」;check-manage 量「启用」只占一行(1440、390)、
说明不在 label 里、读屏名只是「启用」、说明在弹窗里;check-publish 的伪造门禁跟上「无法改用其他接入:」与 demo 标记,
加一节演示标签(两个候选各一个、提醒色而非淡色、和选项名同一行、390 宽在弹窗里)。新加的检查项对着改动前的界面跑过,
都会不通过。
This commit is contained in:
ThearchyHelios
2026-10-06 19:43:44 +08:00
parent 7255ae98a5
commit db84e576c0
11 changed files with 325 additions and 88 deletions
+2
View File
@@ -102,6 +102,8 @@ export interface CopilotModel {
effective_provider: string | null
effective_model: string | null
error?: string | null
/** 同一件事写在助手栏「助手使用的模型」那一格下面的说法:去向写「上方」,不把人指回这个下拉框本身。老后端不给 */
panel_error?: string | null
}
/** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */
+18 -10
View File
@@ -545,18 +545,23 @@ function SyntaxBadge({ syntax }: { syntax: FieldSyntax }) {
}
/**
* 模型一栏留空时执行用什么:节点点名了接入(没写就是全图默认点名的,同后端 NodeContext.cfg 和 schema 那条「没有指定模型」)
* 时是那个接入的默认模型。那个接入不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说
* 「使用它的默认模型」就和它矛盾。全图默认不在节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态
* 模型一栏留空时执行用什么,同后端 NodeContext.cfg:节点没写的取工作流默认设置里的。工作流默认设置写了模型,用的就是那个
* 模型(后端这时也不报「没有指定模型」);否则节点点名了接入(没写就是全图默认点名的)时是那个接入的默认模型。点名的接入
* 不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说「使用……」就和它矛盾。全图默认不在
* 节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态。help 为 undefined 时用节点定义里那句
*/
function modelDefault(config: Record<string, any>, providers: Provider[]): { blank: string; named: string | null; ok: boolean } {
function modelDefault(config: Record<string, any>, providers: Provider[]): { blank: string; help?: string | null } {
const defaults = toGraph([], []).defaults ?? {}
let value = config.provider
if (value == null || value === '') value = toGraph([], []).defaults?.provider
if (value == null || value === '') value = defaults.provider
const named = typeof value === 'string' && value.trim() ? value.trim() : null
const fallback = typeof defaults.model === 'string' && defaults.model.trim() ? defaults.model.trim() : null
// 认法同后端 named_provider:名称或 id,名称两边的空白不算
const found = named ? providers.find((p) => p.id === named || p.name === named || p.name.trim() === named) : undefined
const state = !named || !providers.length || found?.enabled ? 'ok' : found ? 'disabled' : 'missing'
return { blank: PROVIDER_REF_TEXT.modelBlank(named, state), named, ok: state === 'ok' }
const help = state !== 'ok' ? null : fallback ? PROVIDER_REF_TEXT.modelHelpDefaults(fallback)
: named ? PROVIDER_REF_TEXT.modelHelp(named) : undefined
return { blank: PROVIDER_REF_TEXT.modelBlank(named, state, fallback), help }
}
function Field({ field, nodeId, value, config, issues, onChange }: {
@@ -564,10 +569,13 @@ function Field({ field, nodeId, value, config, issues, onChange }: {
issues: FieldIssue[]; onChange: (v: any) => void
}) {
const id = `f-${nodeId}-${field.key}`
// 模型一栏的说明跟着点名的接入走(modelDefault):节点定义里那句只适用于没点名接入的时候
const providers = useCatalog((s) => (field.type === 'model' ? s.providers : NO_ROWS))
const model = field.type === 'model' ? modelDefault(config, providers) : null
const help = !model?.named ? field.help : model.ok ? PROVIDER_REF_TEXT.modelHelp(model.named) : undefined
// 节点定义里的模型一栏(MODEL_FIELDS):说明跟着全图默认的模型、点名的接入走(modelDefault),节点定义里那句只适用于
// 两样都没写的时候。自己画的模型栏(核查节点的反方模型、盲算模型)只读节点自己的配置、留空跟随设置,不取全图默认,
// 说明在它自己里面——这里再套一句「留空则使用接入「X」的默认模型」就和它相反
const modelField = field.type === 'model' && !field.render
const providers = useCatalog((s) => (modelField ? s.providers : NO_ROWS))
const model = modelField ? modelDefault(config, providers) : null
const help = model?.help === undefined ? field.help : model.help ?? undefined
const syntax = syntaxOf(field)
const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields'
|| field.type === 'metricsList' || field.type === 'caliberFrom' || field.type === 'judge'
+11 -1
View File
@@ -12,7 +12,9 @@ import {
import { useCatalog } from '../store/catalog'
import { Modal, Spinner, toast, useRadioGroup } from '../components/ui'
import { humanizeError } from '../lib/errors'
import { CATALOG_UI_TEXT as UT, PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL } from '../lib/terms'
import {
CATALOG_UI_TEXT as UT, PROVIDER_REF_TEXT, PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL,
} from '../lib/terms'
import { isSqlCheckCode, sqlRuleLabel } from '../lib/sqlcheck'
import { SqlLevelBadge } from '../run/SqlChecks'
import { setLocalActor, useLocalActor } from '../lib/actor'
@@ -622,6 +624,14 @@ function FixControl({ fix, pf }: { fix: PublishFix; pf: PreflightState }) {
checked={picked(o.value)} disabled={pf.working} onChange={() => pf.choose(fix, o.value)} />
<span className="min-w-0 [overflow-wrap:anywhere]">
{o.label}
{/* 修完以后落到演示类接入:说明里那半句是淡色小字、排在长句末尾,选项名旁另加醒目的标签 */}
{o.demo && (
<span className="ml-1 whitespace-nowrap rounded border px-1 font-medium" data-fix-demo=""
title={PROVIDER_REF_TEXT.demoTagHint}
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 45%, var(--border))' }}>
{PROVIDER_REF_TEXT.demoTag}
</span>
)}
{fix.default !== undefined && (fix.multiple
? Array.isArray(fix.default) && fix.default.some((d) => same(d, o.value))
: same(fix.default, o.value)) && (
+3 -1
View File
@@ -259,7 +259,9 @@ function normalizeFix(raw: any): PublishFix | null {
const options = asArray<any>(raw.options)
.filter((o) => o && typeof o === 'object' && 'value' in o)
.map((o) => ({ value: o.value, label: asText(o.label ?? o.value), hint: o.hint ? asText(o.hint) : null,
...(o.handoff === true ? { handoff: true } : {}) }))
...(o.handoff === true ? { handoff: true } : {}),
// 点名的模型接入:修完以后落到演示类接入(后端 autofix._demo_mark),弹窗在选项名旁加醒目的标签
...(o.demo === true ? { demo: true } : {}) }))
// 选项类没有候选就没法选:不给控件,比给一个空下拉强
if (kind === 'choice' && !options.length) return null
return {
+23 -10
View File
@@ -920,10 +920,11 @@ export const PUBLISH_FIX_TEXT = {
} as const
/**
* 停用一个接入的后果。不只是下拉里看不到:点名它(或只写了它的模型)的节点、门控 / 裁判 / 核查设置,运行时都照实报错,
* 由定时任务发起的正式运行直接失败(后端 providers/factory.named_provider)
* 停用一个接入的后果。不只是下拉里看不到:点名它(或只写了它的模型)的节点、门控 / 裁判 / 核查设置都调不了它(后端
* providers/factory.named_provider)。调不了以后怎样各处不同(节点报错、门控转人工审批、裁判记未裁判、核查记缺口),
* 由设置页各组自己那一句说,这里只说「无法调用」;整句控制在 40 字以内
*/
const PROVIDER_DISABLED_EFFECT = '该接入的模型不再出现在下拉列表中;指定该接入或其模型的节点和设置将无法调用,运行时报错'
const PROVIDER_DISABLED_EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
/**
* 点名的模型接入不存在或已停用(后端 providers/factory.named_provider、engine/provider_refs.py)。以前点名的接入不存在时
@@ -954,19 +955,29 @@ export const PROVIDER_REF_TEXT = {
*/
modelNote: (name: string, owner: string, modelField: string, providerField: string) =>
`「${name}」是接入「${owner}」的模型:如需使用该模型,请在「${modelField}」中填写,「${providerField}」留空或选择「${owner}」`,
/** 接入编辑框「启用」旁的说明、接入卡片上「已停用」的悬停说明 */
/** 接入编辑框「启用」下面的说明、接入卡片上「已停用」的悬停说明 */
enabledHint: `停用后,${PROVIDER_DISABLED_EFFECT}`,
disabledChip: `已停用:${PROVIDER_DISABLED_EFFECT}`,
/**
* 检查器模型一栏的空选项:节点(没写就是全图默认)点名了接入时,留空用的是那个接入的默认模型,照实说是哪个;那个接入
* 不存在、已停用时说状态(检查器顶上另有接入那条问题),不说「使用」。和画布校验那条「没有指定模型,将使用接入「X」的
* 默认模型」同一个说法(后端 schema.MODEL_UNSET)
* 检查器模型一栏的空选项:留空时执行用什么(同后端 NodeContext.cfg:节点没写的取工作流默认设置里的)。工作流默认设置
* 写了模型,用的就是那个模型(后端这时也不报「没有指定模型」);节点(没写就是全图默认)点名了接入,用那个接入的默认
* 模型,照实说是哪个;那个接入不存在、已停用时说状态(检查器顶上另有接入那条问题),不说「使用」。和画布校验那条
* 「没有指定模型,将使用接入「X」的默认模型」同一个说法(后端 schema.MODEL_UNSET)
*/
modelBlank: (named: string | null, state: 'ok' | 'missing' | 'disabled') => (!named ? '默认(使用第一个可用的模型接入)'
: state === 'missing' ? `默认(模型接入「${named}」不存在)`
: state === 'disabled' ? `默认(模型接入「${named}」已停用)` : `默认(使用接入「${named}」的默认模型)`),
modelBlank: (named: string | null, state: 'ok' | 'missing' | 'disabled', fromDefaults: string | null = null) => (
state === 'missing' ? `默认(模型接入「${named}」不存在)`
: state === 'disabled' ? `默认(模型接入「${named}」已停用)`
: fromDefaults ? `默认(使用工作流默认设置的模型「${fromDefaults}」)`
: named ? `默认(使用接入「${named}」的默认模型)` : '默认(使用第一个可用的模型接入)'),
/** 同一栏下面的说明(没点名接入时用节点定义里的那句);点名的接入不存在、已停用时不写,免得和接入那条问题打架 */
modelHelp: (named: string) => `留空则使用接入「${named}」的默认模型`,
modelHelpDefaults: (model: string) => `留空则使用工作流默认设置的模型「${model}」`,
/**
* 发布前检查里修完以后落到演示类接入的候选(后端 autofix 的 demo 标记):选项名旁的醒目标签。说明里那半句「演示用,
* 不调用真实模型」照旧写,只是淡色小字、排在长句末尾——K1 要防的就是悄悄换成演示模型
*/
demoTag: '演示模型',
demoTagHint: '选择后执行时由演示模型产出,不调用真实模型',
} as const
/**
@@ -987,6 +998,8 @@ export const ASSISTANT_MODEL_TEXT = {
note: '仅影响助手,不改变节点使用的模型。助手需要严格按格式输出,指令遵循能力较弱的模型可能无法生成工作流。',
/** 存着的设定用不了时,齿轮按钮的悬停说明(按钮上另有一个红点) */
broken: '助手使用的模型当前无法调用,点击查看原因',
/** 重新选择时保存失败:下拉退回选之前的样子(服务端存着的没变),在下面说明 */
saveFailed: '保存失败,助手使用的模型未更改',
} as const
/**
+12 -4
View File
@@ -575,10 +575,18 @@ function ProviderEditor({ provider, catalog, onClose, onSaved }: {
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={form.enabled} onChange={(e) => set({ enabled: e.target.checked })} />
启用<span className="text-2xs text-faint" data-provider-enabled-hint="">{PROVIDER_REF_TEXT.enabledHint}</span>
</label>
<div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={form.enabled} aria-describedby="provider-enabled-hint"
onChange={(e) => set({ enabled: e.target.checked })} />
启用
</label>
{/* 停用的后果另起一行写在复选框下面,不放进 label:放在里面时它和「启用」是并排的两个 flex 项,长说明会把「启用」
挤成上下两个字,读屏也把整句连着当成复选框的名字念 */}
<p id="provider-enabled-hint" className="mt-0.5 pl-5 text-2xs leading-snug text-faint" data-provider-enabled-hint="">
{PROVIDER_REF_TEXT.enabledHint}
</p>
</div>
{blocked && <p className="text-2xs text-faint">还缺:{missing.join('、')}</p>}
+31 -9
View File
@@ -45,14 +45,21 @@ export function Composer({ hero, onFocusChange }: {
const [opts, setOpts] = useState(false)
// 存着的助手模型设定(GET /api/copilot/model):接入、模型、实际会用的模型、用不了时的原因
const [saved, setSaved] = useState<CopilotModel | null>(null)
// 重新选择时保存失败:下拉已退回选之前的样子,下面说明没改成
const [saveFailed, setSaveFailed] = useState(false)
// 只认最后一次选择的回包:连着选两次时,先发的那次晚回来也不能盖掉后一次
const picks = useRef(0)
const [useBase, setUseBase] = useState(true)
const ref = useRef<HTMLTextAreaElement>(null)
// 挂上时取一次;每次展开「助手使用的模型」再取一次:接入可能刚在设置里启用、停用或改了名
// 挂上时取一次;每次展开「助手使用的模型」再取一次:接入可能刚在设置里启用、停用或改了名。取的途中人已经重新选过
// 的话不用这一份(它是选之前的样子)。重新取回来的就是服务端现在存着的,上一次「保存失败」的说明不再留着
useEffect(() => {
if (!opts && saved) return
let alive = true
void api.copilot.getModel().then((m) => { if (alive) setSaved(m) }).catch(() => undefined)
setSaveFailed(false)
const seq = picks.current
void api.copilot.getModel().then((m) => { if (alive && seq === picks.current) setSaved(m) }).catch(() => undefined)
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opts])
@@ -75,14 +82,24 @@ export function Composer({ hero, onFocusChange }: {
// 它——否则下拉停在「跟随默认」,看着一切正常,接入失效了也看不出来,再选「跟随默认」不算改动、清不掉存着的接入
const custom = !!saved && (!!saved.provider || (!!saved.model && !options.some((o) => o.value === saved.model)))
const model = custom ? SAVED : saved?.model ?? ''
const broken = saved?.error || null
// 下拉下面写的原因:服务端给的这一格专用的说法(去向写「上方」),老服务端只给整句
const broken = saved?.panel_error || saved?.error || null
const pickModel = (value: string) => {
if (value === SAVED) return
// 先按选的显示;存的是 provider 置空、只写模型(跟随默认时两项都空),回来的是服务端认下的样子
setSaved((s) => ({ configured: !!value, provider: null, model: value || null, effective_provider: null,
effective_model: value || s?.effective_model || null, error: null }))
void api.copilot.setModel({ model: value || null }).then(setSaved).catch(() => undefined)
const before = saved
const seq = ++picks.current
setSaveFailed(false)
// 先按选的显示;存的是 provider 置空、只写模型(跟随默认时两项都空),回来的是服务端认下的样子。跟随默认时实际用
// 哪个模型由服务端说了算,回来之前不写「当前」
setSaved({ configured: !!value, provider: null, model: value || null, effective_provider: null,
effective_model: value || null, error: null, panel_error: null })
void api.copilot.setModel({ model: value || null }).then((m) => { if (seq === picks.current) setSaved(m) }).catch(() => {
if (seq !== picks.current) return
// 没存上:服务端存着的还是原来那一组,退回选之前的样子(原因、齿轮上的红点照旧),并说明保存失败
setSaved(before)
setSaveFailed(true)
})
}
const send = (override?: string) => {
@@ -189,8 +206,9 @@ export function Composer({ hero, onFocusChange }: {
<label className="label" htmlFor="copilot-model">{MT.label}</label>
<select id="copilot-model" className="field" value={model} onChange={(e) => pickModel(e.target.value)}
aria-invalid={broken ? true : undefined} aria-describedby={broken ? 'copilot-model-error' : undefined}>
{/* 「当前」只在跟随默认、用得了的时候写:存着的设定列成单独一项时,这一项说的是选它以后会用哪个 */}
<option value="">{MT.follow}{!custom && saved?.effective_model ? MT.current(saved.effective_model) : ''}</option>
{/* 「当前」只在选着跟随默认、用得了的时候写:选了别的模型时 effective_model 是那个模型,不是跟随默认会用的;
存着的设定列成单独一项时,这一项说的是选它以后会用哪个 */}
<option value="">{MT.follow}{model === '' && saved?.effective_model ? MT.current(saved.effective_model) : ''}</option>
{custom && <option value={SAVED}>{MT.saved(saved!.provider, saved!.model)}</option>}
{groups.map((g) => (
<optgroup key={g} label={g}>
@@ -200,6 +218,10 @@ export function Composer({ hero, onFocusChange }: {
</optgroup>
))}
</select>
{saveFailed && (
<div role="alert" data-copilot-model-save-failed=""
className="mt-1 text-2xs leading-relaxed text-[var(--err)]">{MT.saveFailed}</div>
)}
{broken && (
<div id="copilot-model-error" role="alert" data-copilot-model-error=""
className="mt-1 text-2xs leading-relaxed text-[var(--err)] [overflow-wrap:anywhere]">{broken}</div>
+3 -2
View File
@@ -1389,8 +1389,9 @@ export interface PublishFix {
label: string
preview?: { field?: string | null; before?: unknown; after?: unknown } | null
/** handoff:选了这一项就是「交给 Copilot」(G4 的「它在做计算」)。只认这个标记,不认值——
* 别的选项拿节点 id 当值,节点 id 恰好叫 copilot 的报告撰写节点照样是普通候选 */
options?: { value: unknown; label: string; hint?: string | null; handoff?: boolean }[]
* 别的选项拿节点 id 当值,节点 id 恰好叫 copilot 的报告撰写节点照样是普通候选。
* demo:点名的模型接入的候选,修完以后执行时落到演示类接入(不调用真实模型),选项名旁加醒目的标签 */
options?: { value: unknown; label: string; hint?: string | null; handoff?: boolean; demo?: boolean }[]
multiple?: boolean
/** 建议值:界面上标「建议」,仍然要人选 */
default?: unknown
+38 -7
View File
@@ -655,27 +655,58 @@ await section('设置 · 点名的接入已停用或不存在', async () => {
})
await section('设置 · 停用接入的后果写全;接入名恰好是模型名时说清怎么用这个模型', async () => {
// 停用一个接入以后,点名它(或只写了它的模型)的节点和设置在运行时报错(K1):卡片上「已停用」的悬停说明、编辑框里
// 「启用」旁的说明都要写到这一层,不只是「下拉里看不到」。停用的接入恰好叫 big-model,而 big-model 是启用的 demo-main
// 提供的模型:设置页三格下面补一句这是哪个接入的模型、在哪一栏用它,和服务端保存时 422 的那句一致。接入表、设置都是假的
// 停用一个接入以后,点名它(或只写了它的模型)的节点和设置都调不了它(K1):卡片上「已停用」的悬停说明、编辑框里
// 「启用」下面的说明都要写到这一层,不只是「下拉里看不到」;调不了以后怎样(报错、转人工审批、记未裁判、记缺口)各组
// 自己说,这里不笼统写「运行时报错」。停用的接入恰好叫 big-model,而 big-model 是启用的 demo-main 提供的模型:设置页
// 三格下面补一句这是哪个接入的模型、在哪一栏用它,和服务端保存时 422 的那句一致。接入表、设置都是假的
const PROVIDERS = [
{ id: 'check-trap-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-trap-p2', name: 'big-model', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'old-model',
models: [{ id: 'old-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
const EFFECT = '该接入的模型不再出现在下拉列表中;指定该接入或其模型的节点和设置将无法调用,运行时报错'
const cards = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]] })
const EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
// 编辑框里「启用」的版式:「启用」两个字只占一行(以前长说明和它并排放在 label 里,1440 宽时把它挤成上下两个字);
// 说明不在 label 里,复选框的读屏名只是「启用」,说明由 aria-describedby 接上
const enabledLayout = (page) => page.evaluate(() => {
const hint = document.querySelector('[data-provider-enabled-hint]')
const box = [...document.querySelectorAll('[role="dialog"] input[type="checkbox"]')]
.find((el) => el.closest('label')?.textContent?.trim().startsWith('启用'))
const label = box?.closest('label')
const word = label && [...label.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes('启用'))
const range = document.createRange()
if (word) range.selectNodeContents(word)
const lines = word ? new Set([...range.getClientRects()].filter((r) => r.width > 0).map((r) => Math.round(r.top))).size : 0
const d = document.querySelector('[role="dialog"]')
// 弹窗自己不出横向滚动条,「启用」一行和说明都在弹窗里
const fits = !!d && d.scrollWidth - d.clientWidth <= 0
&& [label, hint].every((el) => el && el.getBoundingClientRect().right <= d.getBoundingClientRect().right + 0.5)
return { lines, inLabel: !!label && !!hint && label.contains(hint), described: !!hint?.id && box?.getAttribute('aria-describedby') === hint.id, fits }
})
const cards = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 1440, height: 900 } })
const chip = cards.page.locator('[data-provider="big-model"] .chip', { hasText: '已停用' })
check('卡片上「已停用」:悬停写明点名它的地方运行时报错', await chip.getAttribute('title').catch(() => '') === `已停用:${EFFECT}`,
check('卡片上「已停用」:悬停写明点名它的节点和设置也无法调用', await chip.getAttribute('title').catch(() => '') === `已停用:${EFFECT}`,
await chip.getAttribute('title').catch(() => ''))
await cards.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click()
const enabledHint = await cards.page.locator('[data-provider-enabled-hint]').innerText().catch(() => '')
check('编辑框「启用」旁:不只说下拉里看不到,也说运行时报错', enabledHint === `停用后,${EFFECT}`, enabledHint)
check('编辑框「启用」下面:不只说下拉里看不到,也说点名它的节点和设置无法调用', enabledHint === `停用后,${EFFECT}`, enabledHint)
const wide = await enabledLayout(cards.page)
check('1440 宽:「启用」只占一行,说明另起一行、不在 label 里,由复选框的 aria-describedby 接上',
wide.lines === 1 && !wide.inLabel && wide.described && wide.fits, JSON.stringify(wide))
check('……复选框的读屏名只是「启用」', await cards.page.getByRole('checkbox', { name: '启用', exact: true }).count() === 1)
await shot(cards.page, 'provider-enabled-hint')
check('没有运行时报错', cards.errors.length === 0, cards.errors[0] ?? '')
await cards.close()
const phone = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 390, height: 844 } })
await phone.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click()
await phone.page.locator('[data-provider-enabled-hint]').waitFor()
const narrow = await enabledLayout(phone.page)
check('390 宽:「启用」同样只占一行,说明在弹窗里折行,弹窗不出横向滚动条', narrow.lines === 1 && narrow.fits, JSON.stringify(narrow))
await shot(phone.page, 'provider-enabled-hint-390')
check('没有运行时报错', phone.errors.length === 0, phone.errors[0] ?? '')
await phone.close()
let stored = await get('/settings')
stored = {
...stored,
+82 -8
View File
@@ -219,6 +219,23 @@ const PROVIDERS = [
{ 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 },
]
/**
* 启用的只有演示类接入(K1b):「提问」点名停用的 demo-off,「不指定接入」落到演示接入,「改用 demo-mock」本身就是演示接入。
* 两个候选都带 demo 标记(后端 autofix._demo_mark),选项名旁要有醒目的「演示模型」标签,不能只靠说明末尾的淡色小字
*/
const PROV_DEMO = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-off' }),
node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ nodes.ask.text }}' }] }),
],
edges: chain('in', 'ask', 'done'),
}
const DEMO_PROVIDERS = [
PROVIDERS[1],
{ id: 'pv-mock', name: 'demo-mock', kind: 'mock', base_url: '', default_model: 'mock-1',
models: [{ id: 'mock-1' }], enabled: true, extra: {}, api_key_masked: '', has_key: false },
]
const wf = (id, name, graph, extra = {}) => ({
id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false,
@@ -241,6 +258,7 @@ const FAKES = {
'pf-provok': wf('pf-provok', '__publish_check_provider_ok__', PROV_OK),
'pf-provmodel': wf('pf-provmodel', '__publish_check_provider_model__', PROV_MODEL),
'pf-provnofix': wf('pf-provnofix', '__publish_check_provider_nofix__', PROV_NOFIX),
'pf-provdemo': wf('pf-provdemo', '__publish_check_provider_demo__', PROV_DEMO),
}
const clone = (x) => JSON.parse(JSON.stringify(x))
@@ -254,7 +272,8 @@ const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, la
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这几张图用到的情形,图里没有全图默认):节点去掉接入落到
* 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)。协作节点换掉、去掉接入时,跟着它的成员自己写着
* 没有启用接入提供的模型,一个候选都落不稳:没有修复,提示不写「或在「发布前检查」中改用其他接入」
* 没有启用接入提供的模型,一个候选都落不稳:没有修复,提示不写「或在「发布前检查」中改用其他接入」,改说卡在哪里
* (后端 provider_refs._tail:「无法改用其他接入:」加第一处落不稳的地方)。落到演示类接入的候选说明末尾照实说、带 demo 标记
*/
function providerLint(graph, providers, level) {
const issues = []
@@ -263,6 +282,7 @@ function providerLint(graph, providers, level) {
const kind = { llm: '模型调用', supervisor: '多 Agent 协作' }
const serves = (p, m) => (p.models ?? []).some((x) => x.id === m) || p.default_model === m
const enabled = providers.filter((x) => x.enabled)
const demo = (p) => (p?.kind === 'mock' ? { demo: true } : {})
for (const n of graph.nodes) {
const c = n.data.config ?? {}
const at = `${who(n)}(${kind[n.type]})`
@@ -281,22 +301,28 @@ function providerLint(graph, providers, level) {
const options = []
const drops = []
const up = r.team && r.n.data.config.provider ? providers.find((x) => x.name === r.n.data.config.provider) : enabled[0]
const stuck = !r.team && (r.n.data.config.agents ?? []).some((a) => !a.provider && a.model && !enabled.some((x) => serves(x, a.model)))
const stuckBy = r.team ? null : (r.n.data.config.agents ?? []).find((a) => !a.provider && a.model && !enabled.some((x) => serves(x, a.model)))
const stuck = !!stuckBy
if (up?.enabled && !stuck) {
const drop = !!model && !serves(up, model)
if (drop) drops.push('')
options.push({ value: '', label: '不指定接入', hint: `去掉这一处指定的接入${drop ? `和模型「${model}」` : ''},执行时改用`
+ (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`) })
+ (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`)
+ (up.kind === 'mock' ? '(演示用,不调用真实模型)' : ''), ...demo(up) })
}
for (const x of stuck ? [] : enabled) {
const drop = !!model && !serves(x, model)
if (drop) drops.push(x.name)
options.push({ value: x.name, label: x.name, hint: drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : null })
const notes = [drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : '',
x.kind === 'mock' ? '演示用的模型接入,不调用真实模型' : ''].filter(Boolean)
options.push({ value: x.name, label: x.name, hint: notes.join(';') || null, ...demo(x) })
}
const fixable = options.length > 0
const off = stuckBy && providers.find((x) => !x.enabled && serves(x, stuckBy.model))
issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate && fixable ? id : null,
message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}`
+ (fixable ? ',或在「发布前检查」中改用其他接入' : '') })
+ (fixable ? ',或在「发布前检查」中改用其他接入'
: off ? `。无法改用其他接入:${r.who}的成员「${stuckBy.name}」指定的模型「${stuckBy.model}」所属的模型接入「${off.name}」已停用` : '') })
if (gate && fixable) {
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops })
}
@@ -638,7 +664,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade,
validate: 'ok', validates: 0, deleted: [] }
// 点名接入、只写了模型的那几张图:接入表伪造,校验、门禁、修复都按它现算
const providers = ['pf-prov', 'pf-provok', 'pf-provmodel', 'pf-provnofix'].includes(id) ? clone(PROVIDERS) : null
const providers = id === 'pf-provdemo' ? clone(DEMO_PROVIDERS)
: ['pf-prov', 'pf-provok', 'pf-provmodel', 'pf-provnofix'].includes(id) ? clone(PROVIDERS) : null
state.providers = providers
await page.route(/\/api\//, (route) => {
@@ -1750,8 +1777,10 @@ await section('点名的模型接入:给不出修复的那一处不借同节
const ownText = await own.innerText().catch(() => '')
check('协作节点自己的接入:一行,没有修复(选项、预览按钮都没有)', await own.locator('[data-fix-choice]').count() === 0
&& await own.getAttribute('data-fix-kind') === null, ownText.replace(/\s+/g, ' ').slice(0, 100))
check('……提示只说去设置里核对,不指到发布前检查', ownText.includes('指定的模型接入「demo-old」不存在。请在「设置 → 模型接入」中核对名称')
&& !ownText.includes('发布前检查'), ownText.replace(/\s+/g, ' ').slice(0, 120))
check('……提示不指到发布前检查:说去设置里核对,再说卡在哪里(成员只写了停用接入的模型)',
ownText.includes('指定的模型接入「demo-old」不存在。请在「设置 → 模型接入」中核对名称。无法改用其他接入:'
+ '「协作」(多 Agent 协作)的成员「研究员」指定的模型「tiny-1」所属的模型接入「demo-off」已停用')
&& !ownText.includes('发布前检查'), ownText.replace(/\s+/g, ' ').slice(0, 160))
check('成员「分析师」那一处:自己的修复画在自己这一行', await member.locator('[data-fix-choice="provider.missing:team:agents[0].provider"]').count() === 1
&& (await member.innerText()).includes('或在「发布前检查」中改用其他接入'))
check('整个弹窗里只有这一组接入的修复', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 1)
@@ -1759,6 +1788,51 @@ await section('点名的模型接入:给不出修复的那一处不借同节
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:落到演示类接入的候选在选项名旁有醒目的「演示模型」标签', async () => {
const { page, errors } = await open({ id: 'pf-provdemo' })
await openDialog(page)
const group = dialog(page).locator('[data-fix-choice="provider.disabled:ask:provider"]')
await waitFor(page, async () => (await group.count()) === 1)
const tags = group.locator('[data-fix-demo]')
check('两个候选都落到演示接入,各有一个「演示模型」标签', await tags.count() === 2
&& (await tags.allInnerTexts()).every((t) => t.trim() === '演示模型'), String(await tags.count()))
const clear = group.locator('label', { hasText: '不指定接入' })
check('……「不指定接入」的说明里照旧写明演示用、不调用真实模型', (await clear.innerText()).includes('(演示用,不调用真实模型)'))
// 醒目:标签用提醒色,不是说明那种淡色小字;标签就在选项名旁(同一行)
const look = await group.evaluate((el) => {
const tag = el.querySelector('[data-fix-demo]')
const hint = tag.closest('label').querySelector('.text-faint')
const probe = document.createElement('span')
probe.style.color = 'var(--warn)'
el.appendChild(probe)
const warn = getComputedStyle(probe).color
probe.remove()
const name = tag.parentElement.firstChild
const range = document.createRange()
range.selectNodeContents(name)
return { tag: getComputedStyle(tag).color, hint: hint ? getComputedStyle(hint).color : '', warn,
sameLine: Math.abs(range.getBoundingClientRect().top - tag.getBoundingClientRect().top) < 6 }
})
check('……标签用提醒色(不是说明的淡色),和选项名在同一行', look.tag === look.warn && look.tag !== look.hint && look.sameLine, JSON.stringify(look))
await shoot(page, 'publish-provider-demo', dialog(page))
// 390 宽:「发布」收在「更多操作」里。标签和说明在弹窗里折行,不伸出弹窗、不出横向滚动条
const narrow = await open({ id: 'pf-provdemo', width: 390, height: 844 })
await narrow.page.getByRole('button', { name: '更多操作' }).click()
await narrow.page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click()
await dialog(narrow.page).waitFor()
await waitFor(narrow.page, async () => (await dialog(narrow.page).locator('[data-fix-demo]').count()) === 2)
const fits = await narrow.page.evaluate(() => {
const d = document.querySelector('[role="dialog"]')
const box = d.getBoundingClientRect()
const tags = [...d.querySelectorAll('[data-fix-demo]')].map((t) => t.getBoundingClientRect())
return { own: d.scrollWidth - d.clientWidth,
inside: tags.every((r) => r.right <= Math.min(box.right, window.innerWidth) + 0.5 && r.left >= box.left - 0.5) }
})
check('390 宽:标签在弹窗里,弹窗不出横向滚动条', fits.own <= 0 && fits.inside, JSON.stringify(fits))
await shoot(narrow.page, 'publish-provider-demo-390', dialog(narrow.page))
check('没有运行时报错', errors.length === 0 && narrow.errors.length === 0, [...errors, ...narrow.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 } })
+102 -36
View File
@@ -2610,7 +2610,7 @@ await section('合并查询:节点库认得、卡片摘要、检查器从上
// ================================================================ 点名的模型接入(K1b)
// 接入表、画布校验、助手的模型设定都是伪造的(一家启用、一家停用,名字都是通用名)。校验按伪造的接入表现算,形状照
// 后端:点名的接入不存在、已停用给接入那一条(provider.*);点名的接入好好的、没写模型给「将使用接入「X」的默认模型」;
// 接入那一条在时不再给「没有指定模型」(provider_refs.quiet_unset_model)
// 接入那一条在时不再给「没有指定模型」(provider_refs.quiet_unset_model);工作流默认设置写了模型时不报「没有指定模型」
const MODEL_PROVIDERS = [
{ id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model',
models: [{ id: 'big-model' }, { id: 'small-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
@@ -2624,6 +2624,8 @@ const MODEL_GRAPH = {
node('gone', 'llm', 300, 220, '接入已删', { prompt: '{{ input.q }}', provider: 'demo-gone' }),
node('off', 'agent', 300, 380, '接入已停用', { prompt: '{{ input.q }}', provider: 'demo-off' }),
node('plain', 'llm', 300, 540, '没点名', { prompt: '{{ input.q }}' }),
// 反方审稿的模型一栏自己画(只读节点自己的配置、留空跟随设置),不取工作流默认设置
node('chal', 'challenge', 300, 700, '审稿', {}),
node('done', 'output', 640, 300, '成果', { fields: [{ name: 'a', value: '{{ last_message }}' }] }),
],
edges: [edge('start', 'named'), edge('start', 'gone'), edge('start', 'off'), edge('start', 'plain'),
@@ -2643,64 +2645,75 @@ const modelLint = (graph) => {
message: `「${n.data.label}」指定的模型接入「${named}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}` })
continue
}
if (!c.model) {
if (!c.model && !graph.defaults?.model) {
issues.push({ level: 'warning', node_id: n.id, field: 'model', code: 'model.unset',
message: named ? `没有指定模型,将使用接入「${named}」的默认模型` : '没有指定模型,将使用默认模型' })
}
}
return { ok: true, issues }
}
// 助手的模型取不到:error 是生成时报的整句(别处也这么说),panel_error 写在助手栏那一格下面,去向是「上方」的下拉框
const ASSISTANT_BROKEN = '助手使用的模型接入「demo-gone」不存在。请在「设置 → 模型接入」中核对名称,或在「编排」页助手栏的「助手使用的模型」中重新选择'
const ASSISTANT_PANEL = '助手使用的模型接入「demo-gone」不存在。请在上方重新选择,或在「设置 → 模型接入」中核对名称'
await section('点名的模型接入:检查器模型一栏说清留空用哪个接入;助手栏照实显示存着的设定、能重新选「跟随默认」', async () => {
FAKES['st-model'] = wf('st-model', { name: '__studio_check_model__', graph: MODEL_GRAPH, version: 1 })
FAKES['st-modeldef'] = wf('st-modeldef', { name: '__studio_check_model_defaults__', graph: { ...MODEL_GRAPH, defaults: { provider: 'demo-main' } }, version: 1 })
// 助手的模型设定:存着一个已删掉的接入和一个下拉里列得出的模型(big-model),GET 照后端说明用不了;PUT 记下请求、
// 清掉存着的接入。模型列得出也不能直接选中它:那样看着一切正常,发出去还会带上模型、绕过存着的接入
const BROKEN = { configured: true, provider: 'demo-gone', model: 'big-model', effective_provider: null, effective_model: null, error: ASSISTANT_BROKEN }
const copilot = { saved: BROKEN, puts: [] }
FAKES['st-modeldm'] = wf('st-modeldm', { name: '__studio_check_model_default_model__',
graph: { ...MODEL_GRAPH, defaults: { provider: 'demo-main', model: 'small-model' } }, version: 1 })
// 助手的模型设定:存着一个已删掉的接入和一个下拉里列得出的模型(big-model),GET 照后端说明用不了;PUT 记下请求,照请求
// 存(选了模型就是那个模型、跟随默认就清掉存着的接入),fail 为真时答 500、什么都不改。模型列得出也不能直接选中它:
// 那样看着一切正常,发出去还会带上模型、绕过存着的接入
const BROKEN = { configured: true, provider: 'demo-gone', model: 'big-model', effective_provider: null, effective_model: null,
error: ASSISTANT_BROKEN, panel_error: ASSISTANT_PANEL }
const copilot = { saved: BROKEN, puts: [], fail: false }
const before = async (page) => {
const json = (route, body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await page.route(/\/api\/providers(\?.*)?$/, (route) => (route.request().method() === 'GET' ? json(route, MODEL_PROVIDERS) : route.fallback()))
await page.route(/\/api\/workflows\/validate$/, (route) => json(route, modelLint(route.request().postDataJSON()?.graph ?? {})))
await page.route(/\/api\/copilot\/model$/, (route) => {
if (route.request().method() === 'PUT') {
copilot.puts.push(route.request().postDataJSON())
copilot.saved = { configured: false, provider: null, model: null, effective_provider: 'demo-main', effective_model: 'big-model', error: null }
const body = route.request().postDataJSON()
copilot.puts.push(body)
if (copilot.fail) return json(route, { detail: '数据库已锁定,请稍后重试' }, 500)
copilot.saved = body.model
? { configured: true, provider: null, model: body.model, effective_provider: 'demo-main', effective_model: body.model, error: null, panel_error: null }
: { configured: false, provider: null, model: null, effective_provider: 'demo-main', effective_model: 'big-model', error: null, panel_error: null }
}
return json(route, copilot.saved)
})
}
try {
const { ctx, page, state, errors } = await open({ path: '/studio/st-model', before, stream: [{ op: 'done', explanation: '好' }] })
const field = page.locator('[data-inspector-sheet] [data-field="model"]')
const look = async (id) => {
await S(page, (x) => window.__studio.getState().select(x), id)
await field.waitFor({ timeout: 4000 })
await page.waitForTimeout(150)
const look = async (p, id) => {
const f = p.locator('[data-inspector-sheet] [data-field="model"]')
await S(p, (x) => window.__studio.getState().select(x), id)
await f.waitFor({ timeout: 4000 })
await p.waitForTimeout(150)
const select = f.locator('select option[value=""]')
return {
blank: (await field.locator('select option[value=""]').innerText()).trim(),
help: await field.locator('[data-field-help]').count() ? (await field.locator('[data-field-help]').innerText()).trim() : null,
text: await field.innerText(),
sheet: await page.locator('[data-inspector-sheet]').innerText(),
blank: await select.count() ? (await select.innerText()).trim() : null,
help: await f.locator('[data-field-help]').count() ? (await f.locator('[data-field-help]').innerText()).trim() : null,
text: await f.innerText(),
sheet: await p.locator('[data-inspector-sheet]').innerText(),
}
}
await waitAnalysis(page)
const named = await look('named')
const named = await look(page, 'named')
check('点名了启用的接入、没写模型:空选项写「使用接入「demo-main」的默认模型」', named.blank === '默认(使用接入「demo-main」的默认模型)', named.blank)
check('……下面的说明同样写这个接入', named.help === '留空则使用接入「demo-main」的默认模型', named.help)
check('……校验那条也照实写接入', named.text.includes('没有指定模型,将使用接入「demo-main」的默认模型'), named.text.slice(0, 120))
const gone = await look('gone')
const gone = await look(page, 'gone')
check('点名的接入不存在:空选项说状态,不说「使用」', gone.blank === '默认(模型接入「demo-gone」不存在)', gone.blank)
check('……模型一栏下面不再写说明,也没有「没有指定模型」那条', gone.help === null && !gone.text.includes('没有指定模型'), gone.text.slice(0, 120))
check('……接入那条问题在检查器顶上', gone.sheet.includes('指定的模型接入「demo-gone」不存在'), gone.sheet.slice(0, 160))
const off = await look('off')
const off = await look(page, 'off')
check('点名的接入已停用:空选项写「已停用」,没有说明', off.blank === '默认(模型接入「demo-off」已停用)' && off.help === null, `${off.blank} · ${off.help}`)
const plain = await look('plain')
const plain = await look(page, 'plain')
check('没点名接入:照旧写第一个可用的模型接入', plain.blank === '默认(使用第一个可用的模型接入)' && plain.help === '留空则使用默认模型接入',
`${plain.blank} · ${plain.help}`)
if (SHOTS) {
await look('gone')
await look(page, 'gone')
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
@@ -2719,12 +2732,15 @@ await section('点名的模型接入:检查器模型一栏说清留空用哪
await gear.click()
const select = page.locator('#copilot-model')
await select.waitFor({ timeout: 3000 })
const picked = await select.evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '')
const pickedText = () => select.evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '')
const followText = async () => (await select.locator('option[value=""]').innerText()).trim()
const picked = await pickedText()
check('下拉停在存着的设定上(接入连同模型),不显示成「跟随默认」,也不只选中同名的模型', picked === 'big-model(模型接入「demo-gone」)'
&& await select.inputValue() !== '' && await select.inputValue() !== 'big-model', picked)
check('……「跟随默认」不带「当前」:选它以后用哪个要服务端说了算', (await select.locator('option[value=""]').innerText()).trim() === '跟随默认')
check('……「跟随默认」不带「当前」:选它以后用哪个要服务端说了算', await followText() === '跟随默认')
const said = await page.locator('[data-copilot-model-error]').innerText().catch(() => '')
check('下拉下面照实写原因(和发出一句话后报的同一句)', said === ASSISTANT_BROKEN && await select.getAttribute('aria-invalid') === 'true', said)
check('下拉下面写原因,去向是「上方」的下拉框,不把人指回这个下拉框本身', said === ASSISTANT_PANEL
&& await select.getAttribute('aria-invalid') === 'true', said)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
@@ -2742,27 +2758,76 @@ await section('点名的模型接入:检查器模型一栏说清留空用哪
check('……发出的请求不带模型', state.bodies.length === 1 && !('model' in state.bodies[0] && state.bodies[0].model), JSON.stringify(state.bodies[0] ?? {}).slice(0, 100))
await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 4000 }).catch(() => {})
// 重新选「跟随默认」时保存失败:服务端存着的没变,下拉退回存着的那一项,原因和齿轮上的红点照旧,并说明没改成
copilot.fail = true
await select.selectOption('')
await until(() => copilot.puts.length > 0, 3000)
check('重新选「跟随默认」算一次改动:存成不指定接入和模型', copilot.puts.length === 1 && copilot.puts[0].model === null
&& !copilot.puts[0].provider, JSON.stringify(copilot.puts))
await page.locator('[data-copilot-model-save-failed]').waitFor({ timeout: 3000 }).catch(() => {})
check('保存失败:说明没改成', (await page.locator('[data-copilot-model-save-failed]').innerText().catch(() => '')) === '保存失败,助手使用的模型未更改')
check('……下拉退回存着的那一项,原因和齿轮上的红点照旧', await pickedText() === 'big-model(模型接入「demo-gone」)'
&& await page.locator('[data-copilot-model-error]').innerText().catch(() => '') === ASSISTANT_PANEL
&& await page.locator('[data-copilot-model-broken]').count() === 1, await pickedText())
// 收起再展开:重新取回的就是服务端现在存着的,上一次「保存失败」的说明不再留着,原因照旧
await gear.click()
await gear.click()
await select.waitFor({ timeout: 3000 })
await page.waitForTimeout(300)
check('……收起再展开:「保存失败」的说明不再留着,原因照旧', await page.locator('[data-copilot-model-save-failed]').count() === 0
&& await page.locator('[data-copilot-model-error]').innerText().catch(() => '') === ASSISTANT_PANEL)
copilot.fail = false
await select.selectOption('')
await until(() => copilot.puts.length > 1, 3000)
check('重新选「跟随默认」算一次改动:存成不指定接入和模型', copilot.puts.length === 2 && copilot.puts[1].model === null
&& !copilot.puts[1].provider, JSON.stringify(copilot.puts))
await page.waitForFunction(() => !document.querySelector('[data-copilot-model-error]'), null, { timeout: 3000 }).catch(() => {})
check('……原因和齿轮上的标记一起消失,「跟随默认」写出当前用的模型', await page.locator('[data-copilot-model-error]').count() === 0
check('……原因、保存失败的说明、齿轮上的标记一起消失,「跟随默认」写出当前用的模型', await page.locator('[data-copilot-model-error]').count() === 0
&& await page.locator('[data-copilot-model-save-failed]').count() === 0
&& await page.locator('[data-copilot-model-broken]').count() === 0
&& (await select.locator('option[value=""]').innerText()).trim() === '跟随默认(当前:big-model)')
&& await followText() === '跟随默认(当前:big-model)', await followText())
// 选了一个模型:服务端回来的「当前」就是这个模型,不能写到「跟随默认」上(跟随默认用的是 demo-main 的 big-model)
await select.selectOption('small-model')
await until(() => copilot.puts.length > 2, 3000)
await page.waitForTimeout(200)
check('选了具体模型:「跟随默认」不写「当前」', copilot.puts[2]?.model === 'small-model' && await followText() === '跟随默认'
&& await select.inputValue() === 'small-model', await followText())
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
// 全图默认点名的接入:没写自己接入的节点跟着它
// 全图默认点名的接入:没写自己接入的节点跟着它。反方审稿的模型一栏不取全图默认,不套这句
const second = await open({ path: '/studio/st-modeldef', before })
await S(second.page, () => window.__studio.getState().select('plain'))
const defField = second.page.locator('[data-inspector-sheet] [data-field="model"]')
await defField.waitFor({ timeout: 4000 })
const defBlank = (await defField.locator('select option[value=""]').innerText()).trim()
check('全图默认点名了接入:没写自己接入的节点写「使用接入「demo-main」的默认模型」', defBlank === '默认(使用接入「demo-main」的默认模型)', defBlank)
const defPlain = await look(second.page, 'plain')
check('全图默认点名了接入:没写自己接入的节点写「使用接入「demo-main」的默认模型」', defPlain.blank === '默认(使用接入「demo-main」的默认模型)', defPlain.blank)
const chal = await look(second.page, 'chal')
check('反方审稿的「反方模型」:不多出「留空则使用接入……」(和它自己「跟随设置」的说明相反)', chal.help === null
&& !chal.text.includes('留空则使用接入') && chal.text.includes('核查模型'), chal.text.slice(0, 140))
check('没有运行时报错', second.errors.length === 0, second.errors.slice(0, 2).join(' | '))
await second.ctx.close()
// 工作流默认设置写了模型:节点没写模型时用的就是它,不说「使用接入的默认模型」
const third = await open({ path: '/studio/st-modeldm', before })
const dmPlain = await look(third.page, 'plain')
check('全图默认写了模型:空选项和说明都写这个模型', dmPlain.blank === '默认(使用工作流默认设置的模型「small-model」)'
&& dmPlain.help === '留空则使用工作流默认设置的模型「small-model」', `${dmPlain.blank} · ${dmPlain.help}`)
const dmNamed = await look(third.page, 'named')
check('……节点自己点名了接入也一样(模型取全图默认的)', dmNamed.blank === '默认(使用工作流默认设置的模型「small-model」)'
&& dmNamed.help === '留空则使用工作流默认设置的模型「small-model」' && !dmNamed.text.includes('没有指定模型'), `${dmNamed.blank} · ${dmNamed.help}`)
const dmGone = await look(third.page, 'gone')
check('……点名的接入不存在时仍先说状态,不说「使用」', dmGone.blank === '默认(模型接入「demo-gone」不存在)' && dmGone.help === null,
`${dmGone.blank} · ${dmGone.help}`)
const dmChal = await look(third.page, 'chal')
check('……反方审稿的模型一栏同样不套这句', dmChal.help === null && !dmChal.text.includes('工作流默认设置'), dmChal.text.slice(0, 140))
if (SHOTS) {
await look(third.page, 'plain')
for (const theme of ['dark', 'light']) {
await third.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await third.page.waitForTimeout(200)
await third.page.locator('[data-inspector-sheet]').screenshot({ path: `${SHOTS}/studio-model-defaults-${theme}.png` })
}
}
check('没有运行时报错', third.errors.length === 0, third.errors.slice(0, 2).join(' | '))
await third.ctx.close()
// 390 宽:助手栏浮在画布上,展开「助手使用的模型」、原因那一长句照样折行,不把页面撑出横向滚动
copilot.saved = BROKEN
const narrow = await open({ path: '/studio/st-model', before, width: 390, height: 844 })
@@ -2795,6 +2860,7 @@ await section('点名的模型接入:检查器模型一栏说清留空用哪
} finally {
delete FAKES['st-model']
delete FAKES['st-modeldef']
delete FAKES['st-modeldm']
}
})