From 43775d531a05454bbfe5ec85e9e73c5ed77afd4d Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 6 Jul 2026 08:57:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20skill=20=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E2=80=94=E2=80=94ppt=20=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=83=AD=E5=88=87=20GLM-5.2(bump=200.39.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内置 skill 可在 frontmatter 声明 model: (ppt → glm.pro52)。 单一执行点:load_skill 命中 → run 内热切 self.llm/self.caps(下一轮生效, 记账/压缩阈值自动跟上)+ 持久化 tasks 双列 + emit model_switch 事件;切失败 降级 warn 原模型继续。send 侧档外静默降级对定向模型豁免(免费档第二条消息 不被降回 flash)。跳档位门控(产品决策);只信内置 skill(用户 skill 的 model 字段忽略,防绕门控);不自动切回。"开关"= frontmatter 那一行本身(registry 每 run 现扫,删行即停,per-skill 粒度)—— 曾实现 app_settings 表 + admin 总开关 + 建 task 预切,架构评审后砍掉(第二事实源/第二执行点),详 DESIGN §3.5。 前端:直播流 model_switch 插模型分隔线 + 顶栏下拉同步(档外定向模型临时补 option 如实显示)。新增 tests/test_skill_model_pinning.py。 Co-Authored-By: Claude Fable 5 --- DESIGN.md | 2 + PROGRESS.md | 5 +- SKILL_LIST.md | 4 +- core/__init__.py | 2 +- core/agent_builder.py | 31 ++++++ core/loop.py | 35 +++++++ core/skills.py | 7 +- skills/ppt/SKILL.md | 1 + tests/test_skill_model_pinning.py | 169 ++++++++++++++++++++++++++++++ web/app.py | 24 ++++- web/static/js/chat.js | 34 +++++- 11 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 tests/test_skill_model_pinning.py diff --git a/DESIGN.md b/DESIGN.md index 8027c45..62064ba 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -88,6 +88,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / **用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。 +**Skill 定向模型(frontmatter `model:`,2026-07-06)**:内置 skill 可在 frontmatter 声明 `model: `(如 ppt → `glm.pro52`),表示"该工作流用这个模型效果最好"。架构 = 声明 → 执行 → 兜底 → 呈现四层,**单一执行点**:① 执行 —— 对话中 `load_skill` 命中即 **run 内热切**(agent_builder 注入 switcher 闭包,loop 替换 `self.llm/self.caps`,下一轮 LLM 调用即生效;记账/压缩阈值/reasoning 都读 `self.caps` 自动跟上),并持久化 `tasks.model/model_profile`(resume / 下条 send 沿用);切失败(yaml 配错/缺 key)降级 warn、原模型继续。② 兜底 —— send 侧"档外模型静默降级"对定向模型豁免(判定:落到档外只有"管理员降档"与"skill 定向"两条路,`profile ∈ 内置 skill 定向集合`即放行)。③ 呈现 —— loop emit `model_switch` 事件,前端插模型分隔线 + 顶栏下拉同步。取舍:**定向模型是产品决策,跳过档位门控**(任何档位用户触发该 skill 都可用);**只信内置 skill**(用户 skill 的 `model` 字段忽略,否则自写 frontmatter 即绕门控);**切换不自动切回**("skill 结束"不可判定,且同 task 后续多半仍在该 skill 语境);**不设独立开关、不做建 task 时预切** —— registry 每 run 现扫 SKILL.md,frontmatter 那一行本身就是热配置(删行下个 run 停用,per-skill 粒度),全局 DB 开关是同一行为的第二事实源、建 task 预切是第二执行点,都砍(评估过 `app_settings` 开关表方案,放弃)。 + ### 3.6 Session 与 Task **Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。 diff --git a/PROGRESS.md b/PROGRESS.md index 80d9f76..9e1dd6c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-03(web 进度 dock 展开遮挡最新内容:贴底时补触底,bump 0.38.1) +最后更新:2026-07-06(skill 定向模型:ppt 触发自动热切 GLM-5.2,bump 0.39.0) --- @@ -21,6 +21,9 @@ ## 已完成关键能力 +### 2026-07-06 / skill 定向模型:ppt 触发自动热切 GLM-5.2(bump 0.39.0) +内置 skill 可在 frontmatter 声明 `model: `(ppt → `glm.pro52`)。单一执行点:对话中 `load_skill` 命中 → run 内热切(agent_builder 注入 switcher 闭包,loop 换 `self.llm/self.caps` 下一轮即生效 + 持久化 tasks 双列 + emit `model_switch` 事件),失败降级 warn 原模型继续;send 侧档外静默降级对定向模型豁免(否则免费档下条消息被降回 flash)。跳档位门控(产品决策,任何档位可用);只信内置 skill(用户 skill 的 model 字段忽略,防自写 frontmatter 绕门控);不自动切回;"开关"= frontmatter 那一行本身(registry 每 run 现扫,删行即停,per-skill 粒度)—— 曾实现 `app_settings` 表 + admin 总开关 + 建 task 预切,架构评审后砍掉(第二事实源 / 第二执行点,见 DESIGN §3.5)。前端:直播流 `model_switch` 插模型分隔线 + 顶栏下拉同步(档外定向模型临时补 option 如实显示)。新增 `tests/test_skill_model_pinning.py`(frontmatter 解析 + loop 热切)。改 core/skills.py、core/loop.py、core/agent_builder.py、web/app.py、web/static/js/chat.js、skills/ppt/SKILL.md;详 DESIGN §3.5。 + ### 2026-07-03 / web 列表状态灯挪到文件夹行左侧,数据行均匀分布(bump 0.38.8) 用户建议:状态放文件夹名左侧、时间那行正常分布。落地:终态徽章 + 运行圆点挪进文件夹行行首(`● 📁 ppt4`,行首左上区最先被扫到;无文件夹行的 task 回落到数据行行首,`syncTaskRowRunIndicator` 按同规则找 host:`.wd-line` 优先、`.meta.stats` 兜底);底部数据行只剩纯数据(skill/条/tok/时间),改 `justify-content:space-between` 均匀铺开,时间自然落行尾。改 `web/static/js/chat.js` + `web/static/dev.html`。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index 83d833b..e884e66 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-07-02(ppt skill 加渲图验收闭环 + 导出验收硬门 + 几何质检) +最后更新:2026-07-06(ppt skill 定向 GLM-5.2 模型 —— frontmatter `model:` 机制,load 时自动切换) Skill 总数:17 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -177,6 +177,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + - ⛔ 用户明确说"报告 / 文档 / 纪要"等纯文档产物 → 不走本 skill - ⚠️ 用户说"汇报 / 方案 / 材料"等产物形态不明 → **先反问** PPT 还是 Word/Markdown,确认后再 load +**定向模型**:frontmatter 定向 `glm.pro52`(GLM-5.2)—— 对话中 load 本 skill 时自动切换到该模型(不限档位,不自动切回;后续消息沿用,可在顶栏下拉手动切走)。 + **默认主题 —— 自由设计**(content-driven):按内容+受众+选定 visual_style 派生配色版式,spec 阶段给 ≥3 套候选挑;商务红/品牌色作为候选之一,用户点名或素材有 brand guideline 才锁定。 **八条对齐**(spec 阶段定稿,a–h):画布 / 页数 / 受众+核心信息+投递目的 / mode+visual_style / 配色 / 图标库 / 字体+字号 / 配图。确认后产出两份引擎契约:`design_spec.md`(人读叙事)+ `spec_lock.md`(机读执行锁,executor 每页重读、抗长 deck 漂移)。 diff --git a/core/__init__.py b/core/__init__.py index a98d7e4..031682f 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.38.8" +__version__ = "0.39.0" diff --git a/core/agent_builder.py b/core/agent_builder.py index 5146f34..799fd30 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -686,9 +686,40 @@ def build_agent( # dispatch 到 per-user 容器(其他工具仍 host)。docker 路径要求 lifespan 已 `init_pool`。 host_executor = HostExecutor(tools) executor = _resolve_executor(host_executor, uid, ur_path, working_dir_path) + + def _skill_model_switch(skill_name: str, current_profile: str): + """skill 定向模型:load_skill 后按内置 skill frontmatter `model:` 热切。 + + 只信内置 skill —— 用户 skill 可自写 frontmatter,照单全收等于绕开档位门控。 + 内置 skill 指定模型是产品决策:任何档位用户触发该 skill 都可用,不过档位门控。 + "开关"即 frontmatter 那一行本身(registry 每 run 现扫,删 `model:` 行下个 run + 就停,per-skill 粒度、无需重启);返回 None = 不切。成功时顺手持久化 + tasks.model/model_profile:下条 send / resume 沿用新模型(send 侧档外降级 + 对 skill 定向模型有对应豁免,见 web/app.py)。 + """ + sk = skills.get(skill_name) + if sk is None or sk.source != "builtin": + return None + profile = (sk.model or "").strip() + if not profile or profile == current_profile: + return None + new_caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"]) + new_llm = LLM(new_caps) # 缺 api key 会 raise → loop 捕获转 warn,不切换 + from sqlalchemy import update as _update + from core.storage import session_scope as _scope + from core.storage.models import Task as _Task + with _scope() as _s: + _s.execute( + _update(_Task) + .where(_Task.task_id == task_id) + .values(model=new_caps.model_id, model_profile=profile) + ) + return profile, new_caps, new_llm + agent = AgentLoop( llm, executor, session, caps, user_id=uid, working_dir=working_dir_path, sink=sink, + skill_model_switch=_skill_model_switch, ) if cancel_check is not None: agent.cancel_check = cancel_check diff --git a/core/loop.py b/core/loop.py index c21dad5..d73f40a 100644 --- a/core/loop.py +++ b/core/loop.py @@ -204,6 +204,7 @@ class AgentLoop: sink: Optional[Any] = None, max_iterations: Optional[int] = None, cancel_check: Optional[Callable[[], bool]] = None, + skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None, ) -> None: self.llm = llm self.executor = executor @@ -220,6 +221,10 @@ class AgentLoop: # ③ tool_calls 之间。chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」 # (几十秒)降到「单 chunk 间隔」(~100ms)。 self.cancel_check = cancel_check + # skill 定向模型(agent_builder 注入):load_skill 成功后以 (skill_name, 当前 profile) + # 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。 + # None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。 + self.skill_model_switch = skill_model_switch # 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。 self._repeat_guard = _RepeatGuard() # 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。 @@ -524,6 +529,36 @@ class AgentLoop: result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]" truncated = True + # skill 定向模型:load_skill 成功且该 skill frontmatter 指定了模型 → 热切, + # 本 run 内下一轮 LLM 即用新模型(记账/压缩阈值/reasoning 都读 self.caps,自动跟上)。 + # 切换说明追加在截断之后,不会被 16k 截掉。切失败(配错/缺 key)→ warn 后原模型继续。 + if ( + name == "load_skill" + and self.skill_model_switch is not None + and not result.startswith("[Error]") + ): + cur_profile = f"{self.caps.family}.{self.caps.variant}" + try: + switched = self.skill_model_switch(str(args.get("name", "")), cur_profile) + except Exception as e: + switched = None + self._emit({ + "type": "warn", + "msg": f"skill 定向模型切换失败,继续用 {cur_profile}: {type(e).__name__}: {e}", + }) + if switched: + new_profile, new_caps, new_llm = switched + self.caps, self.llm = new_caps, new_llm + result += ( + f"\n\n[模型切换] 该 skill 指定模型 {new_profile}," + f"已从 {cur_profile} 自动切换,本 task 后续消息也沿用 {new_profile}。" + ) + self._emit({ + "type": "model_switch", + "model_profile": new_profile, + "from": cur_profile, + }) + # 登记结果做重复检测(用截断后、未加提示的原始结果算指纹,保证同输出哈希一致)。 unproductive, productive = self._repeat_guard.record(name, args, result) if unproductive >= _RepeatGuard.SOFT: diff --git a/core/skills.py b/core/skills.py index 0baadb8..7e04eb7 100644 --- a/core/skills.py +++ b/core/skills.py @@ -50,6 +50,10 @@ class Skill: description: str skill_dir: Path source: str = "builtin" # 'builtin' | 'user' + # frontmatter 可选 `model: `:该 skill 定向模型。load_skill 命中时 + # 由 agent_builder 注入的 switcher 热切(仅内置 skill 生效 —— 用户 skill 若照单全收 + # 等于让任何人自写 frontmatter 绕开档位门控)。空 = 不定向,跟随 task 当前模型。 + model: str = "" @property def skill_md(self) -> Path: @@ -80,7 +84,8 @@ class Skill: desc = meta.get("description") or "" if not desc: raise SkillLoadError("缺 description(frontmatter 必须有 name + description)") - return cls(name=name, description=desc, skill_dir=skill_dir, source=source) + model = str(meta.get("model") or "").strip() + return cls(name=name, description=desc, skill_dir=skill_dir, source=source, model=model) @dataclass diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index ec4e379..4c9f86b 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -1,6 +1,7 @@ --- name: ppt description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck 之一。⛔ 不触发:用户明确说要"报告 / 文档 / 纪要"等指向纯文档形式的产物。⚠️ 歧义先反问:用户说"汇报 / 方案 / 材料"等产物形态不明的词、且没说成品形式时,不要直接 load 本 skill 也不要假定走文档,先反问一句"这份要做成 PPT 演示稿,还是 Word/Markdown 文档?" 用户确认 PPT 后再 load。 +model: glm.pro52 --- # PPT(SVG-first) diff --git a/tests/test_skill_model_pinning.py b/tests/test_skill_model_pinning.py new file mode 100644 index 0000000..92fff20 --- /dev/null +++ b/tests/test_skill_model_pinning.py @@ -0,0 +1,169 @@ +"""skill 定向模型(frontmatter `model:`)测试。 + +覆盖两层: +1. Skill.from_dir 解析 frontmatter 可选 `model` 字段(无 → "";有 → strip 后原样)。 +2. AgentLoop._execute_tool_call 的热切挂点:load_skill 成功且 switcher 返回新 + (profile, caps, llm) → self.caps/self.llm 替换 + 结果尾追加说明 + emit model_switch; + load_skill 报错 / switcher 抛异常 → 不切换、原模型继续。 + +switcher 本体(内置 skill 判定 + 档位豁免 + tasks 持久化)在 agent_builder 闭包里, +依赖 DB,不在此测;loop 侧只关心"给了切换指令就正确执行、失败不破坏 run"。 +""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core.loop import AgentLoop # noqa: E402 +from core.skills import Skill # noqa: E402 + + +def _write_skill(root: Path, name: str, frontmatter_extra: str = "") -> Path: + d = root / name + d.mkdir() + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: 测试用\n{frontmatter_extra}---\n\n# body\n", + encoding="utf-8", + ) + return d + + +class TestSkillModelField(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.root = Path(self.tmpdir.name) + + def tearDown(self): + self.tmpdir.cleanup() + + def test_no_model_field_defaults_empty(self): + sk = Skill.from_dir(_write_skill(self.root, "plain")) + self.assertEqual(sk.model, "") + + def test_model_field_parsed_and_stripped(self): + sk = Skill.from_dir(_write_skill(self.root, "pinned", "model: glm.pro52 \n")) + self.assertEqual(sk.model, "glm.pro52") + + +class _FakeSession: + task_id = "00000000-0000-0000-0000-000000000000" + messages: list = [] + + def append(self, msg): + return 1 + + +class _FakeExecutor: + """load_skill 返回可配置文本;其余不需要。""" + + def __init__(self, result_text: str): + self._result = result_text + + def schemas(self): + return [] + + def call_tool(self, name, args, ctx): + return SimpleNamespace(content=self._result) + + +def _fake_caps(family="deepseek_v4", variant="flash"): + return SimpleNamespace( + family=family, variant=variant, max_iterations=10, + default_reasoning_effort="", reliable_context=64000, + ) + + +def _make_loop(result_text: str, switcher) -> AgentLoop: + loop = AgentLoop( + llm=SimpleNamespace(tag="old_llm"), + executor=_FakeExecutor(result_text), + session=_FakeSession(), + capabilities=_fake_caps(), + user_id="00000000-0000-0000-0000-000000000000", + working_dir=Path("."), + skill_model_switch=switcher, + ) + loop.events = [] + loop.sink = SimpleNamespace(emit=lambda ev: loop.events.append(ev)) + return loop + + +def _load_skill_tc(skill_name="ppt"): + return SimpleNamespace( + id="tc1", + function=SimpleNamespace(name="load_skill", arguments=f'{{"name": "{skill_name}"}}'), + ) + + +class TestLoopHotSwap(unittest.TestCase): + def test_switch_applies_new_caps_and_llm(self): + new_caps = _fake_caps("glm", "pro52") + new_llm = SimpleNamespace(tag="new_llm") + calls = [] + + def switcher(skill_name, cur_profile): + calls.append((skill_name, cur_profile)) + return "glm.pro52", new_caps, new_llm + + loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher) + result, _ = loop._execute_tool_call(_load_skill_tc()) + + self.assertEqual(calls, [("ppt", "deepseek_v4.flash")]) + self.assertIs(loop.caps, new_caps) + self.assertIs(loop.llm, new_llm) + self.assertIn("[模型切换]", result) + self.assertIn("glm.pro52", result) + types = [e["type"] for e in loop.events] + self.assertIn("model_switch", types) + ms = next(e for e in loop.events if e["type"] == "model_switch") + self.assertEqual(ms["model_profile"], "glm.pro52") + self.assertEqual(ms["from"], "deepseek_v4.flash") + + def test_no_switch_when_switcher_returns_none(self): + loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None) + old_caps, old_llm = loop.caps, loop.llm + result, _ = loop._execute_tool_call(_load_skill_tc()) + self.assertIs(loop.caps, old_caps) + self.assertIs(loop.llm, old_llm) + self.assertNotIn("[模型切换]", result) + self.assertNotIn("model_switch", [e["type"] for e in loop.events]) + + def test_error_result_skips_switcher(self): + calls = [] + loop = _make_loop( + "[Error] skill 'nope' not found.", + lambda n, c: calls.append(n), + ) + loop._execute_tool_call(_load_skill_tc("nope")) + self.assertEqual(calls, []) + + def test_switcher_exception_warns_and_keeps_model(self): + def switcher(n, c): + raise FileNotFoundError("glm.yaml missing") + + loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher) + old_caps, old_llm = loop.caps, loop.llm + result, _ = loop._execute_tool_call(_load_skill_tc()) + self.assertIs(loop.caps, old_caps) + self.assertIs(loop.llm, old_llm) + self.assertNotIn("[模型切换]", result) + warns = [e for e in loop.events if e["type"] == "warn"] + self.assertTrue(any("切换失败" in e["msg"] for e in warns)) + + def test_other_tools_never_trigger_switcher(self): + calls = [] + loop = _make_loop("file content", lambda n, c: calls.append(n)) + tc = SimpleNamespace( + id="tc2", function=SimpleNamespace(name="read", arguments='{"path": "a.txt"}') + ) + loop._execute_tool_call(tc) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.py b/web/app.py index 4cbb6a9..af8a884 100644 --- a/web/app.py +++ b/web/app.py @@ -471,6 +471,25 @@ def _resolve_model_profile(profile: str, user_id: Optional[UUID] = None) -> tupl return name, caps.model_id +def _skill_pinned_profiles() -> set: + """全部内置 skill 定向的 model profile 集合(send 侧档外降级豁免用)。 + + task 落到档外模型只有两条路:管理员下调档位(该降),或 skill 定向(load_skill + 热切写入,不该降 —— 产品决策放行)。档内模型根本进不了降级分支,所以 + "profile ∈ 本集合"即可判定 skill 定向来源,无需追溯该 task 当初怎么切的。 + 只扫内置来源(与热切 switcher 的"只信内置"一致);registry 现扫 ~3ms 且只在 + "当前模型已档外"的罕见分支才调。frontmatter 删掉 `model:` 行 → 集合随之收缩, + 存量定向 task 下条消息自然落回 flash。 + """ + from core.agent_builder import load_config + from core.paths import ROOT + from core.skills import SkillRegistry, SkillSource + + cfg = load_config() + reg = SkillRegistry(SkillSource(ROOT / cfg.get("skills_dir", "skills"), "builtin")) + return {s.model for s in reg.skills.values() if s.model} + + async def _run_channel_conversation(app, uid, text, attachments, *, channel): """渠道无关的入站对话核心(§8.7):解析/建该用户该渠道常驻 task → 落盘附件 → 抢 run 锁 → _run_agent_bg → 取 assistant 回复文本。两渠道各一张会话 task,互不串扰。 @@ -2394,7 +2413,10 @@ def create_app() -> FastAPI: ).first() plan = (urow.plan if urow else "") or "" role = (urow.role if urow else "user") or "user" - if not is_allowed(cur_profile, plan, role): + # skill 定向模型豁免:档外但属内置 skill 定向(建 task 默认 / load_skill + # 热切写入)→ 不降,产品决策放行。开关关闭时集合为空 → 恢复降级,存量 + # 定向 task 自然落回 flash。 + if not is_allowed(cur_profile, plan, role) and cur_profile not in _skill_pinned_profiles(): fb_profile, fb_model_id = _resolve_model_profile(FALLBACK_MODEL_PROFILE) values["model_profile"] = fb_profile values["model"] = fb_model_id diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 4f474c2..a9a0076 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -512,9 +512,14 @@ function renderModelDropdown(t) { // 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile) if (!state.models || state.models.length === 0) return ""; const cur = t.model_profile || ""; - const opts = state.models.map(m => + let opts = state.models.map(m => `` ).join(""); + // 当前模型不在该用户档位清单里(skill 定向模型,如 ppt → glm.pro52)→ 补一项, + // 下拉如实显示;用户仍可切回档内模型。 + if (cur && !state.models.some(m => m.profile === cur)) { + opts += ``; + } return `模型`; } @@ -1750,6 +1755,33 @@ function handleSseEvent(ev, asstCard, ctx) { if (isProducer) upgradeMediaArtifacts(asstCard); } if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 + } else if (t === "model_switch") { + // skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线, + // 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。 + const prof = (ev.data && ev.data.model_profile) || ""; + if (prof) { + const dn = (state.models.find(x => x.profile === prof) || {}).display_name || prof; + closeTextSeg(ctx); + const sep = document.createElement("div"); + sep.className = "model-switch muted"; + sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;"; + sep.textContent = `── ${dn}(skill 指定)──`; + asstCard.appendChild(sep); + if (visible && state.taskMeta) { + state.taskMeta.model_profile = prof; + const sel = $("chat-model-sel"); + if (sel) { + // 档外定向模型不在该用户的下拉清单里 → 临时补一项,保持显示真实 + if (![...sel.options].some(o => o.value === prof)) { + const o = document.createElement("option"); + o.value = prof; o.textContent = dn; + sel.appendChild(o); + } + sel.value = prof; + } + $("chat-hint").textContent = `skill 指定模型 → ${dn}`; + } + } } else if (t === "cancelled") { const badge = document.createElement("div"); badge.className = "cancelled-badge";