From db84e576c04c4d57c5b211db439351040983e07c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 19:43:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8E=A5=E5=85=A5=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E6=94=B6=E5=B0=BE=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=EF=BC=8C=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E7=9A=84=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=80=E6=A0=8F=E4=B8=8D=E5=86=8D=E5=A4=9A=E5=87=BA=E4=B8=80?= =?UTF-8?q?=E5=8F=A5=E7=9B=B8=E5=8F=8D=E7=9A=84=E8=AF=B4=E6=98=8E=EF=BC=9B?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8=E8=AF=BB=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=AE=BE=E7=BD=AE=E9=87=8C=E7=9A=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=EF=BC=9B=E5=8A=A9=E6=89=8B=E6=A0=8F=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E9=80=80=E5=9B=9E=E5=8E=9F=E6=A0=B7?= =?UTF-8?q?=E5=B9=B6=E8=AF=B4=E6=98=8E=E3=80=81=E3=80=8C=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E3=80=8D=E5=8F=AA=E5=86=99=E5=9C=A8=E8=B7=9F=E9=9A=8F=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=B8=8A=E3=80=81=E5=8E=9F=E5=9B=A0=E7=9A=84=E5=8E=BB?= =?UTF-8?q?=E5=90=91=E5=86=99=E3=80=8C=E4=B8=8A=E6=96=B9=E3=80=8D=EF=BC=9B?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E3=80=8C=E5=90=AF=E7=94=A8=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=8F=A6=E8=B5=B7=E4=B8=80=E8=A1=8C=EF=BC=9B?= =?UTF-8?q?=E8=90=BD=E5=88=B0=E6=BC=94=E7=A4=BA=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=80=99=E9=80=89=E5=8A=A0=E9=86=92=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 检查器:模型一栏的留空说明(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 宽在弹窗里)。新加的检查项对着改动前的界面跑过, 都会不通过。 --- frontend/src/api/client.ts | 2 + frontend/src/canvas/Inspector.tsx | 28 ++++-- frontend/src/canvas/PublishDialog.tsx | 12 ++- frontend/src/canvas/issues.ts | 4 +- frontend/src/lib/terms.ts | 33 ++++-- frontend/src/pages/SettingsPage.tsx | 16 ++- frontend/src/run/Composer.tsx | 40 ++++++-- frontend/src/types.ts | 5 +- scripts/check-manage.mjs | 45 +++++++-- scripts/check-publish.mjs | 90 +++++++++++++++-- scripts/check-studio.mjs | 138 +++++++++++++++++++------- 11 files changed, 325 insertions(+), 88 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 43d0995f..e6104446 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 留空沿用已存的 */ diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 1802f6ea..ca9ace96 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -545,18 +545,23 @@ function SyntaxBadge({ syntax }: { syntax: FieldSyntax }) { } /** - * 模型一栏留空时执行用什么:节点点名了接入(没写就是全图默认点名的,同后端 NodeContext.cfg 和 schema 那条「没有指定模型」) - * 时是那个接入的默认模型。那个接入不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说 - * 「使用它的默认模型」就和它矛盾。全图默认不在节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态 + * 模型一栏留空时执行用什么,同后端 NodeContext.cfg:节点没写的取工作流默认设置里的。工作流默认设置写了模型,用的就是那个 + * 模型(后端这时也不报「没有指定模型」);否则节点点名了接入(没写就是全图默认点名的)时是那个接入的默认模型。点名的接入 + * 不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说「使用……」就和它矛盾。全图默认不在 + * 节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态。help 为 undefined 时用节点定义里那句 */ -function modelDefault(config: Record, providers: Provider[]): { blank: string; named: string | null; ok: boolean } { +function modelDefault(config: Record, 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' diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 2ba3c3f8..48b95506 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -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)} /> {o.label} + {/* 修完以后落到演示类接入:说明里那半句是淡色小字、排在长句末尾,选项名旁另加醒目的标签 */} + {o.demo && ( + + {PROVIDER_REF_TEXT.demoTag} + + )} {fix.default !== undefined && (fix.multiple ? Array.isArray(fix.default) && fix.default.some((d) => same(d, o.value)) : same(fix.default, o.value)) && ( diff --git a/frontend/src/canvas/issues.ts b/frontend/src/canvas/issues.ts index f6ad4e75..aff73089 100644 --- a/frontend/src/canvas/issues.ts +++ b/frontend/src/canvas/issues.ts @@ -259,7 +259,9 @@ function normalizeFix(raw: any): PublishFix | null { const options = asArray(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 { diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 93a0d0a2..8c972edb 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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 /** diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 521816e7..e58951b5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -575,10 +575,18 @@ function ProviderEditor({ provider, catalog, onClose, onSaved }: { /> - +
+ + {/* 停用的后果另起一行写在复选框下面,不放进 label:放在里面时它和「启用」是并排的两个 flex 项,长说明会把「启用」 + 挤成上下两个字,读屏也把整句连着当成复选框的名字念 */} +

+ {PROVIDER_REF_TEXT.enabledHint} +

+
{blocked &&

还缺:{missing.join('、')}

} diff --git a/frontend/src/run/Composer.tsx b/frontend/src/run/Composer.tsx index 09986f61..569da602 100644 --- a/frontend/src/run/Composer.tsx +++ b/frontend/src/run/Composer.tsx @@ -45,14 +45,21 @@ export function Composer({ hero, onFocusChange }: { const [opts, setOpts] = useState(false) // 存着的助手模型设定(GET /api/copilot/model):接入、模型、实际会用的模型、用不了时的原因 const [saved, setSaved] = useState(null) + // 重新选择时保存失败:下拉已退回选之前的样子,下面说明没改成 + const [saveFailed, setSaveFailed] = useState(false) + // 只认最后一次选择的回包:连着选两次时,先发的那次晚回来也不能盖掉后一次 + const picks = useRef(0) const [useBase, setUseBase] = useState(true) const ref = useRef(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 }: { + {saveFailed && ( +
{MT.saveFailed}
+ )} {broken && ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2bde6047..e1b191ac 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index 4d721b02..f2d14dd5 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -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, diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index e68819ae..babb588c 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -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 } }) diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index 74061c0c..7a741992 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -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'] } })