mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
合入期 5 前置修复 K1b:模型接入报错的说法与修复候选收尾——修复规划只拿这次改动造成的失败否决候选、每处问题只认自己那一处的修复、给不出修复时照实说卡在哪;「不指定接入」落到演示类接入(含交给设置的核查、裁判)时写明演示用;接入名恰好是模型名时补一句;助手栏照实显示存着的设定与原因、能清掉失效的接入;检查器留空说明按节点与全图默认取值
This commit is contained in:
@@ -525,9 +525,10 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic
|
||||
"""当前 Copilot 会用哪个模型,以及是显式配的还是兜底来的。
|
||||
|
||||
存的接入不存在、已停用(或者只存了模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,
|
||||
error 写明原因(和生成时报的同一句),能用时 error 为 None。"""
|
||||
error 写明原因(和生成时报的同一句),能用时 error 为 None。panel_error 是同一件事写在助手栏「助手使用的模型」
|
||||
那一格下面的说法:人就在这个下拉框旁边,去向写「上方」,不再把人指回这个下拉框本身。"""
|
||||
from app.db.models import Setting
|
||||
from app.providers.factory import resolve_provider
|
||||
from app.providers.factory import assistant_unavailable, resolve_provider
|
||||
|
||||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||||
saved = row.value if row else {}
|
||||
@@ -537,7 +538,7 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic
|
||||
except ProviderNotConfigured as e:
|
||||
return {"configured": configured, "provider": saved.get("provider") or None,
|
||||
"model": saved.get("model") or None, "effective_provider": None, "effective_model": None,
|
||||
"error": _unconfigured(e)}
|
||||
"error": _unconfigured(e), "panel_error": assistant_unavailable(e, here=True)}
|
||||
return {
|
||||
"configured": configured,
|
||||
"provider": saved.get("provider") or None,
|
||||
@@ -545,6 +546,7 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic
|
||||
"effective_provider": resolved.name if resolved else None,
|
||||
"effective_model": saved.get("model") or (resolved.default_model if resolved else None),
|
||||
"error": None,
|
||||
"panel_error": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+29
-15
@@ -23,7 +23,8 @@ from app.engine.judge import sanitize_settings as judge_values, settings_problem
|
||||
from app.engine.spend import SPEND_KEY as VERIFY_SPEND, daily_spend as verify_daily_spend
|
||||
from app.providers import catalog
|
||||
from app.providers.factory import (
|
||||
ModelSpec, ProviderNotConfigured, _resolve_key, build_chat_model, list_providers as all_providers, named_provider,
|
||||
ModelSpec, ProviderNotConfigured, _resolve_key, assistant_unavailable, build_chat_model,
|
||||
list_providers as all_providers, model_note, named_provider,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["settings"])
|
||||
@@ -500,7 +501,8 @@ def verify_problem(value: Any) -> str | None:
|
||||
label = VERIFY_SETTING_LABEL[key]
|
||||
if key in ("provider", "model"):
|
||||
if item is not None and not isinstance(item, str):
|
||||
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示沿用证据裁判模型,当前为「{item}」"
|
||||
# 「跟随证据裁判模型」是设置页接入下拉框空选项的原话
|
||||
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「{item}」"
|
||||
elif key == "daily_max_usd":
|
||||
if not _positive(item, whole=False):
|
||||
return f"「{label}」需要填写大于 0 的金额(美元),留空表示不限,当前为「{item}」"
|
||||
@@ -728,36 +730,42 @@ class SettingsIn(BaseModel):
|
||||
PROVIDER_INVALID = "settings_provider_invalid"
|
||||
|
||||
|
||||
def _provider_fields() -> dict[tuple[str, str], tuple[str, str]]:
|
||||
"""设置里点名模型接入的项:(组, 键) → (设置页上的叫法, 留空时用什么)。门控、裁判、核查的叫法和设置页一致;助手的
|
||||
模型由助手面板单独设定(PUT /api/copilot/model),这里只防经设置接口写进去的那一份。"""
|
||||
def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
|
||||
"""设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查的叫法和
|
||||
设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框
|
||||
(模型那一项为 None),这里只防经设置接口写进去的那一份。"""
|
||||
from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入
|
||||
from app.providers.factory import ASSISTANT_MODEL_FIELD
|
||||
|
||||
return {
|
||||
("run", "tool_gate_provider"): ("门控模型 · 接入", "留空表示使用默认接入的默认模型"),
|
||||
(JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], "留空表示不指定"),
|
||||
(VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], "留空表示沿用证据裁判模型"),
|
||||
(COPILOT_SETTING_KEY, "provider"): ("助手模型 · 接入", "留空表示不指定"),
|
||||
("run", "tool_gate_provider"): ("门控模型 · 接入", "门控模型 · 模型"),
|
||||
(JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], JUDGE_LABEL["model"]),
|
||||
(VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], VERIFY_SETTING_LABEL["model"]),
|
||||
(COPILOT_SETTING_KEY, "provider"): (ASSISTANT_MODEL_FIELD, None),
|
||||
}
|
||||
|
||||
|
||||
async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tuple[str, str] | None:
|
||||
"""这次写进来的设置里点名的模型接入有没有问题:(字段, 说明),没问题返回 None。说明和运行时报的原因、提示同一句
|
||||
(factory.named_provider)。
|
||||
(factory.named_provider),主语写这一项在设置页上的叫法;下拉框里选的,写「指定的」。助手那一项只有「编排」页
|
||||
助手栏一个下拉框(只选模型,选了就清掉接入),没有「换一个接入」的地方:说法同助手取不到模型时那一句
|
||||
(factory.assistant_unavailable),哪里报都一样。
|
||||
|
||||
以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时
|
||||
先拦一道。留空照旧表示沿用上一级。只查这次改了的:存着的值没改(写好之后接入才被删、停用)不挡这次保存别的项——
|
||||
设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」的做法。
|
||||
先拦一道。留空照旧表示沿用上一级——说明里不再讲留空是什么意思:设置页那一格的空选项写着(「默认接入」「跟随助手
|
||||
的模型」「跟随证据裁判模型」……),这里另写一遍只会对不上。只查这次改了的:存着的值没改(写好之后接入才被删、
|
||||
停用)不挡这次保存别的项——设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」。
|
||||
点名的名字恰好是某个启用接入的模型时,补一句在这一组的哪一栏用它(factory.model_note)。
|
||||
|
||||
名字不是字符串(接口写进来的数字一类):核查、裁判两组自己的格式检查在前面先报;门控、助手两组没有自己的检查,
|
||||
在这里报,免得存下去之后读它的地方(查助手当前用哪个模型)出错。"""
|
||||
providers = None
|
||||
for (group, key), (label, blank) in _provider_fields().items():
|
||||
for (group, key), (label, model_label) in _provider_fields().items():
|
||||
sent = values.get(group)
|
||||
if not isinstance(sent, dict):
|
||||
continue
|
||||
if (raw := sent.get(key)) is not None and not isinstance(raw, str):
|
||||
return f"{group}.{key}", f"「{label}」需要填写模型接入的名称,{blank},当前为「{raw}」"
|
||||
return f"{group}.{key}", f"「{label}」需要填写模型接入的名称,当前为「{raw}」"
|
||||
if (name := _model_text(raw)) is None:
|
||||
continue
|
||||
row = await session.get(Setting, group)
|
||||
@@ -769,7 +777,13 @@ async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tu
|
||||
try:
|
||||
named_provider(providers, name)
|
||||
except ProviderNotConfigured as e:
|
||||
return f"{group}.{key}", f"「{label}」填写的{e.reason}。{e.hint};{blank}"
|
||||
if model_label is None:
|
||||
return f"{group}.{key}", assistant_unavailable(e)
|
||||
said = f"「{label}」指定的{e.reason}。{e.hint}"
|
||||
if e.model_owner:
|
||||
# 这几组的模型一栏是输入框:写「填写」
|
||||
said += f"。{model_note(e.name, e.model_owner, f'「{model_label}」', f'「{label}」', '填写')}"
|
||||
return f"{group}.{key}", said
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.data.catalog_impact import catalog_drift, catalog_versions_for, recorde
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Run, Workflow, WorkflowVersion
|
||||
from app.engine.governance import publish_issues
|
||||
from app.engine.provider_refs import CODES as PROVIDER_CODES, load_issues, load_providers
|
||||
from app.engine.provider_refs import CODES as PROVIDER_CODES, load_issues, load_providers, quiet_unset_model
|
||||
from app.engine.schema import GraphSpec, NodeType, validate_graph
|
||||
|
||||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||||
@@ -22,9 +22,14 @@ router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||||
_GONE = "工作流不存在,可能已被删除"
|
||||
_LEVELS = ("published", "governed")
|
||||
_BAD_LEVEL = "发布级别只能是「已发布」或「受管」"
|
||||
#: 剩下的挡住发布的只有点名接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,每一处都有自己的修复
|
||||
PROVIDER_LEFT = ("剩下的问题是指定的模型接入不存在或已停用,需要你决定改用哪个接入:请在每一处的修复中选择,"
|
||||
"或在「设置 → 模型接入」中核对")
|
||||
#: 剩下的挡住发布的只有模型接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,助手处理不了。发布弹窗里
|
||||
#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」。
|
||||
#: 人刚点了「交给助手」,写「助手无法处理」,不写「无法交给助手处理」(读来像没交出去)
|
||||
PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。"
|
||||
"请在「设置 → 模型接入」中核对名称或启用相应的接入")
|
||||
#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给。弹窗里那一组
|
||||
#: 选项在每条问题下方,标题是「选择……改用的模型接入」;另起半句,不和前面的「或」连用
|
||||
PROVIDER_LEFT_FIX = ";也可在各条问题下方选择改用的模型接入"
|
||||
|
||||
|
||||
class WorkflowIn(BaseModel):
|
||||
@@ -490,9 +495,12 @@ async def autofix(
|
||||
if out["ok"]:
|
||||
out["assist"] = {"ok": True, "summary": "修复后已没有阻止发布的错误,未再交给助手", "questions": []}
|
||||
return out
|
||||
if all(i.get("code") in PROVIDER_CODES for i in out["remaining"] if i["level"] == "error"):
|
||||
left = [i for i in out["remaining"] if i["level"] == "error"]
|
||||
if all(i.get("code") in PROVIDER_CODES for i in left):
|
||||
# 剩下的只有点名接入的问题:换成哪个接入要人拿主意(助手的自查也不看接入表),不交给助手
|
||||
out["assist"] = {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
|
||||
fixable = any(i.get("fix") for i in left)
|
||||
out["assist"] = {"ok": False, "summary": PROVIDER_LEFT + (PROVIDER_LEFT_FIX if fixable else ""),
|
||||
"questions": []}
|
||||
return out
|
||||
from app.api.copilot import assist_publish_fix
|
||||
|
||||
@@ -523,14 +531,16 @@ async def validate(payload: ValidateIn, session: AsyncSession = Depends(get_sess
|
||||
"""画布上的实时校验。前端每次改动都会调它来标红。
|
||||
|
||||
点名的模型接入不存在或已停用(只写了模型、提供它的接入已停用也算)只给警告:探索运行仍可发起,执行到那个节点时
|
||||
照实报错;发布时才挡住。"""
|
||||
照实报错;发布时才挡住。这时同一节点上「没有指定模型,将使用接入「X」的默认模型」和它矛盾,不再列
|
||||
(quiet_unset_model,发布一类在 governance._not_repeated 里同样处理)。"""
|
||||
try:
|
||||
spec = GraphSpec.model_validate(payload.graph)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]}
|
||||
result = validate_graph(spec).model_dump()
|
||||
result["issues"] += [i.model_dump() for i in await load_issues(session, spec, level="warning")]
|
||||
return result
|
||||
result = validate_graph(spec)
|
||||
found = await load_issues(session, spec, level="warning")
|
||||
result.issues = [*quiet_unset_model(spec, result.issues, found), *found]
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@router.post("/variables")
|
||||
|
||||
+169
-79
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -94,6 +94,16 @@ class _Ctx:
|
||||
self.level = level
|
||||
#: 模型接入表(provider_refs.load_providers):点名的接入出了问题时,候选从里面启用的那些里挑
|
||||
self.providers = providers or []
|
||||
self._provider_refs: dict[tuple[str | None, str], Any] | None = None
|
||||
|
||||
def provider_ref(self, node_id: str | None, field: str) -> Any:
|
||||
"""图里写着接入或模型的那一处(provider_refs.ProviderRef)。整张图只算一遍:每条接入问题都去扫一遍全图,
|
||||
几十处同时坏掉时就是几十遍。"""
|
||||
if self._provider_refs is None:
|
||||
from app.engine.provider_refs import provider_refs
|
||||
|
||||
self._provider_refs = {(r.node_id, r.field): r for r in provider_refs(self.spec)}
|
||||
return self._provider_refs.get((node_id, field))
|
||||
|
||||
def upstream(self, node_id: str, *types: NodeType) -> list[GraphNode]:
|
||||
"""这个节点上游的某几类节点,按画布上的节点顺序。"""
|
||||
@@ -517,98 +527,174 @@ def _landing_text(landed: list[tuple[Any, Any]]) -> str:
|
||||
|
||||
|
||||
def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None:
|
||||
"""执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定。点名的接入不存在、已停用
|
||||
的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定模型」和各个启用的接入
|
||||
(写在模型旁边的接入上)。
|
||||
"""执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定(provider_choice)。"""
|
||||
ref = ctx.provider_ref(issue.get("node_id"), str(issue.get("field") or ""))
|
||||
return provider_choice(ctx.spec, ref, ctx.providers) if ref is not None else None
|
||||
|
||||
要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
|
||||
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
|
||||
带字段):协作节点自己的接入和成员的接入各修各的。
|
||||
|
||||
class _ProviderPlan:
|
||||
"""图里一处点名的接入(provider_refs.ProviderRef)的修复候选,一个一个算:provider_choice 要全部,provider_fixable
|
||||
(问题提示指不指到发布前检查)找到第一个落得稳的就停。
|
||||
|
||||
点名的接入不存在、已停用的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定
|
||||
模型」和各个启用的接入(写在模型旁边的接入上)。
|
||||
|
||||
每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、
|
||||
节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供
|
||||
那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家
|
||||
必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。候选的说明写清执行
|
||||
时改用哪里、模型去没去掉。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。
|
||||
"""
|
||||
from app.engine.provider_refs import land, provider_refs, uses
|
||||
from app.providers.factory import serves
|
||||
必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。
|
||||
|
||||
field = str(issue.get("field") or "")
|
||||
ref = next((r for r in provider_refs(ctx.spec) if r.node_id == issue.get("node_id") and r.field == field), None)
|
||||
if ref is None:
|
||||
return None
|
||||
path = field.removeprefix("defaults.") if ref.node_id is None else field
|
||||
if ref.kind == "model":
|
||||
model_path, provider_path = path, path[: -len("model")] + "provider"
|
||||
else:
|
||||
provider_path, model_path = path, _model_path(path)
|
||||
prefix = "defaults." if ref.node_id is None else ""
|
||||
root = ctx.spec.defaults if ref.node_id is None else (node.config if node is not None else {})
|
||||
model = _read_path(root, model_path)
|
||||
model = model.strip() if isinstance(model, str) and model.strip() else None
|
||||
before = {use.key: use for use in uses(ctx.spec)}
|
||||
只否决这次改动造成的失败:改动波及的一处要是点名的接入没变、而那个接入本来就不存在或已停用(比如节点点名的接入
|
||||
写错了,这次改的是它跟随的全图默认模型),它落不稳不是这次改动的事,它自己那条问题照样在、照样拦着发布。拿它否决
|
||||
的话,两处互相牵连时谁都给不出修复,规划器自己卡住。
|
||||
|
||||
def trial(edits: list[tuple[str, Any]]) -> list[tuple[Any, Any]] | None:
|
||||
"""在副本上改,改动波及的每一处(取法变了的)执行时落到哪里;有一处落不稳返回 None。"""
|
||||
spec = ctx.spec.model_copy(deep=True)
|
||||
target = spec.defaults if ref.node_id is None else spec.node_map()[ref.node_id].config
|
||||
for at, value in edits:
|
||||
_put_path(target, at, value)
|
||||
改的是节点上的一处时只重算这个节点(provider_refs.node_uses:节点只读自己的配置和全图默认),不复制、不重算整张图;
|
||||
改的是全图默认时各节点都可能跟着变,整张图重算(这样的引用最多几处)。"""
|
||||
|
||||
def __init__(self, spec: GraphSpec, ref: Any, providers: list[Any]) -> None:
|
||||
from app.engine.provider_refs import node_uses, uses
|
||||
|
||||
self.spec, self.ref, self.providers = spec, ref, providers
|
||||
path = ref.field.removeprefix("defaults.") if ref.node_id is None else ref.field
|
||||
if ref.kind == "model":
|
||||
self.model_path, self.provider_path = path, path[: -len("model")] + "provider"
|
||||
else:
|
||||
self.provider_path, self.model_path = path, _model_path(path)
|
||||
self.prefix = "defaults." if ref.node_id is None else ""
|
||||
self.node = spec.node_map()[ref.node_id] if ref.node_id is not None else None
|
||||
model = _read_path(spec.defaults if self.node is None else self.node.config, self.model_path)
|
||||
self.model = model.strip() if isinstance(model, str) and model.strip() else None
|
||||
self.before = {use.key: use for use in (uses(spec) if self.node is None else node_uses(spec, self.node))}
|
||||
#: 一个候选都落不稳时说给人听的:第一个候选最后一次试算里第一处落不稳的地方(provider_refs.Landing.why)
|
||||
self.stuck = ""
|
||||
|
||||
def _broken_before(self, use: Any) -> bool:
|
||||
"""这一处点名的接入改动前后没变,而它本来就不存在或已停用:落不稳不是这次改动造成的。"""
|
||||
from app.providers.factory import ProviderNotConfigured, named_provider
|
||||
|
||||
was = self.before.get(use.key)
|
||||
if was is None or use.provider is None or use.provider != was.provider:
|
||||
return False
|
||||
try:
|
||||
named_provider(self.providers, use.provider.name)
|
||||
except ProviderNotConfigured:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _trial(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[Any, Any]] | None, Any]:
|
||||
"""在副本上改,改动波及的每一处(取法变了的)执行时落到哪里:(各处落到哪里,None);有一处因这次改动落不稳
|
||||
时是(None,那一处的 Landing)。改完没有一处落到新地方也算没修好(比如它跟随的那一级也坏着、原因还一样)。"""
|
||||
from app.engine.provider_refs import land, node_uses, uses
|
||||
|
||||
if self.node is None:
|
||||
defaults = copy.deepcopy(self.spec.defaults)
|
||||
for at, value in edits:
|
||||
_put_path(defaults, at, value)
|
||||
# 浅拷贝:节点原样共用,只换全图默认(uses 只读不写)
|
||||
after = uses(self.spec.model_copy(update={"defaults": defaults}))
|
||||
else:
|
||||
twin = self.node.model_copy(deep=True)
|
||||
for at, value in edits:
|
||||
_put_path(twin.config, at, value)
|
||||
after = node_uses(self.spec, twin)
|
||||
landed = []
|
||||
for use in uses(spec):
|
||||
if before.get(use.key) == use:
|
||||
for use in after:
|
||||
if self.before.get(use.key) == use:
|
||||
continue
|
||||
if not (landing := land(use, ctx.providers)).ok:
|
||||
return None
|
||||
if not (landing := land(use, self.providers)).ok:
|
||||
if self._broken_before(use):
|
||||
continue
|
||||
return None, landing
|
||||
landed.append((use, landing))
|
||||
return landed
|
||||
return landed or None, None
|
||||
|
||||
def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None:
|
||||
def _settle(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None:
|
||||
"""先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。"""
|
||||
if (landed := trial(edits)) is not None:
|
||||
landed, failed = self._trial(edits)
|
||||
if landed is None and self.model and all(at != self.model_path for at, _ in edits):
|
||||
edits = [*edits, (self.model_path, None)]
|
||||
landed, failed = self._trial(edits)
|
||||
if landed is not None:
|
||||
return edits, landed
|
||||
if model and all(at != model_path for at, _ in edits):
|
||||
edits = [*edits, (model_path, None)]
|
||||
if (landed := trial(edits)) is not None:
|
||||
return edits, landed
|
||||
if not self.stuck and failed is not None:
|
||||
self.stuck = failed.why
|
||||
return None
|
||||
|
||||
def options(self) -> Iterator[tuple[dict[str, Any], list[tuple[str, Any]]]]:
|
||||
"""落得稳的候选(选项,改法),先「不指定」,再按接入表的顺序各个启用的接入。"""
|
||||
from app.providers.factory import serves
|
||||
|
||||
ref, model = self.ref, self.model
|
||||
if (got := self._settle([(self.model_path if ref.kind == "model" else self.provider_path, None)])) is not None:
|
||||
edits, landed = got
|
||||
what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "")
|
||||
option = {"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}",
|
||||
"hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"}
|
||||
yield _demo_mark(option, landed), edits
|
||||
seen = {PROVIDER_CLEAR}
|
||||
for p in self.providers:
|
||||
if not getattr(p, "enabled", False) or not getattr(p, "name", None) or p.name in seen:
|
||||
continue
|
||||
seen.add(p.name)
|
||||
if (got := self._settle([(self.provider_path, p.name)])) is None:
|
||||
continue
|
||||
edits, landed = got
|
||||
notes = []
|
||||
if len(edits) > 1:
|
||||
# 这一处原来用的就是去掉的那个模型:换上的接入实际用哪个模型(多半是它的默认模型,上一级写了模型就是那个)
|
||||
mine = {landing.model for use, landing in landed
|
||||
if (was := self.before.get(use.key)) is not None and was.model is not None
|
||||
and (was.model.node_id, was.model.field) == (ref.node_id, self.prefix + self.model_path)}
|
||||
used = next(iter(mine)) if len(mine) == 1 else None
|
||||
said = [f"不提供模型「{model}」" if not serves(p, model) else f"模型「{model}」一并去掉"]
|
||||
if used:
|
||||
said.append(f"将改用{'它的默认模型' if used == p.default_model else '模型'}「{used}」")
|
||||
elif not serves(p, model):
|
||||
said.append("这一处的模型一并去掉")
|
||||
notes.append(",".join(said))
|
||||
if getattr(p, "kind", None) == "mock":
|
||||
notes.append(DEMO_PROVIDER)
|
||||
yield _demo_mark({"value": p.name, "label": p.name, "hint": ";".join(notes) or None}, landed), edits
|
||||
|
||||
|
||||
def _demo_mark(option: dict[str, Any], landed: list[tuple[Any, Any]]) -> dict[str, Any]:
|
||||
"""修完以后有一处落到演示类接入(provider_refs.Landing.demo):候选带上 demo 标记,发布弹窗在选项名旁加一个
|
||||
醒目的「演示模型」标签。说明里那半句演示说明照旧写,只是淡色小字、排在长句末尾,不够醒目——K1 要防的就是
|
||||
悄悄换成演示模型。"""
|
||||
return {**option, "demo": True} if any(landing.demo for _, landing in landed) else option
|
||||
|
||||
|
||||
def provider_choice(spec: GraphSpec, ref: Any, providers: list[Any]) -> dict[str, Any] | None:
|
||||
"""图里一处点名的接入(provider_refs.ProviderRef)的修复:要人选的 choice(_edits 记每个候选怎么改),一个候选都
|
||||
落不稳时为 None。候选怎么算见 _ProviderPlan。
|
||||
|
||||
要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
|
||||
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
|
||||
带字段):协作节点自己的接入和成员的接入各修各的。候选的说明写清执行时改用哪里、模型去没去掉,落到演示类接入的
|
||||
照实说并带 demo 标记。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。"""
|
||||
options: list[dict[str, Any]] = []
|
||||
plans: dict[str, list[tuple[str, Any]]] = {}
|
||||
if (got := settle([(model_path if ref.kind == "model" else provider_path, None)])) is not None:
|
||||
edits, landed = got
|
||||
what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "")
|
||||
options.append({"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}",
|
||||
"hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"})
|
||||
plans[PROVIDER_CLEAR] = edits
|
||||
for p in ctx.providers:
|
||||
if not getattr(p, "enabled", False) or not getattr(p, "name", None) or p.name in plans:
|
||||
continue
|
||||
if (got := settle([(provider_path, p.name)])) is None:
|
||||
continue
|
||||
edits, landed = got
|
||||
notes = []
|
||||
if len(edits) > 1:
|
||||
# 这一处原来用的就是去掉的那个模型:换上的接入实际用哪个模型(多半是它的默认模型,上一级写了模型就是那个)
|
||||
mine = {landing.model for use, landing in landed
|
||||
if (was := before.get(use.key)) is not None and was.model is not None
|
||||
and (was.model.node_id, was.model.field) == (ref.node_id, prefix + model_path)}
|
||||
used = next(iter(mine)) if len(mine) == 1 else None
|
||||
said = [f"不提供模型「{model}」" if not serves(p, model) else f"模型「{model}」一并去掉"]
|
||||
if used:
|
||||
said.append(f"将改用{'它的默认模型' if used == p.default_model else '模型'}「{used}」")
|
||||
elif not serves(p, model):
|
||||
said.append("这一处的模型一并去掉")
|
||||
notes.append(",".join(said))
|
||||
if getattr(p, "kind", None) == "mock":
|
||||
notes.append("演示用的模型接入,不调用真实模型")
|
||||
options.append({"value": p.name, "label": p.name, "hint": ";".join(notes) or None})
|
||||
plans[p.name] = edits
|
||||
for option, edits in _ProviderPlan(spec, ref, providers).options():
|
||||
options.append(option)
|
||||
plans[option["value"]] = edits
|
||||
if not options:
|
||||
# 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,说明里写了去设置里启用
|
||||
# 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,提示里说去设置里核对、启用、卡在哪里
|
||||
return None
|
||||
return _choice(f"选择{ref.who}改用的模型接入", field, options, _edits=plans)
|
||||
return _choice(f"选择{ref.who}改用的模型接入", ref.field, options, _edits=plans)
|
||||
|
||||
|
||||
def provider_fixable(spec: GraphSpec, ref: Any, providers: list[Any]) -> tuple[bool, str]:
|
||||
"""这一处给不给得出修复:(给得出,卡在哪里)。provider_refs 拿它决定问题提示指不指到发布前检查——给不出修复就别让
|
||||
人去那里找,改说第一处落不稳的地方(Landing.why)。找到第一个落得稳的候选就停:画布每改一下都要校验,不必为了
|
||||
一句提示把每个候选都算一遍。"""
|
||||
plan = _ProviderPlan(spec, ref, providers)
|
||||
for _ in plan.options():
|
||||
return True, ""
|
||||
return False, plan.stuck
|
||||
|
||||
|
||||
#: 候选本身是演示类接入(kind 为 mock)时的说明。「不指定」落到演示类接入时说明里另带 provider_refs.DEMO_LANDING
|
||||
DEMO_PROVIDER = "演示用的模型接入,不调用真实模型"
|
||||
|
||||
|
||||
_PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = {
|
||||
@@ -698,9 +784,11 @@ def public(fix: dict[str, Any]) -> dict[str, Any]:
|
||||
def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""问题的字典形状,带上对应的修复 id(fix)。validate 和门禁报的同一处问题指向同一个修复。
|
||||
|
||||
先按(编号、节点)认(点名的模型接入一处一条,同一节点上可能有几条,再带上字段);认不出时,图级问题(没有节点)
|
||||
认同一编号的第一条修复,图级修复(不落在节点上,比如改全图默认)认同一编号的每一条问题。前端 canvas/issues.fixFor
|
||||
是同一个退路。
|
||||
先按(编号、节点)认;认不出时,图级问题(没有节点)认同一编号的第一条修复,图级修复(不落在节点上,比如改全图
|
||||
默认)认同一编号的每一条问题。前端 canvas/issues.fixFor 是同一个退路。
|
||||
|
||||
点名的模型接入(_PER_FIELD)一处一条修复,只认(编号、节点、字段)都对得上的那一条,没有就是没有:这一处一个
|
||||
候选都落不稳时修复为空,退到同一节点、同一编号别处的修复上,接口里的 fix 就指到了另一处的修复。
|
||||
"""
|
||||
exact = {(f["code"], f["node_id"], f.get("field")): f["id"] for f in fixes if f["code"] in _PER_FIELD}
|
||||
by_node = {(f["code"], f["node_id"]): f["id"] for f in fixes}
|
||||
@@ -713,8 +801,10 @@ def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, A
|
||||
out = []
|
||||
for issue in map(_as_dict, issues):
|
||||
code = issue.get("code")
|
||||
fid = exact.get((code, issue.get("node_id"), issue.get("field"))) if code else None
|
||||
fid = fid or (by_node.get((code, issue.get("node_id"))) if code else None)
|
||||
if code in _PER_FIELD:
|
||||
out.append({**issue, "fix": exact.get((code, issue.get("node_id"), issue.get("field")))})
|
||||
continue
|
||||
fid = by_node.get((code, issue.get("node_id"))) if code else None
|
||||
if fid is None and code:
|
||||
fid = by_code.get(code) if issue.get("node_id") is None else by_graph.get(code)
|
||||
out.append({**issue, "fix": fid})
|
||||
|
||||
@@ -111,9 +111,9 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any]
|
||||
_lint_evidence(spec, flag, strict=strict)
|
||||
_lint_sql(spec, flag, checkers)
|
||||
if providers is not None:
|
||||
from app.engine.provider_refs import issues as provider_issues, provider_refs
|
||||
from app.engine.provider_refs import issues as provider_issues
|
||||
|
||||
for issue in provider_issues(provider_refs(spec), providers, level="error"):
|
||||
for issue in provider_issues(spec, providers, level="error"):
|
||||
result.add(issue.message, level="error", node_id=issue.node_id, field=issue.field, code=issue.code)
|
||||
|
||||
if strict and not has_contract_output:
|
||||
@@ -164,7 +164,11 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A
|
||||
|
||||
G5 落在 agent 上,validate 那条也落在 agent 上;G4 落在喂数的沙箱代码上,validate 那条落在口径卡读它的
|
||||
那条指标上,按口径卡的输入对回去。没被门禁覆盖的(比如不给口径卡供数的 agent)照旧提示。
|
||||
节点点名的接入不存在或已停用(门禁的 provider.*)时,validate 那条「没有指定模型,将使用接入「X」的默认模型」
|
||||
和它矛盾,同样不列(provider_refs.quiet_unset_model)。
|
||||
"""
|
||||
from app.engine.provider_refs import quiet_unset_model
|
||||
|
||||
cited = {i.node_id for i in gate if i.code == "governed.caliber_agent_cite_fields"}
|
||||
compute = {i.node_id for i in gate if i.code == "governed.caliber_compute_input"}
|
||||
covered = {("evidence.cite_fields_off", nid, "output_schema") for nid in cited}
|
||||
@@ -172,7 +176,8 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A
|
||||
if card.type == NodeType.METRICS and compute:
|
||||
covered |= {("evidence.caliber_compute_input", card.id, f"metrics[{i}].expression")
|
||||
for i, producer in _caliber_inputs(card, spec) if producer.id in compute}
|
||||
return [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered]
|
||||
kept = [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered]
|
||||
return quiet_unset_model(spec, kept, gate)
|
||||
|
||||
|
||||
def _lint_contract(
|
||||
|
||||
@@ -843,7 +843,8 @@ def settings_problem(value: Any) -> str | None:
|
||||
label = SETTING_LABEL.get(key, key)
|
||||
if key in ("provider", "model"):
|
||||
if item is not None and not isinstance(item, str):
|
||||
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示不指定,当前为「{item}」"
|
||||
# 「跟随助手的模型」是设置页接入下拉框空选项的原话(核查一组同样照设置页写,api/settings.verify_problem)
|
||||
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随助手的模型,当前为「{item}」"
|
||||
elif not _limit_ok(key, item):
|
||||
return f"「{label}」需要填写{_limit_words(key)},留空表示不限,当前为「{item}」"
|
||||
return None
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
说法分两种:检查器里有这一项下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判的接入和模型,几类节点自己的模型)
|
||||
说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用
|
||||
其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复
|
||||
按 land 算出修完以后执行时落到哪里,落不稳的候选不给。
|
||||
按 land 算出修完以后执行时落到哪里,落不稳的候选不给。一个候选都落不稳时修复为空,这时不指到发布前检查(那里什么
|
||||
都不给),说去设置里核对、启用,再说卡在哪里(第一处落不稳的地方,比如成员只写了停用接入的模型)——不然人去设置里
|
||||
找那个写错的名字,找不到,也看不出真正挡住修复的是别处。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个
|
||||
接入的模型、在这一处怎么用它(_model_note:按检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空)。
|
||||
|
||||
节点没写模型、点名的接入(自己的或全图默认的)本身不存在或已停用时,校验那条「没有指定模型,将使用接入「X」的
|
||||
默认模型」(schema.MODEL_UNSET)和这里的问题互相矛盾,由 quiet_unset_model 去掉前者。
|
||||
|
||||
接入表要读库,门禁本身不查库(governance.lint_for_publish),所以由接口这一层读出接入表交进来,同数据目录
|
||||
SQL 检查器的做法。设置里点名接入的几项由 api/settings.py 在保存时查。
|
||||
@@ -32,17 +38,26 @@ from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.engine.labels import field_label, type_label
|
||||
from app.engine.schema import CLAIMS_JUDGE, GraphNode, GraphSpec, NodeType, ValidationIssue
|
||||
from app.engine.labels import field_label, judge_label, type_label
|
||||
from app.engine.schema import CLAIMS_JUDGE, MODEL_UNSET, GraphNode, GraphSpec, NodeType, ValidationIssue
|
||||
from app.providers.factory import (
|
||||
NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, named_provider, provider_for_model, serves,
|
||||
NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_fact, model_note, named_provider,
|
||||
provider_for_model, serves,
|
||||
)
|
||||
|
||||
#: 这类问题的编号:provider.missing(点名的接入不存在)、provider.disabled(已停用;只写了模型、提供它的接入停用了也是它)
|
||||
CODES = frozenset({"provider.missing", "provider.disabled"})
|
||||
#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框
|
||||
_WHERE = {("provider", True): "或在节点中换一个接入", ("model", True): "或在节点中换一个模型",
|
||||
("provider", False): "或在「发布前检查」中改用其他接入", ("model", False): "或在「发布前检查」中改用其他接入"}
|
||||
#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框。检查器里没有下拉框的,发布前检查给了修复
|
||||
#: 才指到那里(IN_CHECK),否则不写后半句
|
||||
_WHERE = {"provider": "或在节点中换一个接入", "model": "或在节点中换一个模型"}
|
||||
IN_CHECK = "或在「发布前检查」中改用其他接入"
|
||||
#: 检查器里没有下拉框、发布前检查也给不出修复时,提示末尾说卡在哪里(Landing.why)
|
||||
NO_SWITCH = "无法改用其他接入:"
|
||||
#: 一个启用的接入都没有:没点名接入的执行时换成内置演示模型,点名的调不了
|
||||
NO_ENABLED = "没有启用的模型接入"
|
||||
#: 修完以后执行时落到演示类接入(kind 为 mock):说明里照实写。K1 要防的就是悄悄换成演示模型——落到它是人选的,
|
||||
#: 得让人知道选的是什么
|
||||
DEMO_LANDING = "(演示用,不调用真实模型)"
|
||||
#: 检查器里有模型下拉框的几类节点(前端 nodeDefs 的 MODEL_FIELDS;条件分支只在「让模型分类」时显示,也只在那时才查)
|
||||
_MODEL_PICKER = frozenset({NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.BRANCH, NodeType.REPORT})
|
||||
#: 核查、裁判的接入和模型都没写时执行用设置里的哪一项(api/settings.verify_model_spec、judge.judge_model_spec)
|
||||
@@ -76,7 +91,8 @@ _TAKES_MODEL: dict[NodeType, Callable[[GraphSpec, GraphNode], bool] | None] = {
|
||||
NodeType.VALIDATE: _repairs,
|
||||
NodeType.RETRIEVE: lambda spec, node: _cfg(spec, node, "rerank", "off") == "model",
|
||||
}
|
||||
#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的
|
||||
#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的。值是修复标题里的称呼
|
||||
#:(「选择……的盲算模型改用的模型接入」);问题里说「……的「盲算模型 · 接入」指定的」,写检查器上那一栏的叫法
|
||||
_VERIFY = {NodeType.DUAL_CHECK: "的盲算模型", NodeType.CHALLENGE: "的反方模型"}
|
||||
|
||||
|
||||
@@ -87,7 +103,10 @@ def _name(value: Any) -> str | None:
|
||||
@dataclass(frozen=True)
|
||||
class ProviderRef:
|
||||
"""图里写着接入或模型的一处:落在哪个节点(全图默认为 None)、哪个字段,写的名字,句子里怎么称呼这一处,检查器里
|
||||
有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。"""
|
||||
有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。
|
||||
|
||||
who 是修复标题里的称呼(「选择{who}改用的模型接入」);said 是「{said}指定的模型接入「X」」里的主语,默认同 who。
|
||||
核查节点两者不同:指定接入的是「盲算模型 · 接入」这一栏,不是盲算模型本身。"""
|
||||
|
||||
node_id: str | None
|
||||
field: str
|
||||
@@ -95,12 +114,17 @@ class ProviderRef:
|
||||
who: str
|
||||
editable: bool = False
|
||||
kind: str = "provider"
|
||||
said: str = ""
|
||||
|
||||
@property
|
||||
def subject(self) -> str:
|
||||
return self.said or self.who
|
||||
|
||||
|
||||
def _place(node_id: str | None, field: str, value: Any, who: str, *, editable: bool = False,
|
||||
kind: str = "provider") -> ProviderRef | None:
|
||||
kind: str = "provider", said: str = "") -> ProviderRef | None:
|
||||
name = _name(value)
|
||||
return ProviderRef(node_id, field, name, who, editable, kind) if name else None
|
||||
return ProviderRef(node_id, field, name, who, editable, kind, said) if name else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -123,48 +147,56 @@ def _own_or_default(spec: GraphSpec, node: GraphNode, key: str, who: str) -> Pro
|
||||
editable=key == "model" and node.type in _MODEL_PICKER)
|
||||
|
||||
|
||||
def node_uses(spec: GraphSpec, node: GraphNode) -> list[Use]:
|
||||
"""一个节点执行时取模型的每一处(uses 里属于它的那几项)。只读这个节点自己的配置和全图默认:改一个节点的配置
|
||||
只影响它自己这几处,修复规划(autofix 的 provider.*)按这一点只重算被改的那个节点,不再每试一个候选就把整张图
|
||||
重算一遍——几十个节点都点名同一个停用接入时,那样画布每改一下都要多算上万处。"""
|
||||
out: list[Use] = []
|
||||
cfg = node.config
|
||||
who = f"「{node.title}」({type_label(node.type)})"
|
||||
if node.type in _VERIFY:
|
||||
# 接入、模型哪一项写了就整组以节点为准,都没写交给设置(verify_model_spec)
|
||||
if cfg.get("enabled") is not False:
|
||||
vwho = who + _VERIFY[node.type]
|
||||
p = _place(node.id, "provider", cfg.get("provider"), vwho, editable=True,
|
||||
said=f"{who}的「{field_label('provider', node.type)}」")
|
||||
m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model",
|
||||
said=f"{who}的「{field_label('model', node.type)}」")
|
||||
out.append(Use((node.id, "verify"), vwho, p, m, None if p or m else "verify"))
|
||||
return out
|
||||
if node.type not in _TAKES_MODEL:
|
||||
return out
|
||||
provider, model = _own_or_default(spec, node, "provider", who), _own_or_default(spec, node, "model", who)
|
||||
cond = _TAKES_MODEL[node.type]
|
||||
if cond is None or cond(spec, node):
|
||||
out.append(Use((node.id, "node"), who, provider, model))
|
||||
if node.type == NodeType.SUPERVISOR and isinstance(cfg.get("agents"), list):
|
||||
# 成员自己写了(非空)就用自己的,否则跟随节点,同 nodes/multi._member 的 `cfg.get(...) or ctx.cfg(...)`
|
||||
for i, member in enumerate(cfg["agents"]):
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
label = _name(member.get("name")) or f"第 {i + 1} 位"
|
||||
mwho = f"{who}的成员「{label}」"
|
||||
own_p, own_m = member.get("provider"), member.get("model")
|
||||
out.append(Use((node.id, f"agents[{i}]"), mwho,
|
||||
_place(node.id, f"agents[{i}].provider", own_p, mwho) if own_p else provider,
|
||||
_place(node.id, f"agents[{i}].model", own_m, mwho, kind="model") if own_m else model))
|
||||
if node.type == NodeType.REPORT and _cfg(spec, node, "claims", "off") == CLAIMS_JUDGE:
|
||||
judge = _cfg(spec, node, "judge")
|
||||
# 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条
|
||||
own = judge is cfg.get("judge")
|
||||
nid, at, jwho = (node.id, "judge", f"{who}的{field_label('judge')}") if own else \
|
||||
(None, "defaults.judge", f"{field_label('defaults')}中的{field_label('judge')}")
|
||||
judge = judge if isinstance(judge, dict) else {}
|
||||
p = _place(nid, f"{at}.provider", judge.get("provider"), jwho, editable=own)
|
||||
m = _place(nid, f"{at}.model", judge.get("model"), jwho, editable=own, kind="model")
|
||||
out.append(Use((node.id, "judge"), jwho, p, m, None if p or m else "judge"))
|
||||
return out
|
||||
|
||||
|
||||
def uses(spec: GraphSpec) -> list[Use]:
|
||||
"""图里执行时取模型的每一处,按画布上节点的顺序;只算执行时真会取模型的(见模块说明)。"""
|
||||
out: list[Use] = []
|
||||
for node in spec.nodes:
|
||||
cfg = node.config
|
||||
who = f"「{node.title}」({type_label(node.type)})"
|
||||
if node.type in _VERIFY:
|
||||
# 接入、模型哪一项写了就整组以节点为准,都没写交给设置(verify_model_spec)
|
||||
if cfg.get("enabled") is not False:
|
||||
vwho = who + _VERIFY[node.type]
|
||||
p = _place(node.id, "provider", cfg.get("provider"), vwho, editable=True)
|
||||
m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model")
|
||||
out.append(Use((node.id, "verify"), vwho, p, m, None if p or m else "verify"))
|
||||
continue
|
||||
if node.type not in _TAKES_MODEL:
|
||||
continue
|
||||
provider, model = _own_or_default(spec, node, "provider", who), _own_or_default(spec, node, "model", who)
|
||||
cond = _TAKES_MODEL[node.type]
|
||||
if cond is None or cond(spec, node):
|
||||
out.append(Use((node.id, "node"), who, provider, model))
|
||||
if node.type == NodeType.SUPERVISOR and isinstance(cfg.get("agents"), list):
|
||||
# 成员自己写了(非空)就用自己的,否则跟随节点,同 nodes/multi._member 的 `cfg.get(...) or ctx.cfg(...)`
|
||||
for i, member in enumerate(cfg["agents"]):
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
label = _name(member.get("name")) or f"第 {i + 1} 位"
|
||||
mwho = f"{who}的成员「{label}」"
|
||||
own_p, own_m = member.get("provider"), member.get("model")
|
||||
out.append(Use((node.id, f"agents[{i}]"), mwho,
|
||||
_place(node.id, f"agents[{i}].provider", own_p, mwho) if own_p else provider,
|
||||
_place(node.id, f"agents[{i}].model", own_m, mwho, kind="model") if own_m else model))
|
||||
if node.type == NodeType.REPORT and _cfg(spec, node, "claims", "off") == CLAIMS_JUDGE:
|
||||
judge = _cfg(spec, node, "judge")
|
||||
# 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条
|
||||
own = judge is cfg.get("judge")
|
||||
nid, at, jwho = (node.id, "judge", f"{who}的{field_label('judge')}") if own else \
|
||||
(None, "defaults.judge", f"{field_label('defaults')}中的{field_label('judge')}")
|
||||
judge = judge if isinstance(judge, dict) else {}
|
||||
p = _place(nid, f"{at}.provider", judge.get("provider"), jwho, editable=own)
|
||||
m = _place(nid, f"{at}.model", judge.get("model"), jwho, editable=own, kind="model")
|
||||
out.append(Use((node.id, "judge"), jwho, p, m, None if p or m else "judge"))
|
||||
return out
|
||||
return [use for node in spec.nodes for use in node_uses(spec, node)]
|
||||
|
||||
|
||||
def provider_refs(spec: GraphSpec) -> list[ProviderRef]:
|
||||
@@ -192,23 +224,94 @@ def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[Provider
|
||||
return out
|
||||
|
||||
|
||||
def issues(refs: list[ProviderRef], providers: list[Any], *, level: str) -> list[ValidationIssue]:
|
||||
def _tail(spec: GraphSpec, ref: ProviderRef, providers: list[Any]) -> str:
|
||||
"""提示的后半句:检查器里有这一项的下拉框,指到节点;没有的,发布前检查给得出修复才指到那里——一个候选都落不稳
|
||||
时(比如一个启用的接入都没有,或者改动波及的别处都落不稳)那里什么都不给,指过去只会让人白找,改说卡在哪里。
|
||||
只问给不给得出、卡在哪里(autofix.provider_fixable,找到第一个落得稳的候选就停),不拼整条修复。"""
|
||||
if ref.editable:
|
||||
return f",{_WHERE[ref.kind]}"
|
||||
# 在这里导入:autofix 导入了门禁(governance),门禁又在 lint_for_publish 里导入本模块
|
||||
from app.engine.autofix import provider_fixable
|
||||
|
||||
fixable, stuck = provider_fixable(spec, ref, providers)
|
||||
if fixable:
|
||||
return f",{IN_CHECK}"
|
||||
return f"。{NO_SWITCH}{stuck}" if stuck else ""
|
||||
|
||||
|
||||
def _model_note(spec: GraphSpec, ref: ProviderRef, e: ProviderNotConfigured, providers: list[Any]) -> str:
|
||||
"""点名的名字恰好是某个启用接入的模型时补的那一句,按这一处在检查器里有哪几栏写(运行时报错、设置页的说法见
|
||||
factory.model_note)。多数节点的检查器里没有接入这一栏,叫人「接入留空」做不到:
|
||||
- 核查节点、报告自己的结论句裁判:接入、模型两栏都在(模型是输入框),照设置页的写法;
|
||||
- 检查器里有模型下拉框、没有接入一栏的节点(模型调用、Agent……):在节点的「模型」中选它,接入在发布前检查里
|
||||
改用这个接入或不指定(修复里有哪一项说哪一项);
|
||||
- 协作成员、全图默认这些两栏都没有的:发布前检查里能改用这个接入时说一句,否则只说这是哪个接入的模型。"""
|
||||
name, owner = e.name, e.model_owner
|
||||
if ref.editable:
|
||||
node = spec.node_map()[ref.node_id] if ref.node_id is not None else None
|
||||
labels = (judge_label("model"), judge_label("provider")) if ref.field.startswith("judge.") or node is None \
|
||||
else (field_label("model", node.type), field_label("provider", node.type))
|
||||
return model_note(name, owner, f"「{labels[0]}」", f"「{labels[1]}」", "填写")
|
||||
from app.engine.autofix import PROVIDER_CLEAR, provider_choice
|
||||
|
||||
fact = model_fact(name, owner)
|
||||
choice = provider_choice(spec, ref, providers)
|
||||
values = {o["value"] for o in choice["options"]} if choice else set()
|
||||
node = spec.node_map().get(ref.node_id) if ref.node_id is not None else None
|
||||
if node is not None and ref.field == "provider" and node.type in _MODEL_PICKER:
|
||||
picks = ([f"改用「{owner}」"] if owner in values else []) + \
|
||||
(["选择「不指定接入」"] if PROVIDER_CLEAR in values else [])
|
||||
if not picks:
|
||||
return fact
|
||||
return f"{fact}:如需使用该模型,请在节点的「{field_label('model')}」中选择它,并在「发布前检查」中{'或'.join(picks)}"
|
||||
return f"{fact},可在「发布前检查」中改用「{owner}」" if owner in values else fact
|
||||
|
||||
|
||||
def issues(spec: GraphSpec, providers: list[Any], *, level: str) -> list[ValidationIssue]:
|
||||
"""执行时会报错的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定:画布校验 warning,
|
||||
发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改。"""
|
||||
return [ValidationIssue(level=level, node_id=ref.node_id, field=ref.field, # type: ignore[arg-type]
|
||||
code=f"provider.{e.code}",
|
||||
message=f"{ref.who}指定的{e.reason}。{NAMED_FIX[e.code]},{_WHERE[ref.kind, ref.editable]}")
|
||||
for ref, e in broken(refs, providers)]
|
||||
发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改(_tail);点名的名字恰好是某个启用接入的
|
||||
模型时补一句(_model_note)。"""
|
||||
out = []
|
||||
for ref, e in broken(provider_refs(spec), providers):
|
||||
note = f"。{_model_note(spec, ref, e, providers)}" if e.model_owner else ""
|
||||
out.append(ValidationIssue(level=level, node_id=ref.node_id, field=ref.field, # type: ignore[arg-type]
|
||||
code=f"provider.{e.code}",
|
||||
message=f"{ref.subject}指定的{e.reason}。{NAMED_FIX[e.code]}"
|
||||
f"{_tail(spec, ref, providers)}{note}"))
|
||||
return out
|
||||
|
||||
|
||||
def quiet_unset_model(spec: GraphSpec, found: list[Any], provider_issues: list[Any]) -> list[Any]:
|
||||
"""去掉被接入问题盖住的「没有指定模型,将使用接入「X」的默认模型」(schema.MODEL_UNSET)。
|
||||
|
||||
节点没写模型、取到的接入(自己点名的,没写就是全图默认点名的,同 NodeContext.cfg)本身不存在或已停用时,那条说
|
||||
会用 X 的默认模型照常执行,接入那一条说执行到这里会失败,读者无从判断哪条是真的。接入那一条带着原因和改法,留它。
|
||||
found 是校验的问题(ValidationIssue),provider_issues 是 issues() 的结果;画布校验和发布一类各调一次。"""
|
||||
hit = {(i.node_id, i.field) for i in provider_issues if i.code in CODES}
|
||||
if not hit:
|
||||
return found
|
||||
nodes = spec.node_map()
|
||||
|
||||
def covered(issue: Any) -> bool:
|
||||
if issue.code != MODEL_UNSET or (node := nodes.get(issue.node_id)) is None:
|
||||
return False
|
||||
own = node.config.get("provider") not in (None, "")
|
||||
return ((node.id, "provider") if own else (None, "defaults.provider")) in hit
|
||||
|
||||
return [i for i in found if not covered(i)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Landing:
|
||||
"""执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时
|
||||
为 None),ok 是修复能不能把它落在这里。"""
|
||||
为 None),ok 是修复能不能把它落在这里。落到演示类接入时 demo 为真、where 末尾带着 DEMO_LANDING。落不稳时 why 说
|
||||
为什么(一句话,接在「无法改用其他接入:」后面),修复一个候选都给不出时拿第一处的 why 告诉人卡在哪里。"""
|
||||
|
||||
where: str
|
||||
model: str | None
|
||||
ok: bool
|
||||
demo: bool = False
|
||||
why: str = ""
|
||||
|
||||
|
||||
def land(use: Use, providers: list[Any]) -> Landing:
|
||||
@@ -216,40 +319,54 @@ def land(use: Use, providers: list[Any]) -> Landing:
|
||||
|
||||
ok 为真才算落得稳:点名的接入启用着、有模型可用,并且提供这一处写的模型——拿 A 家的模型名去调 B 家必然失败;
|
||||
只写了模型的,按模型名找到启用的接入。按模型名落到停用的接入(运行时报错)、模型名认不出来(落到第一个可用的
|
||||
接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。交给设置的(核查、裁判)算落得稳:
|
||||
设置保存时查过点名的接入,之后接入出了问题设置页会标出来。"""
|
||||
接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。
|
||||
|
||||
交给设置的(核查、裁判)算落得稳:设置保存时查过点名的接入,之后接入出了问题设置页会标出来。启用的接入全是演示类
|
||||
时,设置这一级不管写的是什么,执行时落到的都是演示模型(点名的启用接入、按模型名找到的、取值链最后的第一个可用的
|
||||
接入,只能是演示类),照实带上 DEMO_LANDING;一个启用的接入都没有时不算落得稳,同上面「换成内置演示模型」。"""
|
||||
if use.settings:
|
||||
return Landing(_SETTINGS[use.settings], None, True)
|
||||
enabled = [p for p in providers if getattr(p, "enabled", False)]
|
||||
if not enabled:
|
||||
return Landing(_SETTINGS[use.settings], None, False, why=NO_ENABLED)
|
||||
demo = all(getattr(p, "kind", None) == "mock" for p in enabled)
|
||||
return Landing(_SETTINGS[use.settings] + (DEMO_LANDING if demo else ""), None, True, demo=demo)
|
||||
model = use.model.name if use.model is not None else None
|
||||
if use.provider is not None:
|
||||
try:
|
||||
found = named_provider(providers, use.provider.name)
|
||||
except ProviderNotConfigured as e:
|
||||
return Landing(e.reason, model, False)
|
||||
where = f"{use.provider.who}指定的接入「{found.name}」"
|
||||
elif model:
|
||||
return Landing(e.reason, model, False, why=f"{use.provider.subject}指定的{e.reason}")
|
||||
where = f"{use.provider.subject}指定的接入「{found.name}」"
|
||||
elif use.model is not None and model:
|
||||
try:
|
||||
found = provider_for_model(providers, model)
|
||||
except ProviderNotConfigured as e:
|
||||
return Landing(e.reason, model, False)
|
||||
return Landing(e.reason, model, False, why=f"{use.model.subject}指定的{e.reason}")
|
||||
if found is None:
|
||||
return Landing(f"认不出的模型「{model}」", model, False)
|
||||
return Landing(f"认不出的模型「{model}」", model, False,
|
||||
why=f"{use.model.subject}指定的模型「{model}」不属于任何启用的模型接入")
|
||||
where = f"提供模型「{model}」的接入「{found.name}」"
|
||||
else:
|
||||
found = fallback_provider(providers)
|
||||
if found is None:
|
||||
return Landing("内置演示模型", None, False)
|
||||
return Landing("内置演示模型", None, False, why=NO_ENABLED)
|
||||
where = f"第一个可用的模型接入「{found.name}」"
|
||||
demo = getattr(found, "kind", None) == "mock"
|
||||
if demo:
|
||||
where += DEMO_LANDING
|
||||
used = model or found.default_model
|
||||
return Landing(where, used, bool(used) and (model is None or serves(found, model)))
|
||||
if not used:
|
||||
return Landing(where, None, False, demo, why=f"模型接入「{found.name}」没有可用的模型")
|
||||
if use.model is not None and model and not serves(found, model):
|
||||
return Landing(where, model, False, demo, why=f"模型接入「{found.name}」不提供{use.model.subject}指定的模型「{model}」")
|
||||
return Landing(where, used, True, demo)
|
||||
|
||||
|
||||
async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> list[ValidationIssue]:
|
||||
"""读接入表、查一遍。图里执行时取模型的几处都没写接入和模型时不读库。"""
|
||||
refs = provider_refs(spec)
|
||||
if not refs:
|
||||
if not provider_refs(spec):
|
||||
return []
|
||||
return issues(refs, await list_providers(session), level=level)
|
||||
return issues(spec, await list_providers(session), level=level)
|
||||
|
||||
|
||||
async def load_providers(session: AsyncSession, spec: GraphSpec) -> list[Any] | None:
|
||||
|
||||
@@ -606,6 +606,19 @@ JUDGE_KEYS = ("provider", "model", "max_claims", "max_cost_usd", "timeout_s", "r
|
||||
#: 证据相矛盾时正式运行怎么判档:degrade 降档(默认)/ withhold 不予出具。探索运行只标注
|
||||
JUDGE_ON_UNSUPPORTED = ("degrade", "withhold")
|
||||
|
||||
#: 「没有指定模型」那条提示的编号。节点(或全图默认)点名了接入、而那个接入本身不存在或已停用时,它和接入那条问题
|
||||
#: 互相矛盾(一条说照常执行、一条说会失败),接口层读了接入表以后按编号去掉它(provider_refs.quiet_unset_model)
|
||||
MODEL_UNSET = "model.unset"
|
||||
|
||||
|
||||
def _named_provider_of(spec: GraphSpec, node: GraphNode) -> str | None:
|
||||
"""节点执行时点名的接入名:节点上没写(None、空串)就取全图默认,同 NodeContext.cfg;只有空白、不是字符串的不算
|
||||
点了名(同 provider_refs._name)。"""
|
||||
value = node.config.get("provider")
|
||||
if value in (None, ""):
|
||||
value = spec.defaults.get("provider")
|
||||
return value.strip() or None if isinstance(value, str) else None
|
||||
|
||||
|
||||
def claims_problem(value: Any) -> str | None:
|
||||
"""claims 的取值有什么问题,没问题返回 None。校验和报告撰写节点执行时报的是同一句话。"""
|
||||
@@ -929,11 +942,15 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
|
||||
|
||||
if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.REPORT):
|
||||
if not (cfg.get("model") or spec.defaults.get("model")):
|
||||
# 点名了接入就用那个接入的默认模型,照实说是哪个:检查器、问题面板里只写「默认模型」,人会以为是第一个
|
||||
# 可用的接入。那个接入本身不存在或已停用时,接口层去掉这一条,只留接入的问题(MODEL_UNSET)
|
||||
named = _named_provider_of(spec, node)
|
||||
result.add(
|
||||
"没有指定模型,将使用默认模型",
|
||||
f"没有指定模型,将使用接入「{named}」的默认模型" if named else "没有指定模型,将使用默认模型",
|
||||
level="warning",
|
||||
node_id=node.id,
|
||||
field="model",
|
||||
code=MODEL_UNSET,
|
||||
)
|
||||
|
||||
# 表达式:用运行时同一个 parse_expression 先解析一遍。以前写错了只有跑到那一步
|
||||
|
||||
@@ -34,13 +34,19 @@ class ModelSpec(BaseModel):
|
||||
class ProviderNotConfigured(RuntimeError):
|
||||
"""模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句,
|
||||
运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。
|
||||
code 给调用方按类别分支:missing(点名的接入不存在)/ disabled / no_model / missing_key / missing_base_url。"""
|
||||
code 给调用方按类别分支:missing(点名的接入不存在)/ disabled / no_model / missing_key / missing_base_url。
|
||||
|
||||
def __init__(self, reason: str, hint: str = "", code: str = "") -> None:
|
||||
super().__init__(f"{reason}。{hint}" if hint else reason)
|
||||
name、model_owner 只有 named_provider 填:点名的名字(去掉两边空白)恰好是某个启用接入的模型时,model_owner 是
|
||||
那个接入的名称。str(e) 末尾补上 model_note 那一句;画布、设置页、助手各自另拼,说清在哪一栏改(同 hint)。"""
|
||||
|
||||
def __init__(self, reason: str, hint: str = "", code: str = "", *, name: str = "", model_owner: str = "") -> None:
|
||||
note = model_note(name, model_owner) if model_owner else ""
|
||||
super().__init__("。".join(part for part in (reason, hint, note) if part))
|
||||
self.reason = reason
|
||||
self.hint = hint
|
||||
self.code = code
|
||||
self.name = name
|
||||
self.model_owner = model_owner
|
||||
|
||||
|
||||
async def list_providers(session: AsyncSession) -> list[Provider]:
|
||||
@@ -48,24 +54,54 @@ async def list_providers(session: AsyncSession) -> list[Provider]:
|
||||
return list(rows.scalars())
|
||||
|
||||
|
||||
#: 点名的接入不存在、已停用时,提示的前半句:在设置里能做什么。后半句(去哪里换一个接入)看场合:运行时报错、
|
||||
#: 设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写(engine/provider_refs.py)
|
||||
NAMED_FIX = {"missing": "请在「设置 → 模型接入」中核对名称", "disabled": "请在「设置 → 模型接入」中启用它"}
|
||||
#: 点名的接入不存在、已停用时,在「设置 → 模型接入」里能做什么。NAMED_FIX 是整句的前半句;后半句(去哪里换一个
|
||||
#: 接入)看场合:运行时报错、设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写
|
||||
#:(engine/provider_refs.py);助手栏那一格下面先说「在上方重新选择」,再说去设置里做什么(assistant_unavailable)
|
||||
NAMED_ACTION = {"missing": "核对名称", "disabled": "启用它"}
|
||||
NAMED_FIX = {code: f"请在「设置 → 模型接入」中{act}" for code, act in NAMED_ACTION.items()}
|
||||
|
||||
|
||||
def model_fact(name: str, owner: str) -> str:
|
||||
"""点名的接入名恰好是某个启用接入的模型:先照实说这是哪个接入的模型,怎么用它各处另说(model_note……)。"""
|
||||
return f"「{name}」是接入「{owner}」的模型"
|
||||
|
||||
|
||||
def model_note(name: str, owner: str, model_field: str = "模型一栏", provider_field: str = "接入",
|
||||
how: str = "选择它") -> str:
|
||||
"""点名的接入名恰好是某个启用接入的模型时补的那一句:多半是把模型名填进了接入那一栏。开发数据里真有一条停用的
|
||||
接入,名字就是一个模型 id,同名模型由另一条启用的接入提供——照「启用它」的提示去做,启用的是那条旧接入(它的
|
||||
模型清单里根本没有这个模型),用户要的是这个模型。model_field、provider_field 是这一处模型、接入两栏的叫法,
|
||||
how 是在模型那一栏里怎么做:设置页、核查节点的模型是输入框,写「填写」;运行时报错不分节点类型,多数节点的模型
|
||||
是下拉框,写「模型一栏」「接入」「选择它」。画布、发布前检查按这一处在检查器里有哪几栏另写(provider_refs)。"""
|
||||
return (f"{model_fact(name, owner)}:如需使用该模型,请在{model_field}中{how},"
|
||||
f"{provider_field}留空或选择「{owner}」")
|
||||
|
||||
|
||||
def _model_owner(providers: list[Provider], name: str) -> Provider | None:
|
||||
"""提供这个模型的启用接入,认法同 provider_for_model(先认模型列表、再认默认模型)。只看启用的:停用接入的模型
|
||||
补了这一句,照着做也一样调不了。"""
|
||||
enabled = [p for p in providers if p.enabled]
|
||||
return next((p for p in enabled if any((m or {}).get("id") == name for m in (p.models or []))), None) \
|
||||
or next((p for p in enabled if p.default_model == name), None)
|
||||
|
||||
|
||||
def named_provider(providers: list[Provider], ref: Any) -> Provider:
|
||||
"""按名称或 id 找显式点名的接入(providers 是 list_providers 的结果)。找不到、找到的已停用都抛
|
||||
ProviderNotConfigured:点名了就只用它,不换别家。运行时取模型、设置和画布校验都按这一处认,说法一致。
|
||||
名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。"""
|
||||
名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。点名的名字恰好是某个启用接入
|
||||
的模型时,异常带上 model_owner,报错补一句这是哪个接入的模型、怎么用它(model_note)。"""
|
||||
key = ref.strip() if isinstance(ref, str) else str(ref)
|
||||
found = next((p for p in providers if key in (p.id, p.name)), None) \
|
||||
or next((p for p in providers if (p.name or "").strip() == key), None)
|
||||
if found is not None and found.enabled:
|
||||
return found
|
||||
owner = _model_owner(providers, key)
|
||||
trap = {"name": key, "model_owner": owner.name} if owner is not None else {}
|
||||
if found is None:
|
||||
raise ProviderNotConfigured(f"模型接入「{key}」不存在", f"{NAMED_FIX['missing']},或换一个接入", code="missing")
|
||||
if not found.enabled:
|
||||
raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", f"{NAMED_FIX['disabled']},或换一个接入",
|
||||
code="disabled")
|
||||
return found
|
||||
raise ProviderNotConfigured(f"模型接入「{key}」不存在", f"{NAMED_FIX['missing']},或换一个接入", code="missing",
|
||||
**trap)
|
||||
raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", f"{NAMED_FIX['disabled']},或换一个接入",
|
||||
code="disabled", **trap)
|
||||
|
||||
|
||||
def serves(provider: Any, model: str) -> bool:
|
||||
@@ -100,17 +136,36 @@ def fallback_provider(providers: list[Provider]) -> Provider | None:
|
||||
return (real or enabled or [None])[0]
|
||||
|
||||
|
||||
def assistant_unavailable(e: BaseException) -> str:
|
||||
#: 助手的模型在界面上只有一处能改:「编排」页助手栏的「助手使用的模型」(设置页里没有这一项)。选「跟随默认」或一个
|
||||
#: 模型都会清掉存着的接入。画布、问数据页、表格导入、数据目录起草报的是同一句,所以写明是哪一页
|
||||
ASSISTANT_MODEL_FIELD = "助手使用的模型"
|
||||
ASSISTANT_PICKER = f"「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中"
|
||||
ASSISTANT_REPICK = f"或在{ASSISTANT_PICKER}重新选择"
|
||||
|
||||
|
||||
def assistant_unavailable(e: BaseException, *, here: bool = False) -> str:
|
||||
"""助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。
|
||||
|
||||
点名的接入不存在、已停用(按模型名找到的接入已停用也算)照实说是哪个接入、在设置里能做什么,或者给助手换一个
|
||||
模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。其余(缺 key、没有模型……)是接入本身没配完。"""
|
||||
点名的接入不存在、已停用(按模型名找到的接入已停用也算)照实说是哪个接入、在设置里能做什么,或者到助手栏重新
|
||||
选一个模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。点名的名字恰好是启用接入的模型时并成一句:先说
|
||||
在设置里能做什么,再说这是哪个接入的模型、也可以在助手栏直接选它(不再另起一句把「助手使用的模型」说两遍)。
|
||||
其余(缺 key、没有模型……)是接入本身没配完。
|
||||
|
||||
here:说给助手栏「助手使用的模型」那一格下面(GET /api/copilot/model 的 panel_error)。人就在这个下拉框旁边,
|
||||
去向写「上方」,不再把人指回「编排」页助手栏。"""
|
||||
from app.core.errors import not_configured
|
||||
|
||||
code = getattr(e, "code", "")
|
||||
if code in NAMED_FIX:
|
||||
return f"助手使用的{getattr(e, 'reason', '') or e}。{NAMED_FIX[code]},或为助手换一个模型"
|
||||
return f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
|
||||
said = f"助手使用的{getattr(e, 'reason', '') or e}。"
|
||||
if owner := getattr(e, "model_owner", ""):
|
||||
where = "上方" if here else ASSISTANT_PICKER
|
||||
return f"{said}{NAMED_FIX[code]};{model_fact(getattr(e, 'name', ''), owner)},也可在{where}直接选择它"
|
||||
if here:
|
||||
return f"{said}请在上方重新选择,或在「设置 → 模型接入」中{NAMED_ACTION[code]}"
|
||||
return f"{said}{NAMED_FIX[code]},{ASSISTANT_REPICK}"
|
||||
return (f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
|
||||
+ (",或在上方重新选择" if here else ""))
|
||||
|
||||
|
||||
async def resolve_provider(
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
- 画布校验:节点点名的接入不存在或已停用给警告,说清哪个节点、哪个接入;发布前检查、发布、自动修复的复核给错误,
|
||||
挡住发布。只查执行时真会去取模型的那几处(分支「让模型分类」、校验「让模型修复」且允许重试、检索「让模型重排」
|
||||
时才算;核查节点自己的开关关着不算);报告没写裁判配置时查全图默认里的那一份;
|
||||
- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处说「在发布前检查中改用」;
|
||||
- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处在发布前检查给得出修复时
|
||||
说「在发布前检查中改用」(给不出时不说,见 test_provider_ref_wording);
|
||||
- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入(修完以后执行时落到哪里、只写了模型而
|
||||
提供它的接入已停用的那一处,见 test_provider_ref_landing)。
|
||||
|
||||
@@ -72,18 +73,25 @@ FIELDS = [
|
||||
("verify", "provider", "核查模型 · 接入"),
|
||||
("judge", "provider", "裁判模型 · 接入"),
|
||||
("run", "tool_gate_provider", "门控模型 · 接入"),
|
||||
("copilot", "provider", "助手模型 · 接入"),
|
||||
("copilot", "provider", "助手使用的模型"),
|
||||
]
|
||||
|
||||
|
||||
def refused(group: str, label: str, reason: str, fix: str) -> str:
|
||||
"""保存时拒收的说法。助手那一项只有「编排」页助手栏一个下拉框(只选模型),没有「换一个接入」的地方:和助手取不到
|
||||
模型时报的同一句(factory.assistant_unavailable,见 test_provider_ref_wording)。"""
|
||||
if group == "copilot":
|
||||
return f"助手使用的{reason}。{fix},或在「编排」页助手栏的「助手使用的模型」中重新选择"
|
||||
return f"「{label}」指定的{reason}。{fix},或换一个接入"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label", FIELDS)
|
||||
async def test_saving_a_provider_that_does_not_exist_is_refused_with_the_field(client, group, key, label):
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: GONE}}})
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
assert body["code"] == "settings_provider_invalid" and body["field"] == f"{group}.{key}"
|
||||
assert body["detail"].startswith(f"「{label}」填写的模型接入「{GONE}」不存在。"), body["detail"]
|
||||
assert "请在「设置 → 模型接入」中核对名称,或换一个接入" in body["detail"] and "留空表示" in body["detail"]
|
||||
assert body["detail"] == refused(group, label, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称")
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
@@ -93,8 +101,7 @@ async def test_saving_a_disabled_provider_is_refused_too(client, group, key, lab
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
assert body["field"] == f"{group}.{key}"
|
||||
assert body["detail"].startswith(f"「{label}」填写的模型接入「{OFF}」已停用。"), body["detail"]
|
||||
assert "请在「设置 → 模型接入」中启用它,或换一个接入" in body["detail"]
|
||||
assert body["detail"] == refused(group, label, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它")
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
@@ -207,8 +214,9 @@ async def test_every_place_a_graph_names_a_provider_is_checked(client):
|
||||
f"「协作」(多 Agent 协作)的成员「分析师」指定的模型接入「{GONE}」不存在")
|
||||
assert got[("write", "judge.provider")]["message"].startswith(
|
||||
f"「写周报」(报告撰写)的结论句裁判指定的模型接入「{GONE}」不存在")
|
||||
assert got[("dual", "provider")]["message"].startswith(f"「复核销售额」(双盲复算)的盲算模型指定的模型接入「{GONE}」")
|
||||
assert got[("chal", "provider")]["message"].startswith(f"「审稿」(反方审稿)的反方模型指定的模型接入「{OFF}」已停用")
|
||||
assert got[("dual", "provider")]["message"].startswith(
|
||||
f"「复核销售额」(双盲复算)的「盲算模型 · 接入」指定的模型接入「{GONE}」")
|
||||
assert got[("chal", "provider")]["message"].startswith(f"「审稿」(反方审稿)的「反方模型 · 接入」指定的模型接入「{OFF}」已停用")
|
||||
assert got[(None, "defaults.provider")]["message"].startswith(f"工作流默认设置指定的模型接入「{GONE}」不存在")
|
||||
|
||||
|
||||
@@ -281,14 +289,15 @@ async def test_publish_check_reads_the_unsaved_canvas_and_autofix_keeps_the_prob
|
||||
|
||||
|
||||
async def test_handing_only_a_provider_problem_to_the_assistant_says_it_cannot_help(client):
|
||||
"""修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改。"""
|
||||
from app.api.workflows import PROVIDER_LEFT
|
||||
"""修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改(这一处
|
||||
有修复,连同各条问题的修复一起说;没有修复时的说法见 test_provider_ref_wording)。"""
|
||||
from app.api.workflows import PROVIDER_LEFT, PROVIDER_LEFT_FIX
|
||||
|
||||
wf = await _workflow(writer(None))
|
||||
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": writer(GONE),
|
||||
"assist": True})
|
||||
out = r.json()
|
||||
assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
|
||||
assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT + PROVIDER_LEFT_FIX, "questions": []}
|
||||
assert out["ok"] is False and [i["code"] for i in provider_issues({"issues": out["remaining"]})] == [
|
||||
"provider.missing"]
|
||||
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": writer(LIVE),
|
||||
@@ -303,15 +312,14 @@ async def test_publishing_a_clean_graph_is_unaffected(client):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label", [("run", "tool_gate_provider", "门控模型 · 接入"),
|
||||
("copilot", "provider", "助手模型 · 接入")])
|
||||
("copilot", "provider", "助手使用的模型")])
|
||||
async def test_a_provider_name_that_is_not_text_is_refused_with_the_field(client, group, key, label):
|
||||
"""门控、助手两组没有自己的格式检查:名字写成数字一类以前照样存下,查助手当前用哪个模型时就出错。"""
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: 123}}})
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
assert body["code"] == "settings_provider_invalid" and body["field"] == f"{group}.{key}"
|
||||
assert body["detail"].startswith(f"「{label}」需要填写模型接入的名称,留空表示"), body["detail"]
|
||||
assert body["detail"].endswith("当前为「123」")
|
||||
assert body["detail"] == f"「{label}」需要填写模型接入的名称,当前为「123」", body["detail"]
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
|
||||
@@ -103,8 +103,11 @@ def test_a_node_naming_only_a_model_of_a_disabled_provider_is_stopped_before_pub
|
||||
|
||||
|
||||
def test_the_problem_is_recorded_once_where_the_model_is_written():
|
||||
"""全图默认里的模型几个节点共用时只记一条;协作成员、全图默认里的模型在检查器里改不了,提示指到发布前检查。
|
||||
节点点名了启用的接入时,跟随它的成员、取全图默认模型的节点以点名的为准,不提。"""
|
||||
"""全图默认里的模型几个节点共用时只记一条;协作成员、全图默认里的模型在检查器里改不了,发布前检查给得出修复时
|
||||
提示指到那里。节点点名了启用的接入时,跟随它的成员、取全图默认模型的节点以点名的为准,不提。
|
||||
|
||||
「协作二」的成员自己写着丙家的模型,换哪个接入、去掉这个模型(又跟随全图默认的丙家模型)都落不稳,没有修复:
|
||||
提示说去设置里启用,不指到发布前检查,再说卡在哪里(去掉以后落到全图默认那个同样停用接入的模型)。"""
|
||||
graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"),
|
||||
node("team", "supervisor", "协作", provider="甲接入", goal="分析",
|
||||
agents=[{"name": "分析师", "model": "丙一号", "system": "分析"}]),
|
||||
@@ -112,10 +115,11 @@ def test_the_problem_is_recorded_once_where_the_model_is_written():
|
||||
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]),
|
||||
defaults={"model": "丙一号"})
|
||||
got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)]
|
||||
tail = "请在「设置 → 模型接入」中启用它,或在「发布前检查」中改用其他接入"
|
||||
enable = "请在「设置 → 模型接入」中启用它"
|
||||
assert got == [
|
||||
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{tail}"),
|
||||
(None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{tail}"),
|
||||
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}。"
|
||||
f"无法改用其他接入:工作流默认设置指定的模型「丙一号」{STOPPED}"),
|
||||
(None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{enable},或在「发布前检查」中改用其他接入"),
|
||||
]
|
||||
|
||||
|
||||
@@ -127,7 +131,7 @@ def test_verify_nodes_and_the_judge_naming_only_a_model_of_a_disabled_provider_a
|
||||
node("chal", "challenge", "审稿", report="write", model="丙一号"))
|
||||
got = {(i["node_id"], i["field"]): i["message"] for i in gate(graph)}
|
||||
assert set(got) == {("write", "judge.model"), ("dual", "model"), ("chal", "model")}
|
||||
assert got[("dual", "model")] == (f"「复核销售额」(双盲复算)的盲算模型指定的模型「丙一号」{STOPPED}。"
|
||||
assert got[("dual", "model")] == (f"「复核销售额」(双盲复算)的「盲算模型」指定的模型「丙一号」{STOPPED}。"
|
||||
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
|
||||
assert got[("write", "judge.model")].startswith(f"「写周报」(报告撰写)的结论句裁判指定的模型「丙一号」{STOPPED}")
|
||||
|
||||
|
||||
@@ -430,9 +430,11 @@ async def test_judging_on_demand_with_a_broken_judge_provider_says_why(monkeypat
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者给助手换一个模型(不再说「尚未配置完成」)
|
||||
SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,或为助手换一个模型",
|
||||
DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,或为助手换一个模型"}
|
||||
#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者到「编排」页助手栏重新选一个模型(设置页里没有助手的
|
||||
#: 接入这一项,能改的只有那个下拉框;不再说「尚未配置完成」)
|
||||
REPICK = "或在「编排」页助手栏的「助手使用的模型」中重新选择"
|
||||
SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,{REPICK}",
|
||||
DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,{REPICK}"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref", [MISSING, DISABLED])
|
||||
@@ -591,8 +593,9 @@ async def test_after_the_publish_fix_stops_naming_a_disabled_provider_the_formal
|
||||
[issue] = [i for i in r.json()["issues"] if i.get("code") == "provider.disabled"]
|
||||
[fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]]
|
||||
[clear] = [o for o in fix["options"] if o["value"] == ""]
|
||||
# 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入
|
||||
assert clear["hint"] == f"去掉这一处指定的接入和模型「停用演示模型」,执行时改用第一个可用的模型接入「{WRITER_PROVIDER}」"
|
||||
# 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入,是演示类的,说明里照实写
|
||||
assert clear["hint"] == (f"去掉这一处指定的接入和模型「停用演示模型」,执行时改用第一个可用的模型接入「{WRITER_PROVIDER}」"
|
||||
"(演示用,不调用真实模型)")
|
||||
r = await client.post(f"/api/workflows/{wid}/autofix", json={"level": "published", "apply": [fix["id"]],
|
||||
"choices": {fix["id"]: ""}})
|
||||
fixed = r.json()
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""点名的模型接入(K1)收尾:说法与修复候选。
|
||||
|
||||
K1 让点名的接入不存在或已停用时照实报错、发布前挡住并给修复。复核留下的几处:
|
||||
- 核查节点那句「……的盲算模型指定的模型接入」:指定接入的是「盲算模型 · 接入」这一栏,不是模型本身;
|
||||
- 修复规划:没有候选能落稳时修复为空,问题却仍说「或在「发布前检查」中改用其他接入」;按(编号、节点、字段)认不出修复
|
||||
时退到同节点、同编号别处的修复,接口里的 fix 指错;改动波及、但本来就因自己点名的接入坏着的那一处也算落不稳,两处
|
||||
互相牵连时谁都给不出修复;
|
||||
- 「不指定接入」落到演示类接入时,说明不提「演示用、不调用真实模型」——K1 要防的就是悄悄换成演示模型;
|
||||
- 点名的接入名恰好是某个启用接入的模型:提示只说去启用那条旧接入,用户多半想要的是这个模型;
|
||||
- 保存设置 422 的说法:下拉框写成「填写的」,留空那半句和设置页的叫法对不上,助手那一项叫法界面上没有;
|
||||
- 「交给助手」后只剩接入问题时的说明:一句两个冒号、「核对」没有宾语;
|
||||
- 同一节点同时提示「没有指定模型,将使用默认模型」和「指定的模型接入不存在」,互相矛盾;
|
||||
- 助手的模型取不到时,提示指到真正能改它的地方(「编排」页助手栏的「助手使用的模型」)。
|
||||
|
||||
第 1 轮复核又留下的几处:
|
||||
- 「不指定接入」交给设置(核查、裁判)时,落到演示类接入也没说;
|
||||
- 「接入名恰好是模型名」那句叫人「接入留空」,多数节点的检查器里没有接入这一栏,模型又是下拉框却写「填写」;
|
||||
- 给不出修复时只剩「核对名称」,没说真正卡住修复的是哪一处;
|
||||
- 「演示用,不调用真实模型」只是淡色小字:候选另带 demo 标记,界面上加醒目的标签;
|
||||
- 画布校验为每处坏掉的接入把每个候选都在整张图上算一遍,几十处同时坏掉时开销随规模平方增长;
|
||||
- 助手那一项的 422 说法和助手报错、助手栏对不上;助手栏下拉下面的原因把人指回这个下拉本身;
|
||||
- 裁判的格式检查仍写「留空表示不指定」;「交给助手」后只剩接入问题时的说明措辞。
|
||||
|
||||
纯函数的用例用虚构的接入表,不读库;接口的用例建自己的几条接入、用完删掉。接入名、节点名、模型名都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Provider, Setting, Workflow, WorkflowVersion
|
||||
from app.engine import autofix, provider_refs
|
||||
from app.engine.autofix import apply_fixes, check
|
||||
from app.engine.governance import publish_issues
|
||||
from app.engine.provider_refs import issues as provider_ref_issues
|
||||
from app.engine.schema import GraphSpec
|
||||
from app.main import app
|
||||
from app.providers.factory import ProviderNotConfigured, assistant_unavailable, named_provider
|
||||
|
||||
|
||||
def provider(name: str, *models: str, enabled: bool = True, kind: str = "openai_compatible") -> SimpleNamespace:
|
||||
return SimpleNamespace(id=f"pv-{name}", name=name, kind=kind, enabled=enabled,
|
||||
models=[{"id": m} for m in models], default_model=models[0] if models else None)
|
||||
|
||||
|
||||
JIA = provider("甲接入", "甲一号", "甲二号")
|
||||
YI = provider("乙接入", "乙一号")
|
||||
BING = provider("丙接入", "丙一号", enabled=False)
|
||||
#: 停用的接入恰好叫「乙一号」——启用的乙接入提供同名的模型(开发数据里的原样:模型名被建成了一条接入)
|
||||
TRAP = provider("乙一号", "旧一号", enabled=False)
|
||||
TABLE = [JIA, YI, BING]
|
||||
DEMO = provider("演示接入", "演示一号", kind="mock")
|
||||
DEMO_NOTE = "(演示用,不调用真实模型)"
|
||||
MISSING_FIX, DISABLED_FIX = "请在「设置 → 模型接入」中核对名称", "请在「设置 → 模型接入」中启用它"
|
||||
IN_CHECK = ",或在「发布前检查」中改用其他接入"
|
||||
NO_SWITCH = "。无法改用其他接入:"
|
||||
|
||||
|
||||
def node(nid, ntype, label=None, **config):
|
||||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
|
||||
|
||||
|
||||
def flow(*middle, defaults=None) -> dict:
|
||||
nodes = [node("start", "input"), *middle,
|
||||
node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}])]
|
||||
graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||||
if defaults:
|
||||
graph["defaults"] = defaults
|
||||
return graph
|
||||
|
||||
|
||||
def provider_issues(issues) -> list[dict]:
|
||||
return [i for i in map(autofix._as_dict, issues) if str(i.get("code") or "").startswith("provider.")]
|
||||
|
||||
|
||||
def gate(graph: dict, table=TABLE) -> dict[tuple, dict]:
|
||||
found = provider_issues(publish_issues(GraphSpec.model_validate(graph), level="published", providers=table))
|
||||
return {(i["node_id"], i["field"]): i for i in found}
|
||||
|
||||
|
||||
def checked(graph: dict, table=TABLE) -> dict:
|
||||
return check(GraphSpec.model_validate(graph), level="published", providers=table)
|
||||
|
||||
|
||||
def fix_of(graph: dict, fid: str, table=TABLE) -> dict | None:
|
||||
return next((f for f in checked(graph, table)["fixes"] if f["id"] == fid), None)
|
||||
|
||||
|
||||
def hints(fix: dict) -> dict:
|
||||
return {o["value"]: o.get("hint") for o in fix["options"]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核查节点:指定接入的是「盲算模型 · 接入」这一栏
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_verify_node_names_the_field_that_points_at_the_provider():
|
||||
"""双盲复算、反方审稿:问题里写检查器上那一栏的叫法(「盲算模型 · 接入」「反方模型」),不说「盲算模型指定的接入」。
|
||||
修复的标题仍按角色说(「选择……的盲算模型改用的模型接入」),读得通。"""
|
||||
graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入"),
|
||||
node("dual", "dual_check", "复核销售额", provider="戊接入"),
|
||||
node("chal", "challenge", "审稿", report="write", provider="丙接入"),
|
||||
node("chal2", "challenge", "复审", report="write", model="丙一号"))
|
||||
got = gate(graph)
|
||||
assert got[("dual", "provider")]["message"] == (
|
||||
f"「复核销售额」(双盲复算)的「盲算模型 · 接入」指定的模型接入「戊接入」不存在。{MISSING_FIX},或在节点中换一个接入")
|
||||
assert got[("chal", "provider")]["message"] == (
|
||||
f"「审稿」(反方审稿)的「反方模型 · 接入」指定的模型接入「丙接入」已停用。{DISABLED_FIX},或在节点中换一个接入")
|
||||
assert got[("chal2", "model")]["message"] == (
|
||||
"「复审」(反方审稿)的「反方模型」指定的模型「丙一号」所属的模型接入「丙接入」已停用。"
|
||||
f"{DISABLED_FIX},或在节点中换一个模型")
|
||||
assert fix_of(graph, "provider.missing:dual:provider")["label"] == "选择「复核销售额」(双盲复算)的盲算模型改用的模型接入"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 没有修复时:提示不指到发布前检查,也不借别处的修复
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_without_a_fix_the_hint_does_not_send_people_to_the_publish_check():
|
||||
"""协作节点点名的接入不存在,成员只写了停用的丙家的模型、跟随节点的接入:去掉或换掉节点的接入,成员都落不稳,
|
||||
修复为空。检查器里又没有这一栏,提示不说「或在「发布前检查」中改用其他接入」,说去设置里核对,再说卡在哪里——
|
||||
人去设置里找「戊接入」找不到,真正挡住修复的是成员的模型所属的接入停用了。画布校验的警告是同一句。成员换成启用的
|
||||
接入提供的模型,修复就有了,提示照常指到发布前检查。"""
|
||||
graph = flow(node("team", "supervisor", "协作", provider="戊接入", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]))
|
||||
found = checked(graph)
|
||||
[issue] = provider_issues(found["issues"])
|
||||
assert (issue["node_id"], issue["field"], issue["fix"]) == ("team", "provider", None)
|
||||
assert issue["message"] == (f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}{NO_SWITCH}"
|
||||
"「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用")
|
||||
[warn] = provider_ref_issues(GraphSpec.model_validate(graph), TABLE, level="warning")
|
||||
assert warn.message == issue["message"]
|
||||
|
||||
graph["nodes"][1]["data"]["config"]["agents"][0]["model"] = "甲二号"
|
||||
[issue] = provider_issues(checked(graph)["issues"])
|
||||
assert issue["fix"] == "provider.missing:team:provider"
|
||||
assert issue["message"] == f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}{IN_CHECK}"
|
||||
|
||||
|
||||
def test_without_any_enabled_provider_the_hint_says_so():
|
||||
"""一个启用的接入都没有:去掉接入以后执行时只能换成内置演示模型,不算修好,也没有别的接入可换。提示照实说卡在
|
||||
这里,不只说去设置里启用这个接入。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"))
|
||||
[issue] = provider_issues(checked(graph, [BING])["issues"])
|
||||
assert issue["fix"] is None
|
||||
assert issue["message"] == f"「提问」(模型调用)指定的模型接入「丙接入」已停用。{DISABLED_FIX}{NO_SWITCH}没有启用的模型接入"
|
||||
|
||||
|
||||
def test_an_issue_without_its_own_fix_does_not_borrow_another_places_fix():
|
||||
"""全图默认点名停用的丙,节点「提问」只写了丙家的模型(跟随全图默认的接入),节点「复述」自己点名丙:复述那一处有
|
||||
修复(改用甲、乙),全图默认那一处没有(换掉或去掉,提问都落不稳)。全图默认那条不能认到复述的修复上去。"""
|
||||
graph = flow(node("a", "llm", "提问", model="丙一号", prompt="总结"),
|
||||
node("b", "llm", "复述", provider="丙接入", model="乙一号", prompt="复述"),
|
||||
defaults={"provider": "丙接入"})
|
||||
found = checked(graph)
|
||||
got = {(i["node_id"], i["field"]): i for i in provider_issues(found["issues"])}
|
||||
assert set(got) == {("b", "provider"), (None, "defaults.provider")}
|
||||
assert got[("b", "provider")]["fix"] == "provider.disabled:b:provider"
|
||||
assert got[(None, "defaults.provider")]["fix"] is None
|
||||
# 卡在「提问」:它只写了丙家的模型,去掉全图默认的接入以后按模型名找,找到的还是停用的丙
|
||||
assert got[(None, "defaults.provider")]["message"] == (
|
||||
f"工作流默认设置指定的模型接入「丙接入」已停用。{DISABLED_FIX}{NO_SWITCH}"
|
||||
"「提问」(模型调用)指定的模型「丙一号」所属的模型接入「丙接入」已停用")
|
||||
assert [f["id"] for f in found["fixes"]] == ["provider.disabled:b:provider"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 很多处同时坏掉:每试一个候选只重算被改的那个节点
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_checking_many_broken_places_does_not_redo_the_whole_graph_per_candidate(monkeypatch):
|
||||
"""几十个节点都点名同一个停用的接入(导入别处导出的工作流时常见):画布校验要给每一处说给不给得出修复,发布前检查
|
||||
再给每一处列候选。改一个节点的配置只影响它自己取模型的那几处,每试一个候选只重算这个节点,开销随节点数线性增长;
|
||||
以前每试一个候选都把整张图重算一遍,随节点数平方增长(60 个节点时画布每改一下要多算约 0.35 秒)。数的是一共算了
|
||||
多少处取模型的地方(provider_refs.Use),节点数翻 4 倍时不能超过约 4 倍。"""
|
||||
made = {"n": 0}
|
||||
|
||||
class Counted(provider_refs.Use):
|
||||
def __init__(self, *args, **kwargs):
|
||||
made["n"] += 1
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(provider_refs, "Use", Counted)
|
||||
table = [provider(f"接入{i}", f"模型{i}") for i in range(6)] + [BING]
|
||||
|
||||
def cost(n: int) -> tuple[int, int]:
|
||||
spec = GraphSpec.model_validate(flow(*[node(f"n{i}", "llm", f"节点{i}", provider="丙接入", prompt="总结")
|
||||
for i in range(n)]))
|
||||
made["n"] = 0
|
||||
assert len(provider_ref_issues(spec, table, level="warning")) == n
|
||||
lint = made["n"]
|
||||
made["n"] = 0
|
||||
assert len(check(spec, level="published", providers=table)["fixes"]) == n
|
||||
return lint, made["n"]
|
||||
|
||||
small, big = cost(8), cost(32)
|
||||
assert big[0] <= 5 * small[0] and big[1] <= 5 * small[1], (small, big)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 本来就因自己点名的接入坏着的那一处,不否决别处的修复
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_place_broken_by_its_own_provider_does_not_veto_a_fix_elsewhere():
|
||||
"""「提问」点名不存在的戊接入、模型取全图默认的丙一号(只有停用的丙提供),「复述」跟随全图默认。去掉全图默认的模型
|
||||
以后,提问照样因为戊接入不存在而失败——这不是这次改动造成的,不拿它否决全图默认的修复;否则两条都没有修复,
|
||||
规划器自己卡住。先修全图默认,提问的修复随之出现(不指定接入、改用甲、改用乙)。"""
|
||||
graph = flow(node("a", "llm", "提问", provider="戊接入", prompt="总结"),
|
||||
node("b", "llm", "复述", prompt="复述"),
|
||||
defaults={"model": "丙一号"})
|
||||
found = checked(graph)
|
||||
got = {(i["node_id"], i["field"]): i for i in provider_issues(found["issues"])}
|
||||
assert set(got) == {("a", "provider"), (None, "defaults.model")}
|
||||
fid = "provider.disabled:graph:defaults.model"
|
||||
assert got[(None, "defaults.model")]["fix"] == fid
|
||||
assert got[("a", "provider")]["fix"] is None
|
||||
assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的模型,执行时改用第一个可用的模型接入「甲接入」"
|
||||
|
||||
out = apply_fixes(graph, [fid], {fid: ""}, level="published", providers=TABLE)
|
||||
assert out["applied"] == [fid] and out["rejected"] == [], out["rejected"]
|
||||
assert "model" not in (out["graph"].get("defaults") or {})
|
||||
[left] = provider_issues(out["remaining"])
|
||||
assert (left["node_id"], left["field"], left["fix"]) == ("a", "provider", "provider.missing:a:provider")
|
||||
[fix] = [f for f in out["fixes"] if f["id"] == left["fix"]]
|
||||
assert [o["value"] for o in fix["options"]] == ["", "甲接入", "乙接入"]
|
||||
|
||||
|
||||
def test_the_place_being_fixed_is_never_waved_through():
|
||||
"""被修的那一处自己换了接入引用:去掉节点的戊接入以后落到全图默认的戊接入,原因一字不差也不算修好。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="戊接入", prompt="总结"), defaults={"provider": "戊接入"})
|
||||
found = checked(graph)
|
||||
fid = "provider.missing:ask:provider"
|
||||
[fix] = [f for f in found["fixes"] if f["id"] == fid]
|
||||
assert "" not in hints(fix)
|
||||
out = apply_fixes(graph, [fid], {fid: ""}, level="published", providers=TABLE)
|
||||
assert out["applied"] == [], out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 落到演示类接入时照实说
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stop_naming_a_provider_that_lands_on_a_demo_provider_says_so():
|
||||
"""启用的只有演示类接入:「不指定接入」落到它,说明里和「改用演示接入」那一项一样写明演示用、不调用真实模型。"""
|
||||
table = [BING, DEMO]
|
||||
lone = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
|
||||
got = hints(fix_of(lone, "provider.disabled:ask:provider", table))
|
||||
assert got[""] == f"去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「演示接入」{DEMO_NOTE}"
|
||||
assert got["演示接入"] == "不提供模型「丙一号」,将改用它的默认模型「演示一号」;演示用的模型接入,不调用真实模型"
|
||||
# 有启用的真实接入:去掉以后落到真实接入,不带这句
|
||||
assert hints(fix_of(lone, "provider.disabled:ask:provider", [JIA, BING, DEMO]))[""] == (
|
||||
"去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「甲接入」")
|
||||
|
||||
# 跟随节点点名的演示接入、按模型名落到演示接入,也一样
|
||||
graph = flow(node("team", "supervisor", "协作", provider="演示接入", goal="分析",
|
||||
agents=[{"name": "分析师", "provider": "丙接入", "system": "分析"}]),
|
||||
node("ask", "llm", "提问", provider="丙接入", model="演示一号", prompt="总结"))
|
||||
assert hints(fix_of(graph, "provider.disabled:team:agents[0].provider", table))[""] == (
|
||||
f"去掉这一处指定的接入,执行时改用「协作」(多 Agent 协作)指定的接入「演示接入」{DEMO_NOTE}")
|
||||
assert hints(fix_of(graph, "provider.disabled:ask:provider", table))[""] == (
|
||||
f"去掉这一处指定的接入,执行时改用提供模型「演示一号」的接入「演示接入」{DEMO_NOTE}")
|
||||
|
||||
|
||||
def test_stop_naming_a_provider_handed_to_settings_says_when_it_lands_on_a_demo_model():
|
||||
"""核查节点、报告自己的结论句裁判去掉接入以后交给设置(核查模型、裁判模型)。启用的接入全是演示类时,设置这一级
|
||||
不管写的是什么,执行时落到的都是演示模型(取值链最后是第一个可用的接入):说明里和模型调用那一处一样写明演示用、
|
||||
不调用真实模型,候选也带 demo 标记。有启用的真实接入时不带;一个启用的接入都没有时,设置这一级只能换成内置演示
|
||||
模型(或者调不了),不算修好、不给这一项。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"),
|
||||
node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": "丙接入"}),
|
||||
node("dual", "dual_check", "复核销售额", provider="丙接入"))
|
||||
demo_only = [BING, DEMO]
|
||||
got = {fid: hints(fix_of(graph, fid, demo_only)) for fid in (
|
||||
"provider.disabled:ask:provider", "provider.disabled:write:judge.provider", "provider.disabled:dual:provider")}
|
||||
assert got["provider.disabled:ask:provider"][""] == (
|
||||
f"去掉这一处指定的接入,执行时改用第一个可用的模型接入「演示接入」{DEMO_NOTE}")
|
||||
assert got["provider.disabled:write:judge.provider"][""] == f"去掉这一处指定的接入,执行时改用设置中的裁判模型{DEMO_NOTE}"
|
||||
assert got["provider.disabled:dual:provider"][""] == f"去掉这一处指定的接入,执行时改用设置中的核查模型{DEMO_NOTE}"
|
||||
clear = next(o for o in fix_of(graph, "provider.disabled:dual:provider", demo_only)["options"] if o["value"] == "")
|
||||
assert clear.get("demo") is True
|
||||
|
||||
real = [JIA, BING, DEMO]
|
||||
assert hints(fix_of(graph, "provider.disabled:dual:provider", real))[""] == "去掉这一处指定的接入,执行时改用设置中的核查模型"
|
||||
clear = next(o for o in fix_of(graph, "provider.disabled:dual:provider", real)["options"] if o["value"] == "")
|
||||
assert "demo" not in clear
|
||||
|
||||
assert fix_of(graph, "provider.disabled:dual:provider", [BING]) is None
|
||||
assert fix_of(graph, "provider.disabled:write:judge.provider", [BING]) is None
|
||||
|
||||
|
||||
def test_candidates_that_land_on_a_demo_provider_carry_a_mark():
|
||||
"""落到演示类接入的候选带 demo 标记(发布弹窗在选项名旁加醒目的标签),落到真实接入的不带。"""
|
||||
table = [JIA, BING, DEMO]
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"))
|
||||
fix = fix_of(graph, "provider.disabled:ask:provider", table)
|
||||
marks = {o["value"]: o.get("demo", False) for o in fix["options"]}
|
||||
assert marks == {"": False, "甲接入": False, "演示接入": True}
|
||||
# 只剩演示接入可用:「不指定接入」也落到它
|
||||
fix = fix_of(graph, "provider.disabled:ask:provider", [BING, DEMO])
|
||||
assert {o["value"]: o.get("demo", False) for o in fix["options"]} == {"": True, "演示接入": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 点名的接入名恰好是某个启用接入的模型
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 运行时报错不分节点类型:多数节点的模型是下拉框,写「选择它」
|
||||
MODEL_NOTE = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「乙接入」"
|
||||
|
||||
|
||||
def test_a_provider_name_that_is_really_a_model_gets_a_note():
|
||||
"""停用的接入叫「乙一号」,启用的乙接入提供模型「乙一号」:照提示启用的会是那条旧接入,用户多半想要的是这个模型。
|
||||
报错照旧说停用了、去哪里启用,另补一句这是哪个接入的模型、怎么用它。不存在的名字同理;只有停用的接入提供的
|
||||
模型名、谁的模型都不是的名字不补。"""
|
||||
table = [JIA, YI, BING, TRAP]
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, "乙一号")
|
||||
assert e.value.code == "disabled" and e.value.reason == "模型接入「乙一号」已停用"
|
||||
assert str(e.value) == f"模型接入「乙一号」已停用。{DISABLED_FIX},或换一个接入。{MODEL_NOTE}"
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, " 甲二号 ")
|
||||
assert str(e.value) == (f"模型接入「甲二号」不存在。{MISSING_FIX},或换一个接入。"
|
||||
"「甲二号」是接入「甲接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「甲接入」")
|
||||
for name in ("丙一号", "戊接入"):
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, name)
|
||||
assert "是接入" not in str(e.value), str(e.value)
|
||||
|
||||
# 助手:并成一句,指到能直接选这个模型的地方(不再另起一句把「助手使用的模型」说两遍)
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, "乙一号")
|
||||
assert assistant_unavailable(e.value) == (
|
||||
f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX};"
|
||||
"「乙一号」是接入「乙接入」的模型,也可在「编排」页助手栏的「助手使用的模型」中直接选择它")
|
||||
|
||||
|
||||
def test_the_model_note_on_the_canvas_points_to_fields_that_exist_there():
|
||||
"""画布、发布前检查里那句按这一处在检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空:
|
||||
- 模型调用这类节点只有模型下拉框:在节点的「模型」中选它,接入在发布前检查里改用乙接入或不指定(修复里有哪项说哪项);
|
||||
- 协作成员、全图默认两栏都没有:只说发布前检查里可以改用乙接入;
|
||||
- 核查节点、报告自己的结论句裁判两栏都在(模型是输入框):照设置页的写法,写这个节点上两栏的叫法;
|
||||
- 给不出修复的:只说这是哪个接入的模型。"""
|
||||
table = [JIA, YI, BING, TRAP]
|
||||
fact = "「乙一号」是接入「乙接入」的模型"
|
||||
stopped = f"指定的模型接入「乙一号」已停用。{DISABLED_FIX}"
|
||||
graph = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"),
|
||||
node("team", "supervisor", "协作", provider="甲接入", goal="分析",
|
||||
agents=[{"name": "分析师", "provider": "乙一号", "system": "分析"}]),
|
||||
node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": "乙一号"}),
|
||||
node("dual", "dual_check", "复核销售额", provider="乙一号"),
|
||||
node("chal", "challenge", "审稿", report="write", provider="乙一号"))
|
||||
got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(checked(graph, table)["issues"])}
|
||||
assert got[("ask", "provider")] == (
|
||||
f"「提问」(模型调用){stopped}{IN_CHECK}。{fact}:如需使用该模型,请在节点的「模型」中选择它,"
|
||||
"并在「发布前检查」中改用「乙接入」或选择「不指定接入」")
|
||||
assert got[("team", "agents[0].provider")] == (
|
||||
f"「协作」(多 Agent 协作)的成员「分析师」{stopped}{IN_CHECK}。{fact},可在「发布前检查」中改用「乙接入」")
|
||||
assert got[("write", "judge.provider")] == (
|
||||
f"「写周报」(报告撰写)的结论句裁判{stopped},或在节点中换一个接入。"
|
||||
f"{fact}:如需使用该模型,请在「裁判模型」中填写,「裁判模型 · 接入」留空或选择「乙接入」")
|
||||
assert got[("dual", "provider")] == (
|
||||
f"「复核销售额」(双盲复算)的「盲算模型 · 接入」{stopped},或在节点中换一个接入。"
|
||||
f"{fact}:如需使用该模型,请在「盲算模型」中填写,「盲算模型 · 接入」留空或选择「乙接入」")
|
||||
assert got[("chal", "provider")].endswith(
|
||||
f"{fact}:如需使用该模型,请在「反方模型」中填写,「反方模型 · 接入」留空或选择「乙接入」")
|
||||
# 画布校验的警告是同一句
|
||||
warned = {(i.node_id, i.field): i.message
|
||||
for i in provider_ref_issues(GraphSpec.model_validate(graph), table, level="warning")}
|
||||
assert warned == got
|
||||
|
||||
# 全图默认也点名「乙一号」:「提问」去掉自己的接入以后落到全图默认那个停用的接入,修复里没有「不指定接入」,只说改用
|
||||
defaulted = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"),
|
||||
node("plain", "llm", "复述", prompt="复述"), defaults={"provider": "乙一号"})
|
||||
got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(checked(defaulted, table)["issues"])}
|
||||
assert got[("ask", "provider")].endswith(
|
||||
f"{fact}:如需使用该模型,请在节点的「模型」中选择它,并在「发布前检查」中改用「乙接入」")
|
||||
assert got[(None, "defaults.provider")] == f"工作流默认设置{stopped}{IN_CHECK}。{fact},可在「发布前检查」中改用「乙接入」"
|
||||
|
||||
# 成员自己写着甲家的模型:协作节点改用乙接入时成员落不稳,修复里只有「不指定接入」和甲接入
|
||||
mixed = flow(node("team", "supervisor", "协作", provider="乙一号", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "甲二号", "system": "研究"}]))
|
||||
[issue] = provider_issues(checked(mixed, table)["issues"])
|
||||
assert issue["message"].endswith(
|
||||
f"{fact}:如需使用该模型,请在节点的「模型」中选择它,并在「发布前检查」中选择「不指定接入」")
|
||||
|
||||
# 给不出修复(成员只写了停用的丙家的模型,换掉、去掉节点的接入都落不稳):只说这是哪个接入的模型
|
||||
stuck = flow(node("team", "supervisor", "协作", provider="乙一号", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]))
|
||||
[issue] = provider_issues(checked(stuck, table)["issues"])
|
||||
assert issue["fix"] is None
|
||||
assert issue["message"] == (
|
||||
f"「协作」(多 Agent 协作){stopped}{NO_SWITCH}"
|
||||
f"「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用。{fact}")
|
||||
|
||||
|
||||
def test_the_assistant_hint_points_to_the_dropdown_that_can_change_it():
|
||||
"""设置页里没有助手的接入这一项:能改的是「编排」页助手栏的「助手使用的模型」(选「跟随默认」或一个模型会清掉存着的
|
||||
接入)。表格导入、数据目录的 AI 起草也用助手的模型,同一句。"""
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(TABLE, "戊接入")
|
||||
assert assistant_unavailable(e.value) == (
|
||||
f"助手使用的模型接入「戊接入」不存在。{MISSING_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择")
|
||||
|
||||
|
||||
def test_under_the_assistant_dropdown_the_hint_points_up_not_back_to_itself():
|
||||
"""助手栏「助手使用的模型」那一格下面:人就在这个下拉框旁边,去向写「上方」,不再说「「编排」页助手栏的「助手使用的
|
||||
模型」」。接入本身没配完(缺 key……)时也补一句可以在上方重新选。"""
|
||||
table = [JIA, YI, BING, TRAP]
|
||||
for name, said in (
|
||||
("戊接入", "助手使用的模型接入「戊接入」不存在。请在上方重新选择,或在「设置 → 模型接入」中核对名称"),
|
||||
("丙接入", "助手使用的模型接入「丙接入」已停用。请在上方重新选择,或在「设置 → 模型接入」中启用它"),
|
||||
("乙一号", f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX};「乙一号」是接入「乙接入」的模型,也可在上方直接选择它"),
|
||||
):
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, name)
|
||||
assert assistant_unavailable(e.value, here=True) == said
|
||||
assert "助手栏" not in said
|
||||
keyless = ProviderNotConfigured("模型接入「甲接入」缺少 API Key", "请在「设置 → 模型接入」中填写", code="missing_key")
|
||||
assert assistant_unavailable(keyless, here=True).endswith("请到「设置 → 模型接入」检查,或在上方重新选择")
|
||||
assert assistant_unavailable(keyless).endswith("请到「设置 → 模型接入」检查")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 接口:保存设置、交给助手、画布校验(建自己的几条接入,用完删掉)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
LIVE, OFF, GONE = "措辞用接入", "措辞用停用接入", "措辞用接人"
|
||||
#: 停用的接入,名字恰好是 LIVE 提供的模型
|
||||
NAMED_LIKE_MODEL = "措辞模型"
|
||||
SETTING_KEYS = ["verify", "judge", "copilot", "run"]
|
||||
NAMES = [LIVE, OFF, NAMED_LIKE_MODEL]
|
||||
|
||||
|
||||
async def _clean() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(Setting).where(Setting.key.in_(SETTING_KEYS)))
|
||||
await session.execute(delete(Provider).where(Provider.name.in_(NAMES)))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def rows():
|
||||
await _clean()
|
||||
async with SessionLocal() as session:
|
||||
session.add_all([
|
||||
Provider(name=LIVE, kind="mock", models=[{"id": NAMED_LIKE_MODEL}], default_model=NAMED_LIKE_MODEL,
|
||||
enabled=True, extra={}),
|
||||
Provider(name=OFF, kind="mock", models=[{"id": "措辞停用模型"}], default_model="措辞停用模型",
|
||||
enabled=False, extra={}),
|
||||
Provider(name=NAMED_LIKE_MODEL, kind="mock", models=[{"id": "措辞旧模型"}], default_model="措辞旧模型",
|
||||
enabled=False, extra={}),
|
||||
])
|
||||
await session.commit()
|
||||
yield
|
||||
await _clean()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(rows):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def stored(key: str):
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Setting, key)
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法)
|
||||
SETTING_FIELDS = [
|
||||
("verify", "provider", "核查模型 · 接入", "核查模型 · 模型"),
|
||||
("judge", "provider", "裁判模型 · 接入", "裁判模型 · 模型"),
|
||||
("run", "tool_gate_provider", "门控模型 · 接入", "门控模型 · 模型"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label,model_label", SETTING_FIELDS)
|
||||
async def test_saving_a_broken_provider_is_worded_like_the_settings_page(client, group, key, label, model_label):
|
||||
"""下拉框里选的接入写「指定的」,不写「填写的」;留空那半句和设置页空选项的叫法对不上,不再说(那一格就在下拉框
|
||||
下面,空选项写着留空的意思)。这几组的模型一栏是输入框,名字恰好是模型时写「填写」。"""
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: GONE}}})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == f"「{label}」指定的模型接入「{GONE}」不存在。{MISSING_FIX},或换一个接入"
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: OFF}}})
|
||||
assert r.json()["detail"] == f"「{label}」指定的模型接入「{OFF}」已停用。{DISABLED_FIX},或换一个接入"
|
||||
# 名字恰好是启用接入的模型:补一句这是哪个接入的模型、在这一组里怎么用它
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: NAMED_LIKE_MODEL}}})
|
||||
note = (f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型:"
|
||||
f"如需使用该模型,请在「{model_label}」中填写,「{label}」留空或选择「{LIVE}」")
|
||||
assert r.json()["detail"] == f"「{label}」指定的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX},或换一个接入。{note}"
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
async def test_saving_a_broken_assistant_provider_is_worded_like_the_assistant(client):
|
||||
"""助手那一项只有「编排」页助手栏一个下拉框(只选模型),没有「换一个接入」的地方:422 的说法和助手取不到模型时
|
||||
报的同一句,名字恰好是模型时并成一句。"""
|
||||
picker = "「编排」页助手栏的「助手使用的模型」中"
|
||||
r = await client.put("/api/settings", json={"values": {"copilot": {"provider": GONE}}})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == f"助手使用的模型接入「{GONE}」不存在。{MISSING_FIX},或在{picker}重新选择"
|
||||
r = await client.put("/api/settings", json={"values": {"copilot": {"provider": OFF}}})
|
||||
assert r.json()["detail"] == f"助手使用的模型接入「{OFF}」已停用。{DISABLED_FIX},或在{picker}重新选择"
|
||||
r = await client.put("/api/settings", json={"values": {"copilot": {"provider": NAMED_LIKE_MODEL}}})
|
||||
assert r.json()["detail"] == (f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};"
|
||||
f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,也可在{picker}直接选择它")
|
||||
assert "换一个接入" not in r.json()["detail"]
|
||||
assert await stored("copilot") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label", [("run", "tool_gate_provider", "门控模型 · 接入"),
|
||||
("copilot", "provider", "助手使用的模型")])
|
||||
async def test_a_provider_name_that_is_not_text_is_worded_without_the_blank_half(client, group, key, label):
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: 123}}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == f"「{label}」需要填写模型接入的名称,当前为「123」"
|
||||
|
||||
|
||||
async def test_the_verify_format_check_says_blank_follows_the_judge_model_like_the_page(client):
|
||||
r = await client.put("/api/settings", json={"values": {"verify": {"provider": 3}}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == "「核查模型 · 接入」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「3」"
|
||||
|
||||
|
||||
async def test_the_judge_format_check_says_blank_follows_the_assistant_model_like_the_page(client):
|
||||
"""设置页裁判那一组接入下拉框的空选项是「跟随助手的模型」,不是「不指定」。"""
|
||||
r = await client.put("/api/settings", json={"values": {"judge": {"provider": 3}}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == "「裁判模型 · 接入」需要填写模型接入的名称或模型 ID,留空表示跟随助手的模型,当前为「3」"
|
||||
|
||||
|
||||
async def _workflow(graph: dict) -> str:
|
||||
async with SessionLocal() as session:
|
||||
wf = Workflow(name="措辞检查", graph=graph, version=1)
|
||||
session.add(wf)
|
||||
await session.flush()
|
||||
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph))
|
||||
await session.commit()
|
||||
return wf.id
|
||||
|
||||
|
||||
async def test_handing_only_provider_problems_to_the_assistant_is_one_sentence_per_step(client):
|
||||
"""发布弹窗里渲染成「助手:……」:说明自己不再带冒号,「核对」有宾语,不说「节点」(可能是全图默认)。人刚点了
|
||||
「交给助手」,说「助手无法处理」,不说「无法交给助手处理」(读来像没交出去)。有修复时补半句指到各条问题下方的
|
||||
选项(那一组的标题是「选择……改用的模型接入」),不和前一个「或」连用;没有修复时不提。"""
|
||||
from app.api.workflows import PROVIDER_LEFT, PROVIDER_LEFT_FIX
|
||||
|
||||
assert ":" not in PROVIDER_LEFT + PROVIDER_LEFT_FIX
|
||||
assert PROVIDER_LEFT == ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。"
|
||||
"请在「设置 → 模型接入」中核对名称或启用相应的接入")
|
||||
assert PROVIDER_LEFT_FIX == ";也可在各条问题下方选择改用的模型接入"
|
||||
graph = flow(node("ask", "llm", "提问", provider=GONE, prompt="总结"))
|
||||
wf = await _workflow(graph)
|
||||
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph, "assist": True})
|
||||
assert r.json()["assist"] == {"ok": False, "summary": PROVIDER_LEFT + PROVIDER_LEFT_FIX, "questions": []}
|
||||
|
||||
# 没有一条有修复(成员只写了停用接入的模型,换掉、去掉节点的接入都落不稳):不指到修复
|
||||
graph = flow(node("team", "supervisor", "协作", provider=GONE, goal="分析",
|
||||
agents=[{"name": "研究员", "model": "措辞停用模型", "system": "研究"}]))
|
||||
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph, "assist": True})
|
||||
out = r.json()
|
||||
assert [i["fix"] for i in provider_issues(out["remaining"])] == [None]
|
||||
assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
|
||||
|
||||
|
||||
def unset_model(result: dict) -> list[dict]:
|
||||
return [i for i in result["issues"] if i.get("code") == "model.unset"]
|
||||
|
||||
|
||||
async def test_no_model_says_which_provider_and_steps_aside_when_that_provider_is_broken(client):
|
||||
"""节点点名了接入、没写模型:说用这个接入的默认模型。这个接入本身不存在或已停用时只留接入那一条:一条说会照常
|
||||
执行、一条说执行到这里会失败,读者无从判断。全图默认的接入同理。没点名接入的照旧说「将使用默认模型」。"""
|
||||
graph = flow(node("a", "llm", "提问", provider=LIVE, prompt="总结"),
|
||||
node("b", "llm", "复述", provider=GONE, prompt="复述"),
|
||||
node("c", "agent", "查数", prompt="查数"),
|
||||
node("d", "report", "写周报", instructions="写周报", provider=""),
|
||||
defaults={"provider": OFF})
|
||||
r = await client.post("/api/workflows/validate", json={"graph": graph})
|
||||
got = {i["node_id"]: i for i in unset_model(r.json())}
|
||||
assert set(got) == {"a"}, got
|
||||
assert (got["a"]["level"], got["a"]["field"], got["a"]["message"]) == (
|
||||
"warning", "model", f"没有指定模型,将使用接入「{LIVE}」的默认模型")
|
||||
assert {(i["node_id"], i["field"]) for i in provider_issues(r.json()["issues"])} == {
|
||||
("b", "provider"), (None, "defaults.provider")}
|
||||
|
||||
# 发布前检查同样只留接入那一条
|
||||
wf = await _workflow(graph)
|
||||
r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
|
||||
assert {i["node_id"] for i in unset_model(r.json())} == {"a"}
|
||||
|
||||
# 全图默认的接入换成启用的:跟随它的节点说用它的默认模型;什么都没点名的照旧
|
||||
graph["defaults"] = {"provider": LIVE}
|
||||
r = await client.post("/api/workflows/validate", json={"graph": graph})
|
||||
said = {i["node_id"]: i["message"] for i in unset_model(r.json())}
|
||||
assert said["c"] == said["a"] == f"没有指定模型,将使用接入「{LIVE}」的默认模型"
|
||||
graph["defaults"] = {}
|
||||
r = await client.post("/api/workflows/validate", json={"graph": graph})
|
||||
assert {i["node_id"]: i["message"] for i in unset_model(r.json())}["c"] == "没有指定模型,将使用默认模型"
|
||||
|
||||
|
||||
async def test_the_assistant_model_lookup_explains_a_provider_named_like_a_model(client):
|
||||
"""error 是生成时报的同一句(别处也这么说);panel_error 写在助手栏那一格下面,去向是「上方」的下拉框。"""
|
||||
async with SessionLocal() as session:
|
||||
session.add(Setting(key="copilot", value={"provider": NAMED_LIKE_MODEL, "model": ""}))
|
||||
await session.commit()
|
||||
got = (await client.get("/api/copilot/model")).json()
|
||||
note = f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型"
|
||||
assert got["error"] == (f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};"
|
||||
f"{note},也可在「编排」页助手栏的「助手使用的模型」中直接选择它")
|
||||
assert got["panel_error"] == f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};{note},也可在上方直接选择它"
|
||||
# 在助手栏重新选「跟随默认」:存着的接入一并清掉,不再报错
|
||||
r = await client.put("/api/copilot/model", json={"model": None})
|
||||
assert r.json()["error"] is None and r.json()["panel_error"] is None
|
||||
assert (await stored("copilot")) == {"provider": "", "model": ""}
|
||||
@@ -117,6 +117,9 @@ async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
await resolve_provider(session, "fake-v4.1-flash", "fake-v4.1-flash")
|
||||
assert e.value.code == "disabled" and "fake-v4.1-flash" in str(e.value)
|
||||
# 照「启用它」去做启用的是那条旧接入:另补一句这个名字是哪个接入的模型、怎么用它
|
||||
assert e.value.model_owner == "云端接入" and str(e.value).endswith(
|
||||
"「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「云端接入」")
|
||||
assert (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id
|
||||
|
||||
|
||||
@@ -201,10 +204,11 @@ async def test_the_assistant_is_told_which_provider_is_broken(session):
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
await resolve_provider(session, ref, model)
|
||||
said[case] = assistant_unavailable(e.value)
|
||||
assert said["missing"] == "助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,或为助手换一个模型"
|
||||
assert said["disabled"] == "助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,或为助手换一个模型"
|
||||
repick = "或在「编排」页助手栏的「助手使用的模型」中重新选择"
|
||||
assert said["missing"] == f"助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,{repick}"
|
||||
assert said["disabled"] == f"助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,{repick}"
|
||||
assert said["by_model"] == ("助手使用的模型「m-off」所属的模型接入「停用接入」已停用。"
|
||||
"请在「设置 → 模型接入」中启用它,或为助手换一个模型")
|
||||
f"请在「设置 → 模型接入」中启用它,{repick}")
|
||||
keyless = ProviderNotConfigured("模型接入「云端接入」没有配置 API Key", "请在接入中填写", code="missing_key")
|
||||
assert assistant_unavailable(keyless) == (
|
||||
"助手使用的模型尚未配置完成:模型接入「云端接入」没有配置 API Key。请到「设置 → 模型接入」检查")
|
||||
|
||||
@@ -91,6 +91,21 @@ export interface ProviderTest {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 助手当前用哪个模型(GET / PUT /api/copilot/model)。provider、model 是存着的设定;存的接入不存在、已停用(或只存了
|
||||
* 模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,error 是和生成时报的同一句。老后端不给 error
|
||||
*/
|
||||
export interface CopilotModel {
|
||||
configured: boolean
|
||||
provider: string | null
|
||||
model: string | null
|
||||
effective_provider: string | null
|
||||
effective_model: string | null
|
||||
error?: string | null
|
||||
/** 同一件事写在助手栏「助手使用的模型」那一格下面的说法:去向写「上方」,不把人指回这个下拉框本身。老后端不给 */
|
||||
panel_error?: string | null
|
||||
}
|
||||
|
||||
/** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */
|
||||
export interface ProviderDraft {
|
||||
id?: string
|
||||
@@ -718,12 +733,9 @@ export const api = {
|
||||
layout: (graph: GraphSpec) => post<GraphSpec>('/copilot/layout', { graph }),
|
||||
fromRun: (runId: string, name?: string) =>
|
||||
post<any>('/copilot/from-run', { run_id: runId, name }),
|
||||
getModel: () => get<{
|
||||
configured: boolean; provider: string | null; model: string | null
|
||||
effective_provider: string | null; effective_model: string | null
|
||||
}>('/copilot/model'),
|
||||
getModel: () => get<CopilotModel>('/copilot/model'),
|
||||
setModel: (body: { provider?: string | null; model?: string | null }) =>
|
||||
put<any>('/copilot/model', body),
|
||||
put<CopilotModel>('/copilot/model', body),
|
||||
/** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */
|
||||
review: (body: { run_id: string; question: string }) =>
|
||||
post<ReviewResult>('/copilot/review', body),
|
||||
|
||||
@@ -7,15 +7,15 @@ import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax, type NodeDef } fr
|
||||
import { fieldOfIssue, isCaseKeyIssue, unboundToolOf, withToolBound, type FieldRef } from './issues'
|
||||
import { hintOf } from './shortcuts'
|
||||
import { isActivePhase } from '../run/trace'
|
||||
import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio'
|
||||
import { EDIT_LOCK_TEXT, toGraph, useEditLock, useStudio } from '../store/studio'
|
||||
import { datasourceTools, modelOptions, providerOfModel, useCatalog, useDatasources } from '../store/catalog'
|
||||
import { api } from '../api/client'
|
||||
import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui'
|
||||
import { formatShortcut } from '../lib/keys'
|
||||
import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms'
|
||||
import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, PROVIDER_REF_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms'
|
||||
import { TemplateText } from './TemplateText'
|
||||
import { MERGE_ALIAS, isQueryNode, mergeAlias } from './mergeInputs'
|
||||
import type { NodeType, ValidationIssue, WorkflowVersion } from '../types'
|
||||
import type { NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types'
|
||||
|
||||
/** 检查器里一条落到字段上的问题 */
|
||||
type FieldIssue = ValidationIssue & { at: FieldRef | null }
|
||||
@@ -544,11 +544,38 @@ function SyntaxBadge({ syntax }: { syntax: FieldSyntax }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型一栏留空时执行用什么,同后端 NodeContext.cfg:节点没写的取工作流默认设置里的。工作流默认设置写了模型,用的就是那个
|
||||
* 模型(后端这时也不报「没有指定模型」);否则节点点名了接入(没写就是全图默认点名的)时是那个接入的默认模型。点名的接入
|
||||
* 不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说「使用……」就和它矛盾。全图默认不在
|
||||
* 节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态。help 为 undefined 时用节点定义里那句
|
||||
*/
|
||||
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 = 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'
|
||||
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 }: {
|
||||
field: FieldDef; nodeId: string; value: any; config: Record<string, any>
|
||||
issues: FieldIssue[]; onChange: (v: any) => void
|
||||
}) {
|
||||
const id = `f-${nodeId}-${field.key}`
|
||||
// 节点定义里的模型一栏(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'
|
||||
@@ -590,8 +617,8 @@ function Field({ field, nodeId, value, config, issues, onChange }: {
|
||||
<FieldInput field={field} id={id} nodeId={nodeId} syntax={syntax} value={value} config={config}
|
||||
invalid={bad} describedBy={[errorId, off ? `${id}-off` : ''].filter(Boolean).join(' ') || undefined}
|
||||
disabled={!!off} issues={issues} onChange={onChange} />
|
||||
{field.help && field.type !== 'switch' && (
|
||||
<div className="mt-1 text-2xs leading-snug text-faint">{field.help}</div>
|
||||
{help && field.type !== 'switch' && (
|
||||
<div className="mt-1 text-2xs leading-snug text-faint" data-field-help="">{help}</div>
|
||||
)}
|
||||
{off && <div id={`${id}-off`} className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }}>{off}</div>}
|
||||
{!!own.length && (
|
||||
@@ -705,7 +732,7 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
|
||||
const groups = [...new Set(options.map((o) => o.group))]
|
||||
return (
|
||||
<select id={id} className="field" value={value ?? ''} {...aria} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">默认(使用第一个可用的模型接入)</option>
|
||||
<option value="">{modelDefault(config, providers).blank}</option>
|
||||
{groups.map((g) => (
|
||||
<optgroup key={g} label={g}>
|
||||
{options.filter((o) => o.group === g).map((o) => (
|
||||
|
||||
@@ -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)) && (
|
||||
|
||||
@@ -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 {
|
||||
@@ -321,16 +323,18 @@ export function isMissingEndpoint(e: unknown): boolean {
|
||||
* 这条问题的修复:后端点了 fix id 就按 id 找;没点的(/publish 被拦时的回包只有 code)按 code + 节点认。
|
||||
* 认不出时和后端 autofix.annotate 同一个退路:图级问题(没有节点,比如「没有带契约的出口」,
|
||||
* 修复却落在唯一的出口上)认同一编号的第一条修复;图级修复(改的是全图默认,不落在任何节点上)
|
||||
* 认同一编号的每一条问题。再认不出就没有
|
||||
* 认同一编号的每一条问题。再认不出就没有。点名的模型接入不走退路(同 annotate)
|
||||
*/
|
||||
export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix | undefined {
|
||||
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 = (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 (isProviderRefCode(issue.code)) {
|
||||
// 点名的接入一处一条修复,只认节点、字段都对得上的那一条(同一节点上的几处按字段分)。没有就是这一处没有修复
|
||||
//(一个候选都落不稳),不退到同节点、同编号别处的修复上——退过去,这一处的问题下面就画着另一处的选项
|
||||
return same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null) && (f.field ?? null) === (issue.field ?? null))
|
||||
}
|
||||
const exact = 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)
|
||||
}
|
||||
|
||||
@@ -919,6 +919,13 @@ export const PUBLISH_FIX_TEXT = {
|
||||
whole: '整个工作流',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 停用一个接入的后果。不只是下拉里看不到:点名它(或只写了它的模型)的节点、门控 / 裁判 / 核查设置都调不了它(后端
|
||||
* providers/factory.named_provider)。调不了以后怎样各处不同(节点报错、门控转人工审批、裁判记未裁判、核查记缺口),
|
||||
* 由设置页各组自己那一句说,这里只说「无法调用」;整句控制在 40 字以内
|
||||
*/
|
||||
const PROVIDER_DISABLED_EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
|
||||
|
||||
/**
|
||||
* 点名的模型接入不存在或已停用(后端 providers/factory.named_provider、engine/provider_refs.py)。以前点名的接入不存在时
|
||||
* 运行悄悄换成演示模型、点名停用的照常调用;现在运行照实报错,设置页、画布校验、发布前检查都提前说出来
|
||||
@@ -942,6 +949,57 @@ export const PROVIDER_REF_TEXT = {
|
||||
judgeBroken: '裁判调用将失败,所有结论句记为未裁判',
|
||||
// 和核查一组「不存在」那句(VERIFY_SETTING_TEXT.section.providerGone)同一个后果
|
||||
verifyBroken: '核查调用将失败',
|
||||
/**
|
||||
* 存着的接入名恰好是某个启用接入的模型(多半是把模型名填进了接入那一栏):照「启用它」去做,启用的是那条旧接入。
|
||||
* 和服务端保存时 422 的那一句(factory.model_note)一字不差;两栏写这一组的叫法(「门控模型 · 模型」「门控模型 · 接入」)
|
||||
*/
|
||||
modelNote: (name: string, owner: string, modelField: string, providerField: string) =>
|
||||
`「${name}」是接入「${owner}」的模型:如需使用该模型,请在「${modelField}」中填写,「${providerField}」留空或选择「${owner}」`,
|
||||
/** 接入编辑框「启用」下面的说明、接入卡片上「已停用」的悬停说明 */
|
||||
enabledHint: `停用后,${PROVIDER_DISABLED_EFFECT}`,
|
||||
disabledChip: `已停用:${PROVIDER_DISABLED_EFFECT}`,
|
||||
/**
|
||||
* 检查器模型一栏的空选项:留空时执行用什么(同后端 NodeContext.cfg:节点没写的取工作流默认设置里的)。工作流默认设置
|
||||
* 写了模型,用的就是那个模型(后端这时也不报「没有指定模型」);节点(没写就是全图默认)点名了接入,用那个接入的默认
|
||||
* 模型,照实说是哪个;那个接入不存在、已停用时说状态(检查器顶上另有接入那条问题),不说「使用」。和画布校验那条
|
||||
* 「没有指定模型,将使用接入「X」的默认模型」同一个说法(后端 schema.MODEL_UNSET)
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* 「编排」页助手栏里「助手使用的模型」那一格(run/Composer.tsx)。服务端报助手的模型取不到时,指的就是这里
|
||||
* (providers/factory.ASSISTANT_REPICK):设置页没有助手的接入这一项,存着的接入失效了只能在这里重新选
|
||||
*/
|
||||
export const ASSISTANT_MODEL_TEXT = {
|
||||
/** 和服务端 factory.ASSISTANT_MODEL_FIELD 一字不差:报错里让人去「助手使用的模型」中重新选择 */
|
||||
label: '助手使用的模型',
|
||||
follow: '跟随默认',
|
||||
current: (model: string) => `(当前:${model})`,
|
||||
/**
|
||||
* 存着的设定下拉里列不出来(点名了接入——下拉只选模型;或者模型不在可选列表中):照实列成一项并选中它。否则下拉停在
|
||||
* 「跟随默认」,看着一切正常,再选「跟随默认」也不算改动、清不掉存着的接入
|
||||
*/
|
||||
saved: (provider: string | null, model: string | null) => (provider
|
||||
? (model ? `${model}(模型接入「${provider}」)` : `模型接入「${provider}」的默认模型`) : (model ?? '')),
|
||||
note: '仅影响助手,不改变节点使用的模型。助手需要严格按格式输出,指令遵循能力较弱的模型可能无法生成工作流。',
|
||||
/** 存着的设定用不了时,齿轮按钮的悬停说明(按钮上另有一个红点) */
|
||||
broken: '助手使用的模型当前无法调用,点击查看原因',
|
||||
/** 重新选择时保存失败:下拉退回选之前的样子(服务端存着的没变),在下面说明 */
|
||||
saveFailed: '保存失败,助手使用的模型未更改',
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -186,7 +186,7 @@ function ProviderCard({ provider: p, kindLabel, onEdit, onRemove }: {
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
<span className="chip">{kindLabel}</span>
|
||||
{p.default_model && <span className="chip mono" title="默认模型">{p.default_model}</span>}
|
||||
{!p.enabled && <span className="chip" title="停用的接入不会出现在模型下拉里">已停用</span>}
|
||||
{!p.enabled && <span className="chip" title={PROVIDER_REF_TEXT.disabledChip}>已停用</span>}
|
||||
<span className="flex-1" />
|
||||
<HealthPill record={record} checkingSince={checkingSince} />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -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">停用后,该接入的模型不会出现在节点的模型下拉列表中</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>}
|
||||
|
||||
@@ -803,13 +811,18 @@ function PrefsTab() {
|
||||
const collectionMissing = !collectionNames.includes(cur.collection)
|
||||
/**
|
||||
* 接入那一格下面的报错:服务端按这一项拒收的话照它的整句;否则存着(或选着)的接入不存在、已停用时说清这一项会怎样。
|
||||
* 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到
|
||||
* 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到。labels 是这一组接入、模型两栏的叫法:名字恰好是某个
|
||||
* 启用接入的模型时(多半是把模型名填进了接入一栏)补一句怎么用它,同服务端保存时那句,免得照「启用」去启用那条旧接入
|
||||
*/
|
||||
const providerError = (field: string, name: string, found: Provider | undefined, broken: string): string | undefined => {
|
||||
const providerError = (field: string, name: string, found: Provider | undefined, broken: string,
|
||||
labels: { provider: string; model: 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}`
|
||||
if (found?.enabled) return undefined
|
||||
const state = found ? PROVIDER_REF_TEXT.disabled(name) : PROVIDER_REF_TEXT.missing(name)
|
||||
const owner = providerOfModel(providers, name)
|
||||
const note = owner?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(name, owner.provider.name, labels.model, labels.provider)}` : ''
|
||||
return `${state}:${broken}${note}`
|
||||
}
|
||||
// 门控模型留空时后端的挑法(resolve_provider):第一个启用的真实接入,没有就第一个启用的
|
||||
const enabledProviders = providers.filter((p) => p.enabled)
|
||||
@@ -817,13 +830,15 @@ function PrefsTab() {
|
||||
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 gateProviderError = providerError('run.tool_gate_provider', cur.gateProvider, gateProvider, PROVIDER_REF_TEXT.gateBroken,
|
||||
TOOL_GATE_TEXT)
|
||||
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 judgeProviderError = providerError('judge.provider', cur.judgeProvider, judgeProvider, PROVIDER_REF_TEXT.judgeBroken,
|
||||
JUDGE_SETTING_TEXT)
|
||||
const judgeModelName = cur.judgeModel.trim()
|
||||
const judgeByModel = !cur.judgeProvider && !!judgeModelName
|
||||
// 裁判模型没有单独配置(接入、模型都留空):后端依次用助手的模型、默认接入
|
||||
|
||||
@@ -164,9 +164,13 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
|
||||
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)
|
||||
// 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句。名字恰好是某个启用接入的模型时补一句怎么用它
|
||||
//(同设置页门控、裁判两组,和服务端保存时那句一致)
|
||||
const providerTrap = provider?.enabled === false || providerMissing ? providerOfModel(providers, cur.provider) : undefined
|
||||
const providerNote = providerTrap?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(cur.provider, providerTrap.provider.name, T.model, T.provider)}` : ''
|
||||
const providerError = errors.provider ?? (providerMissing ? `${S.providerGone(cur.provider)}${providerNote}`
|
||||
: provider && !provider.enabled
|
||||
? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}${providerNote}` : undefined)
|
||||
const modelName = cur.model.trim()
|
||||
const byModel = !cur.provider && !!modelName
|
||||
const notSet = !cur.provider && !modelName
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react'
|
||||
import { AlertCircle, ArrowUp, CornerDownRight, Database, Settings2, Sparkles, Square, Wrench } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
import { api, type CopilotModel } from '../api/client'
|
||||
import { useCatalog, useDatasources, modelOptions } from '../store/catalog'
|
||||
import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio'
|
||||
import { isComposing, Spinner, TechDetails, useRadioGroup } from '../components/ui'
|
||||
import { formatShortcut } from '../lib/keys'
|
||||
import { ASSISTANT_MODEL_TEXT as MT } from '../lib/terms'
|
||||
import type { DataSource } from '../types'
|
||||
import { COPILOT_PHASE_TEXT } from './decode'
|
||||
|
||||
/** 「助手使用的模型」下拉里代表「存着的设定」的那一项的值(模型 id 不会是它) */
|
||||
const SAVED = '\u0000saved'
|
||||
|
||||
/**
|
||||
* Copilot 的输入。
|
||||
*
|
||||
@@ -39,17 +43,26 @@ export function Composer({ hero, onFocusChange }: {
|
||||
const lock = useEditLock()
|
||||
const [text, setText] = useState('')
|
||||
const [opts, setOpts] = useState(false)
|
||||
const [model, setModel] = useState('')
|
||||
const [effective, setEffective] = useState('')
|
||||
// 存着的助手模型设定(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(() => {
|
||||
void api.copilot.getModel().then((m) => {
|
||||
setModel(m.model ?? '')
|
||||
setEffective(m.effective_model ?? '')
|
||||
}).catch(() => undefined)
|
||||
}, [])
|
||||
if (!opts && saved) return
|
||||
let alive = true
|
||||
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])
|
||||
|
||||
// 工具栏的 Copilot 按钮把焦点甩过来
|
||||
useEffect(() => {
|
||||
@@ -63,18 +76,38 @@ export function Composer({ hero, onFocusChange }: {
|
||||
if (hero) ref.current?.focus()
|
||||
}, [hero])
|
||||
|
||||
const options = modelOptions(providers)
|
||||
const groups = [...new Set(options.map((o) => o.group))]
|
||||
// 存着的设定下拉里列不出来:点名了接入(下拉只选模型,选了会清掉接入),或者模型不在可选列表中。照实列成一项并选中
|
||||
// 它——否则下拉停在「跟随默认」,看着一切正常,接入失效了也看不出来,再选「跟随默认」不算改动、清不掉存着的接入
|
||||
const custom = !!saved && (!!saved.provider || (!!saved.model && !options.some((o) => o.value === saved.model)))
|
||||
const model = custom ? SAVED : saved?.model ?? ''
|
||||
// 下拉下面写的原因:服务端给的这一格专用的说法(去向写「上方」),老服务端只给整句
|
||||
const broken = saved?.panel_error || saved?.error || null
|
||||
|
||||
const pickModel = (value: string) => {
|
||||
setModel(value)
|
||||
void api.copilot.setModel({ model: value || null })
|
||||
.then((m) => setEffective(m.effective_model ?? ''))
|
||||
.catch(() => undefined)
|
||||
if (value === SAVED) return
|
||||
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) => {
|
||||
const q = (override ?? text).trim()
|
||||
if (!q || copilot.active) return
|
||||
// 被正式运行拦下时(store 已经弹过提示)什么都没开始:这句话留在框里,结束后直接再发
|
||||
if (!runCopilot(q, useBase && nodes.length > 0, model || undefined)) return
|
||||
// 被正式运行拦下时(store 已经弹过提示)什么都没开始:这句话留在框里,结束后直接再发。
|
||||
// 存着的设定列不出来时不带模型:服务端按存着的整组取(带了模型就会绕过存着的接入,和这里说的对不上)
|
||||
if (!runCopilot(q, useBase && nodes.length > 0, model && model !== SAVED ? model : undefined)) return
|
||||
setText('')
|
||||
// 从头生成是一次性的:新图一落到画布上,下一句多半是在它上面改。停在「从头生成」
|
||||
// 的话,下一句又把刚生成的图整张换掉(撤销找得回,但人得先发现)
|
||||
@@ -82,8 +115,6 @@ export function Composer({ hero, onFocusChange }: {
|
||||
}
|
||||
const mode = useRadioGroup(MODES, useBase ? 'base' : 'fresh', (m) => setUseBase(m === 'base'))
|
||||
|
||||
const options = modelOptions(providers)
|
||||
const groups = [...new Set(options.map((o) => o.group))]
|
||||
const examples = hero ? exampleFor(nodes.length, sources) : []
|
||||
|
||||
return (
|
||||
@@ -136,14 +167,17 @@ export function Composer({ hero, onFocusChange }: {
|
||||
leading={
|
||||
<>
|
||||
<button
|
||||
className={clsx('rounded-md p-1.5 transition-colors hover:bg-hover',
|
||||
className={clsx('relative rounded-md p-1.5 transition-colors hover:bg-hover',
|
||||
opts ? 'text-[var(--accent)]' : 'text-faint')}
|
||||
title="助手使用的模型"
|
||||
aria-label="助手使用的模型"
|
||||
title={broken ? MT.broken : MT.label}
|
||||
aria-label={MT.label}
|
||||
aria-expanded={opts}
|
||||
data-copilot-model-broken={broken ? '' : undefined}
|
||||
onClick={() => setOpts((v) => !v)}
|
||||
>
|
||||
<Settings2 size={13} />
|
||||
{/* 存着的设定用不了:收着的时候也要看得出来,不然要等发出一句话才报错 */}
|
||||
{broken && <span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-[var(--err)]" aria-hidden />}
|
||||
</button>
|
||||
{/* 两种方式并排摆出来,选中的那个就是这一句会怎么做。以前是一个 10px 的字,
|
||||
点一下就翻成另一个词,看不出它是开关,更看不出另一头是什么。从头生成
|
||||
@@ -169,9 +203,13 @@ export function Composer({ hero, onFocusChange }: {
|
||||
{opts && (
|
||||
<div className="fade-up mt-2 space-y-2 rounded-lg border bg-panel p-2.5">
|
||||
<div>
|
||||
<label className="label" htmlFor="copilot-model">助手使用的模型</label>
|
||||
<select id="copilot-model" className="field" value={model} onChange={(e) => pickModel(e.target.value)}>
|
||||
<option value="">跟随默认{effective ? `(当前:${effective})` : ''}</option>
|
||||
<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}>
|
||||
{/* 「当前」只在选着跟随默认、用得了的时候写:选了别的模型时 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}>
|
||||
{options.filter((o) => o.group === g).map((o) => (
|
||||
@@ -180,10 +218,15 @@ export function Composer({ hero, onFocusChange }: {
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-1 text-2xs leading-snug text-faint">
|
||||
仅影响助手,不改变节点使用的模型。助手需要严格按格式输出,
|
||||
指令遵循能力较弱的模型可能无法生成工作流。
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
<div className="mt-1 text-2xs leading-snug text-faint">{MT.note}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -607,7 +607,8 @@ await section('设置 · 点名的接入已停用或不存在', async () => {
|
||||
judge: { ...(stored.judge ?? {}), provider: 'demo-off', model: null },
|
||||
verify: { ...(stored.verify ?? {}), provider: 'demo-off', model: null },
|
||||
}
|
||||
const REFUSED = '「门控模型 · 接入」填写的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入;留空表示使用默认接入的默认模型'
|
||||
// 服务端的说法(api/settings._provider_problem):下拉框里选的写「指定的」,不另讲留空是什么意思(空选项写着)
|
||||
const REFUSED = '「门控模型 · 接入」指定的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入'
|
||||
let refuse = true
|
||||
const { page, sent, errors, close } = await open('/settings/prefs', {
|
||||
handlers: [
|
||||
@@ -653,6 +654,83 @@ await section('设置 · 点名的接入已停用或不存在', async () => {
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('设置 · 停用接入的后果写全;接入名恰好是模型名时说清怎么用这个模型', async () => {
|
||||
// 停用一个接入以后,点名它(或只写了它的模型)的节点和设置都调不了它(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 = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
|
||||
// 编辑框里「启用」的版式:「启用」两个字只占一行(以前长说明和它并排放在 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}`,
|
||||
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)
|
||||
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,
|
||||
run: { ...(stored.run ?? {}), tool_gate_provider: 'big-model', tool_gate_model: null },
|
||||
judge: { ...(stored.judge ?? {}), provider: 'big-model', model: null },
|
||||
verify: { ...(stored.verify ?? {}), provider: 'big-model', model: null },
|
||||
}
|
||||
const { page, errors, close } = await open('/settings/prefs', {
|
||||
handlers: [[/^GET \/settings$/, (route) => json(stored)(route)], [/^GET \/providers$/, json(PROVIDERS)]],
|
||||
})
|
||||
const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '')
|
||||
const note = (model, provider) => `「big-model」是接入「demo-main」的模型:如需使用该模型,请在「${model}」中填写,「${provider}」留空或选择「demo-main」`
|
||||
check('门控模型:先说停用了、会怎样,再补一句这是 demo-main 的模型、在哪一栏用它',
|
||||
await said('pref-gate-provider') === `模型接入「big-model」已停用:门控模型无法调用,所有工具调用将转为人工审批。${note('门控模型 · 模型', '门控模型 · 接入')}`,
|
||||
await said('pref-gate-provider'))
|
||||
check('证据裁判:同样补这一句,写裁判那两栏的叫法', (await said('pref-judge-provider')).endsWith(`。${note('裁判模型 · 模型', '裁判模型 · 接入')}`),
|
||||
await said('pref-judge-provider'))
|
||||
check('核查模型:同样补这一句,写核查那两栏的叫法', (await said('pref-verify-provider')).endsWith(`。${note('核查模型 · 模型', '核查模型 · 接入')}`),
|
||||
await said('pref-verify-provider'))
|
||||
await shot(page, 'prefs-provider-named-like-model')
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('设置 · 智能体护栏', async () => {
|
||||
// 默认步数、token 预算、金额预算(后端 engine/guards.py)。预算留空 = 不限,存 null
|
||||
let stored = await get('/settings')
|
||||
|
||||
+125
-13
@@ -199,12 +199,43 @@ const PROV_MODEL = {
|
||||
],
|
||||
edges: chain('in', 'look', 'team', 'done'),
|
||||
}
|
||||
/**
|
||||
* 一处给不出修复(K1b):协作节点点名的接入不存在,成员「研究员」跟随节点的接入、自己只写了停用的 demo-off 提供的模型——
|
||||
* 节点换成哪家、去掉接入,研究员都落不稳,这一处没有修复,提示也不指到发布前检查;成员「分析师」自己点名的接入不存在,
|
||||
* 改用 demo-main 落得稳,有修复。两条同编号、同节点:前一条不能借后一条的修复
|
||||
*/
|
||||
const PROV_NOFIX = {
|
||||
nodes: [
|
||||
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
|
||||
node('team', 'supervisor', 240, '协作', { goal: '复核', provider: 'demo-old',
|
||||
agents: [{ name: '分析师', provider: 'demo-old', tools: [] }, { name: '研究员', model: 'tiny-1', tools: [] }] }),
|
||||
node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }),
|
||||
],
|
||||
edges: chain('in', 'team', '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 },
|
||||
]
|
||||
/**
|
||||
* 启用的只有演示类接入(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,
|
||||
@@ -226,6 +257,8 @@ const FAKES = {
|
||||
'pf-prov': wf('pf-prov', '__publish_check_provider__', PROV),
|
||||
'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))
|
||||
@@ -234,11 +267,13 @@ const who = (n) => `「${n.data.label}」`
|
||||
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、
|
||||
* 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix.provider_choice:一处一条问题(字段 provider、
|
||||
* agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复。
|
||||
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这两张图用到的情形,图里没有全图默认):节点去掉接入落到
|
||||
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这几张图用到的情形,图里没有全图默认):节点去掉接入落到
|
||||
* 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时
|
||||
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)
|
||||
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)。协作节点换掉、去掉接入时,跟着它的成员自己写着
|
||||
* 没有启用接入提供的模型,一个候选都落不稳:没有修复,提示不写「或在「发布前检查」中改用其他接入」,改说卡在哪里
|
||||
* (后端 provider_refs._tail:「无法改用其他接入:」加第一处落不稳的地方)。落到演示类接入的候选说明末尾照实说、带 demo 标记
|
||||
*/
|
||||
function providerLint(graph, providers, level) {
|
||||
const issues = []
|
||||
@@ -247,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]})`
|
||||
@@ -261,26 +297,35 @@ function providerLint(graph, providers, level) {
|
||||
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) continue
|
||||
const model = r.holder.model
|
||||
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]
|
||||
if (up?.enabled) {
|
||||
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 enabled) {
|
||||
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 ? ',或在「发布前检查」中改用其他接入'
|
||||
: 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 })
|
||||
}
|
||||
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops })
|
||||
}
|
||||
// 哪一级都没写接入、只写了模型:按模型名找,只有停用的接入提供它时同样是问题,落在写模型的那一处。修复是「不指定
|
||||
// 模型」(落到第一个可用的接入)或改用一个启用的接入(写在模型旁边,模型去掉)
|
||||
@@ -619,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'].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) => {
|
||||
@@ -1721,6 +1767,72 @@ await section('点名的模型接入:一处一行、各有一组要人选的
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('点名的模型接入:给不出修复的那一处不借同节点别处的修复,提示也不指到发布前检查', async () => {
|
||||
const { page, errors } = await open({ id: 'pf-provnofix' })
|
||||
await openDialog(page)
|
||||
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue^="provider."]').count()) === 2)
|
||||
const rows = dialog(page).locator('[data-preflight-issue^="provider."]')
|
||||
const own = rows.nth(0)
|
||||
const member = rows.nth(1)
|
||||
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」不存在。请在「设置 → 模型接入」中核对名称。无法改用其他接入:'
|
||||
+ '「协作」(多 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)
|
||||
await shoot(page, 'publish-provider-nofix', dialog(page))
|
||||
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 } })
|
||||
|
||||
@@ -2607,6 +2607,263 @@ await section('合并查询:节点库认得、卡片摘要、检查器从上
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
// ================================================================ 点名的模型接入(K1b)
|
||||
// 接入表、画布校验、助手的模型设定都是伪造的(一家启用、一家停用,名字都是通用名)。校验按伪造的接入表现算,形状照
|
||||
// 后端:点名的接入不存在、已停用给接入那一条(provider.*);点名的接入好好的、没写模型给「将使用接入「X」的默认模型」;
|
||||
// 接入那一条在时不再给「没有指定模型」(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 },
|
||||
{ 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 MODEL_GRAPH = {
|
||||
nodes: [
|
||||
node('start', 'input', 0, 300, '输入', { fields: [{ name: 'q', required: true }] }),
|
||||
node('named', 'llm', 300, 60, '点名接入', { prompt: '{{ input.q }}', provider: 'demo-main' }),
|
||||
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'),
|
||||
edge('named', 'done'), edge('gone', 'done'), edge('off', 'done'), edge('plain', 'done')],
|
||||
}
|
||||
/** 伪造的画布校验:只管模型这一栏和点名的接入 */
|
||||
const modelLint = (graph) => {
|
||||
const issues = []
|
||||
for (const n of graph.nodes ?? []) {
|
||||
if (!['llm', 'agent'].includes(n.type)) continue
|
||||
const c = n.data?.config ?? {}
|
||||
const named = (c.provider || graph.defaults?.provider || '').trim()
|
||||
const p = MODEL_PROVIDERS.find((x) => x.name === named)
|
||||
const own = c.provider ? n.id : null
|
||||
if (named && !p?.enabled) {
|
||||
issues.push({ level: 'warning', node_id: own, field: own ? 'provider' : 'defaults.provider', code: p ? 'provider.disabled' : 'provider.missing',
|
||||
message: `「${n.data.label}」指定的模型接入「${named}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}` })
|
||||
continue
|
||||
}
|
||||
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 })
|
||||
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, 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') {
|
||||
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 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 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(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(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(page, 'off')
|
||||
check('点名的接入已停用:空选项写「已停用」,没有说明', off.blank === '默认(模型接入「demo-off」已停用)' && off.help === null, `${off.blank} · ${off.help}`)
|
||||
const plain = await look(page, 'plain')
|
||||
check('没点名接入:照旧写第一个可用的模型接入', plain.blank === '默认(使用第一个可用的模型接入)' && plain.help === '留空则使用默认模型接入',
|
||||
`${plain.blank} · ${plain.help}`)
|
||||
if (SHOTS) {
|
||||
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)
|
||||
await page.locator('[data-inspector-sheet]').screenshot({ path: `${SHOTS}/studio-model-inspector-${theme}.png` })
|
||||
}
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
|
||||
}
|
||||
|
||||
// 助手栏:存着的接入失效了,齿轮上有红点;展开后下拉照实停在存着的那一项,下面写原因
|
||||
await S(page, () => window.__studio.getState().select(null))
|
||||
const gear = page.getByRole('button', { name: '助手使用的模型' })
|
||||
await gear.waitFor({ timeout: 4000 })
|
||||
await page.waitForFunction(() => !!document.querySelector('[data-copilot-model-broken]'), null, { timeout: 4000 }).catch(() => {})
|
||||
check('存着的接入用不了:齿轮上有标记,悬停写明', await page.locator('[data-copilot-model-broken]').count() === 1
|
||||
&& (await gear.getAttribute('title')) === '助手使用的模型当前无法调用,点击查看原因', await gear.getAttribute('title'))
|
||||
await gear.click()
|
||||
const select = page.locator('#copilot-model')
|
||||
await select.waitFor({ timeout: 3000 })
|
||||
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 followText() === '跟随默认')
|
||||
const said = await page.locator('[data-copilot-model-error]').innerText().catch(() => '')
|
||||
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)
|
||||
await page.waitForTimeout(200)
|
||||
await page.locator('#copilot-model').locator('xpath=ancestor::div[contains(@class, "rounded-lg")][1]')
|
||||
.screenshot({ path: `${SHOTS}/studio-copilot-model-${theme}.png` })
|
||||
}
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
|
||||
}
|
||||
// 存着的设定列不出来时发一句话:不带模型,服务端按存着的整组取(带了模型会绕过存着的接入)
|
||||
const box = page.getByLabel('描述要生成或修改的工作流')
|
||||
await box.fill('把回答改短一些')
|
||||
await box.press('Enter')
|
||||
await until(() => state.bodies.length > 0, 3000)
|
||||
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)
|
||||
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
|
||||
&& await page.locator('[data-copilot-model-save-failed]').count() === 0
|
||||
&& await page.locator('[data-copilot-model-broken]').count() === 0
|
||||
&& 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 })
|
||||
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 })
|
||||
await blur(narrow.page)
|
||||
await narrow.page.keyboard.press('Alt+KeyA')
|
||||
const narrowGear = narrow.page.getByRole('button', { name: '助手使用的模型' })
|
||||
await narrowGear.waitFor({ timeout: 4000 })
|
||||
// 编排页在 390 宽时工具栏本身就比视口宽(与这一块无关):比的是展开前后,展开这一块不能再多撑出横向溢出
|
||||
const overflow = () => narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
const base = await overflow()
|
||||
await narrowGear.click()
|
||||
await narrow.page.locator('[data-copilot-model-error]').waitFor({ timeout: 3000 })
|
||||
const fit = await narrow.page.evaluate(() => {
|
||||
const err = document.querySelector('[data-copilot-model-error]').getBoundingClientRect()
|
||||
const sel = document.getElementById('copilot-model').getBoundingClientRect()
|
||||
return { over: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
right: Math.round(Math.max(err.right, sel.right)), vw: window.innerWidth }
|
||||
})
|
||||
check('390 宽:原因折行显示,下拉和原因都在视口里,展开这一块不多撑出横向溢出', fit.over <= base && fit.right <= fit.vw,
|
||||
JSON.stringify({ ...fit, base }))
|
||||
if (SHOTS) {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await narrow.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
|
||||
await narrow.page.waitForTimeout(200)
|
||||
await narrow.page.screenshot({ path: `${SHOTS}/studio-copilot-model-390-${theme}.png` })
|
||||
}
|
||||
}
|
||||
check('没有运行时报错', narrow.errors.length === 0, narrow.errors.slice(0, 2).join(' | '))
|
||||
await narrow.ctx.close()
|
||||
} finally {
|
||||
delete FAKES['st-model']
|
||||
delete FAKES['st-modeldef']
|
||||
delete FAKES['st-modeldm']
|
||||
}
|
||||
})
|
||||
|
||||
check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`)
|
||||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user