From 87ee38556dc87097658e18aedbfda03731c8b7e1 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Sat, 12 Sep 2026 00:40:07 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(model):=20api=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20[types]=20=E6=89=8B=E5=8A=A8=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E6=A8=A1=E5=9E=8B=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 [types] 表(每行 "模型名" = "text" / "vision" / "embed"):configs/model.ts 的 ApiConnectionItem 增加 types(objectValue: any,保留原值以便精确校验),逐键校验并给出非法值 / 模型名含点号未加引号(被解析成嵌套表)的 error 日志;非法值只忽略该键,不影响整行连接 - catalog.ts:新增 DeclarableModelTag(限 text/vision/embed,不含内部排除值 gen)与 ClassifyExtra;classifyModel 优先级改为 手动声明 > 命名终值(rerank/生图/嵌入白名单) > 接口能力位(仅正向证据) > 命名能力位 > 兜底 text;手动声明即最终答案,与自动判定冲突时按声明执行、不记日志 - model.ts:ConnConfigLike 增加 modelTypes;rebuildEntries 把 manual 传给 classifyModel;新增 warnUnusedModelTypes() —— [types] 声明未命中当前列表(仅 status=ok 时判定)记一条 warning,按 connIndex:模型名 去重,reset() 清空 - 配置描述与出厂示例补 [types](写明"必须写在本行最后""模型名含 . : / 需加引号"两条坑);.ai model 帮助补一句类型声明说明 - 单测 4 项:声明生效(嵌入/识图进候选、嵌入不再进对话候选)、绝对优先(glm-4v→text、text-embedding-3-small→vision、覆盖生图白名单)、TOML 解析(大小写空格/引号键/非法值/裸键嵌套表/表头顺序错误整行跳过)、未命中声明 warning(恰好一次、命中不提示、列表未就绪不误报、忽略连接不提示) - README/docs 同步:api连接 配置手册与示例、分类优先级说明、.ai model 命令手册、catalog/model 模块说明、模型类型排查话术 --- README.md | 15 +- ...41\345\235\227\350\257\246\350\247\243.md" | 4 +- ...44\344\270\216\351\205\215\347\275\256.md" | 8 +- ...70\350\247\201\351\227\256\351\242\230.md" | 5 +- scripts/unit-test-entry.ts | 157 ++++++++++++++++++ src/changelog.ts | 6 + src/cmd/sub_cmd/model.ts | 1 + src/config/configs/model.ts | 36 +++- src/model/catalog.ts | 46 ++--- src/model/model.ts | 31 +++- 10 files changed, 276 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8a4c2b8..726a52b 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,14 @@ provider = "deepseek" # 服务商:deepseek/openai/googl api_key = "sk-xxxx" # 你的 API Key base_url = "https://api.deepseek.com/v1" # 可选,省略时取服务商默认 models = ["deepseek-v4-flash"] # 可选:钉住清单(填写后跳过自动拉取,离线/无列表接口时用);删掉该行=启动自动获取模型列表 +# [types] # 可选:手动声明模型类型,必须写在本行最后(其后不能再写 api_key 等键) +# "my-embed-1" = "embed" # 网关自命名的嵌入模型:不再被误当对话模型 +# "inhouse-vl" = "vision" # 未命中命名白名单的多模态模型:进识图候选 ``` - 未填 `models` 的连接启动时会自动获取该平台的可用模型列表(OpenAI 兼容 `GET /models`;anthropic 走 `/v1/models` 并自动翻页);获取失败按连接降级展示(认证失败/无列表接口/超时),不影响其它连接; - **模型规则** 每行是一个"用途组"请求模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,**不写任何模型名**;出厂默认给 chat/压缩/总结/judge 预设了对话参数; -- 默认模型自动取该用途**首个可用**的同类型模型(出厂 deepseek 拉取/钉住的首个文本模型 → chat 即用);要换别的模型用 `.ai model <用途> <模型>` 绑定。图片识别 / 向量记忆需要先有可被识别为视觉 / 嵌入的模型(如 `glm-4v` / `text-embedding-3-small`),再绑定到 image-understanding / text-embedding 用途; +- 默认模型自动取该用途**首个可用**的同类型模型(出厂 deepseek 拉取/钉住的首个文本模型 → chat 即用);要换别的模型用 `.ai model <用途> <模型>` 绑定。图片识别 / 向量记忆需要先有可被识别为视觉 / 嵌入的模型(如 `glm-4v` / `text-embedding-3-small`),再绑定到 image-understanding / text-embedding 用途;模型名若无法被自动识别(网关自命名、新模型等),在 **api连接** 的 `[types]` 表里手动声明 `text`/`vision`/`embed`(优先级最高,详见下方配置手册); - 常用命令:`.ai model list` 查看当前模型列表(读加载结果、不联网),`.ai model pull` 立即重拉全部连接并展示(无视 models 钉住清单,强制网络),`.ai model` 查看各用途与连接状态;`.ai balance` 可查全部连接余额(deepseek/moonshot/siliconflow 内置接口直接查,其余平台提示控制台入口,见下方[可用AI大模型开放平台列表](#可用ai大模型开放平台列表)的余额说明); - `anthropic`(Claude)已适配请求/响应格式(system 拆出、tool_result 合并、响应归一化);其流式暂不支持,配置 `stream = true` 时会自动回退为非流式。 @@ -166,7 +169,7 @@ AI骰娘4 是一款运行在 [SealDice](https://docs.sealdice.com/) 上的智能 | 设置项 | 说明 | |:---:|:---| -| api连接 | TOML 格式,每行一个服务商连接。`api_key` 必填;`provider` 选填(省略时按 OpenAI 兼容处理,此时需显式填 `base_url`);`base_url` 可选(省略时取该 provider 默认地址)。可选 `models`(模型钉住清单:填写后跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商);可选 `[request]`(列表拉取覆盖:list_url/auth_header_name/headers/timeout)。未填 `models` 的连接启动时自动获取可用模型列表(OpenAI 兼容 `GET /models`;anthropic 走 `/v1/models` 自动翻页),失败按连接降级展示,不拖垮其它连接。`ignore` 可选:1=忽略该连接 | +| api连接 | TOML 格式,每行一个服务商连接。`api_key` 必填;`provider` 选填(省略时按 OpenAI 兼容处理,此时需显式填 `base_url`);`base_url` 可选(省略时取该 provider 默认地址)。可选 `models`(模型钉住清单:填写后跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商);可选 `[types]`(**手动声明模型类型**:每行 `"模型名" = "text"` / `"vision"` / `"embed"`,优先级最高,可覆盖命名猜测与接口能力位;模型名含 `.` `:` `/` 等字符必须加引号;**必须写在该行最后**,其后不能再写 `api_key` 等键,否则整行解析失败;无效值只忽略该键并记日志);可选 `[request]`(列表拉取覆盖:list_url/auth_header_name/headers/timeout)。未填 `models` 的连接启动时自动获取可用模型列表(OpenAI 兼容 `GET /models`;anthropic 走 `/v1/models` 自动翻页),失败按连接降级展示,不拖垮其它连接。`ignore` 可选:1=忽略该连接 | | 模型规则 | TOML 格式,每行一个"用途组"模板:`use` 数组(`chat`/`compression`/`summarization`/`judge`/`image-understanding`/`text-embedding`,可多选)+ 可选 `[body]`(请求参数模板)+ 可选 `[request]`(method/url/headers/content_type/auth_header_name/timeout,默认不写由插件解析)。**不写模型名**:命中这些用途的模型统一套用该模板;多条规则 use 重叠时按行序逐键合并、后覆盖先 | ```toml @@ -176,6 +179,10 @@ api_key = "sk-xxxx" base_url = "https://api.deepseek.com/v1" models = ["deepseek-v4-flash"] # 可选:钉住清单;删掉该行=启动自动拉取 +# [types] # 可选:手动声明模型类型,必须写在本行最后(其后不能再写 api_key 等键) +# "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号 +# "inhouse-vl" = "vision" # 声明未命中当前模型列表时会在日志里给一条 warning 提示(检查拼写) + # 模型规则 示例(同一行内只保留一组字段) use = ["chat", "compression", "summarization", "judge"] @@ -183,7 +190,7 @@ use = ["chat", "compression", "summarization", "judge"] temperature = 1 ``` -> 模型来自「api连接」的自动拉取或 `models` 钉住清单,并按能力分类进各用途候选:文本类进对话候选;带明确视觉标签的模型(如 glm-4v/gemini/pixtral,或接口返回视觉能力位)进识图与对话候选;嵌入白名单命名(如 `text-embedding-*`/`bge-*`/`gemini-embedding-*`)进嵌入候选;生图/reranker 类不进任何候选。默认模型自动取该用途第一个候选(首个可用的同类型模型);想换别的模型用全局覆盖指定。 +> 模型类型判定优先级:**`[types]` 手动声明 > 命名终值(reranker/生图/嵌入白名单) > 接口自报能力位 > 命名能力位 > 兜底纯文本**(手动声明即最终答案,与自动判定冲突时按声明执行)。模型来自「api连接」的自动拉取或 `models` 钉住清单,并按能力分类进各用途候选:文本类进对话候选;带明确视觉标签的模型(如 glm-4v/gemini/pixtral,或接口返回视觉能力位)进识图与对话候选;嵌入白名单命名(如 `text-embedding-*`/`bge-*`/`gemini-embedding-*`)进嵌入候选;生图/reranker 类不进任何候选。默认模型自动取该用途第一个候选(首个可用的同类型模型);想换别的模型用全局覆盖指定。 > > 全局分用途覆盖:`.ai model` 查看各用途当前模型与连接状态;`.ai model list` 查看加载/最近一次拉取到内存的模型列表(不联网、不持久化,按 `[连接序号]` 分组);`.ai model pull` 立即重拉全部连接并展示(无视 models 钉住清单,强制网络);`.ai model <用途>` 查看指定用途候选;`.ai model <用途> <模型>` 设置(支持编号 / 裸名唯一 / `[序号]:模型名` 精确,重名歧义会提示;覆盖失效自动回退默认)。`.ai model <模型名>` 兼容为设置 chat 用途。 > @@ -449,7 +456,7 @@ platform: [] # 可选:[] / 省略 = 所有平台;例如 [QQ, DISC | `.ai off [--r/--j/--c/--t/--p/--a]` | `.ai off --t` | 关闭 AI(含非指令正则触发),加参数只关闭对应模式 | | `.ai fgt [assistant/user]` | - | 遗忘当前上下文;assistant 为遗忘 AI 发言与函数调用,user 为遗忘用户发言与函数返回 | | `.ai role [<名称>]` | - | 查看 / 切换角色设定 | -| `.ai model [list\|pull\|<用途> [<模型>]]` | `.ai model chat deepseek-v4-flash` | 查看 / 绑定全局分用途模型(骰主):无参数查看各用途与连接状态;`.ai model list` 查看加载/最近一次拉取到内存的模型列表(不联网,按 `[连接序号]` 分组);`.ai model pull` 立即重拉全部连接并展示(无视 models 钉住清单,强制网络);`.ai model <用途>` 查看指定用途候选;`.ai model <用途> <模型>` 设置全局覆盖(支持编号 / 裸名唯一 / `[序号]:模型名`,重名歧义会提示);旧写法 `.ai model <模型名>` 等价于设置 chat 用途 | +| `.ai model [list\|pull\|<用途> [<模型>]]` | `.ai model chat deepseek-v4-flash` | 查看 / 绑定全局分用途模型(骰主):无参数查看各用途与连接状态;`.ai model list` 查看加载/最近一次拉取到内存的模型列表(不联网,按 `[连接序号]` 分组);`.ai model pull` 立即重拉全部连接并展示(无视 models 钉住清单,强制网络);`.ai model <用途>` 查看指定用途候选;`.ai model <用途> <模型>` 设置全局覆盖(支持编号 / 裸名唯一 / `[序号]:模型名`,重名歧义会提示);旧写法 `.ai model <模型名>` 等价于设置 chat 用途。模型类型自动判定,可用「api连接」的 `[types]` 手动声明(text/vision/embed,优先级最高) | | `.ai balance` | - | 并发查询全部(非忽略)api连接的账户余额(骰主):deepseek / moonshot / siliconflow 内置余额接口直接查;其余平台提示控制台入口;one-api/new-api 等网关可在连接 `[request]` 配置 `balance_url` + `balance_json_path` 后查询 | | `.ai stop` | - | 完全暂停当前对话(打断流式输出/工具链/排队请求,清计时器) | | `.ai subagent` / `.ai subagent list` | - | 查看本会话子代理运行情况:列出 ID / 用途 / 状态 | diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index 4e389f3..d5ab739 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -77,8 +77,8 @@ ## 模型层(src/model/) -- `model.ts`:`Model` 静态注册表(`states` 连接状态 + `entries` 模型实例)与全局分用途覆盖 `purposeModelOverrides`(use → 模型标识:全注册表唯一名 → 裸名,跨连接重名 → `[连接序号]:模型名`;由 `.ai model` 写入 `modelPurposeOverrides` 存储键)。模型实例来自「api连接」的 `models` 钉住清单(pinned)/启动自动拉取(auto),**只存内存、不持久化**,按能力标签(`catalog.ts`)分类进各用途候选。`getChatModel(use)`/`getMultimodalModel(use)`/`getEmbeddingModel(use)` 先按用途查覆盖(覆盖失效自动回退),再取「该用途第一个候选」为默认;`findModelByRef` 按标识反查;`listModelsForUse(use)` 返回候选列表(`.ai model` 与余额不足自动切模型共用);`ensureLoaded()/pull()` 供首条消息等待与 `.ai model pull` 立即重拉(无视 models 钉住清单,强制网络);`reset()` 供测试/热重载。 -- `catalog.ts`:模型能力分类(text/vision/embed/gen)——embedding 白名单命名(如 text-embedding-*/bge-*/gemini-embedding-*)、视觉白名单与接口能力位(glm-4v/gemini/pixtral 等)、生图/reranker 排除。 +- `model.ts`:`Model` 静态注册表(`states` 连接状态 + `entries` 模型实例)与全局分用途覆盖 `purposeModelOverrides`(use → 模型标识:全注册表唯一名 → 裸名,跨连接重名 → `[连接序号]:模型名`;由 `.ai model` 写入 `modelPurposeOverrides` 存储键)。模型实例来自「api连接」的 `models` 钉住清单(pinned)/启动自动拉取(auto),**只存内存、不持久化**,按能力标签(`catalog.ts`:手动 `[types]` 声明 > 命名终值 > 接口能力位 > 命名能力位 > 兜底 text)分类进各用途候选;`rebuildEntries()` 重建注册表时会对「声明了但当前列表里不存在」的 `[types]` 条目记一条 warning(模型名拼错不再静默失效)。`getChatModel(use)`/`getMultimodalModel(use)`/`getEmbeddingModel(use)` 先按用途查覆盖(覆盖失效自动回退),再取「该用途第一个候选」为默认;`findModelByRef` 按标识反查;`listModelsForUse(use)` 返回候选列表(`.ai model` 与余额不足自动切模型共用);`ensureLoaded()/pull()` 供首条消息等待与 `.ai model pull` 立即重拉(无视 models 钉住清单,强制网络);`reset()` 供测试/热重载。 +- `catalog.ts`:模型能力分类(text/vision/embed/gen)——判定优先级 **手动声明(「api连接」`[types]`) > 命名终值(reranker/生图/嵌入白名单,如 text-embedding-*/bge-*/dall-e) > 接口自报能力位(仅正向证据:vision/embed) > 命名能力位(视觉白名单 glm-4v/gemini/pixtral 等) > 兜底 text**;手动声明即最终答案(与自动判定冲突时按声明执行,不记日志)。`DeclarableModelTag` 限定用户可声明值为 text/vision/embed(内部排除值 gen 不可声明);`classifyModel(provider, name, extra)` 的 `extra` 承载 manual 与接口能力位;当前每条分支恰好产出 1 个标签(数组形态沿用历史结构)。 - `request_rules.ts`:模型规则(use 组 body/request 模板)存储与合并;`bodyDefaultsFor(use)` 返回「代码兜底默认 < 规则模板(行序后覆盖先)」的请求体,`requestOverridesFor(use)` 返回调用期 `[request]` 覆盖(headers/auth_header_name/content_type/timeout)。 - `list.ts`:连接级「获取可用模型列表」适配——OpenAI 兼容 `GET {base}/models`(Bearer);anthropic 走 `x-api-key` + `/v1/models` 并 `limit=1000` 循环翻页;支持连接 `[request]`(list_url/auth_header_name/headers/timeout)覆盖;拉取函数可注入便于单测。 - `balance.ts`:连接级账户余额查询(.ai balance 用)——deepseek/moonshot/siliconflow 内置余额端点;其余平台支持在连接 `[request]` 配置 `balance_url`/`balance_json_path`(+balance_divisor/balance_currency)后查询自定义网关(one-api/new-api 等)。 diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index 38a1493..b13c6f2 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -113,7 +113,7 @@ | 分组 | 常用键名 | 说明 | | --- | --- | --- | | 基础 | 日志级别、请求超时时限、请求并发上限、请求队列上限、是否开启全局待机 | 全局开关 | -| 模型 | api连接 / 模型规则 | 两个 TOML 配置。`api连接` 每行一个连接:`api_key` 必填,`provider`/`base_url` 选填(provider 省略时按 OpenAI 兼容处理、需显式填 base_url;base_url 省略时取 provider 默认);可选 `models`(模型钉住清单,填写后跳过自动拉取)与 `[request]`(列表与余额查询覆盖:list_url/auth_header_name/headers/timeout 及 balance_url/balance_json_path/balance_divisor/balance_currency)。未钉住连接启动时自动获取模型列表(OpenAI 兼容 GET /models、anthropic 走 /v1/models 自动翻页),失败按连接降级展示(auth/无列表接口/超时),不影响其它连接。`模型规则` 每行一个用途组模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,不写任何模型名,命中这些用途的模型统一套用。默认模型取该用途第一个可用模型自动生效;`.ai model list`(读加载/最近一次拉取到内存的列表,不联网)、`.ai model pull`(立即重拉全部并展示,无视 models 钉住清单强制网络)、`.ai model <用途> <模型>`(绑定,支持编号/裸名/`[序号]:模型名`,覆盖失效自动回退默认)。列表只存内存、不持久化。修改后需重载 JS 生效 | +| 模型 | api连接 / 模型规则 | 两个 TOML 配置。`api连接` 每行一个连接:`api_key` 必填,`provider`/`base_url` 选填(provider 省略时按 OpenAI 兼容处理、需显式填 base_url;base_url 省略时取 provider 默认);可选 `models`(模型钉住清单,填写后跳过自动拉取)、可选 `[types]`(**手动声明模型类型**:每行 `"模型名" = "text"` / `"vision"` / `"embed"`,优先级最高,可覆盖命名猜测与接口能力位;模型名含 `.` `:` `/` 等字符必须加引号;**必须写在该行最后**,其后不能再写 `api_key` 等键,否则整行解析失败;无效值只忽略该键并记日志)与 `[request]`(列表与余额查询覆盖:list_url/auth_header_name/headers/timeout 及 balance_url/balance_json_path/balance_divisor/balance_currency)。未钉住连接启动时自动获取模型列表(OpenAI 兼容 GET /models、anthropic 走 /v1/models 自动翻页),失败按连接降级展示(auth/无列表接口/超时),不影响其它连接。`模型规则` 每行一个用途组模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,不写任何模型名,命中这些用途的模型统一套用。默认模型取该用途第一个可用模型自动生效;`.ai model list`(读加载/最近一次拉取到内存的列表,不联网)、`.ai model pull`(立即重拉全部并展示,无视 models 钉住清单强制网络)、`.ai model <用途> <模型>`(绑定,支持编号/裸名/`[序号]:模型名`,覆盖失效自动回退默认)。列表只存内存、不持久化。修改后需重载 JS 生效 | | 角色设定 | 角色扮演设定 | 每条第一行为角色设定名称(>20 截断),其余为设定内容;`$gSYSPROMPT` / `.ai role` 选择;修改后需重载 JS 生效 | | 上下文 | 上下文最大token、对话保存轮数、预设上下文、插入system message间隔轮数、消息压缩阈值 | 上下文最大token 默认 1000000,0/负数无效并回退默认;超过后保留最近「对话保存轮数」真实用户轮,更早消息先归档沉淀为观察/长期记忆再删除;预设上下文 role 按 user/assistant 轮流;插入间隔需小于保存轮数二分之一才生效 | | 消息接收 | 接收图片/指令消息/骰子发送的消息、忽略消息豹语条件、忽略消息正则表达式 | 接收图片只负责记录图片 URL;自动识别仍由图片识别条件和识图模型配置决定 | @@ -143,6 +143,10 @@ base_url = "https://api.deepseek.com/v1" # 可选,缺省取服务商默认 # models = ["deepseek-v4-flash"] # 可选:填写后跳过自动拉取,直接用该清单(离线/无列表接口时用);删掉该行=启动自动拉取 # ignore = 1 # 可选:1=忽略该连接 +# [types] # 可选:手动声明模型类型,优先级最高;必须写在本行最后(其后不能再写 api_key 等键) +# "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号 +# "inhouse-vl" = "vision" # 声明未命中当前模型列表时,日志给一条 warning 提示(检查模型名拼写) + # ── 模型规则:每行一个用途组模板,不写模型名;命中这些用途的模型统一套用 ── use = ["chat", "compression", "summarization", "judge"] @@ -150,6 +154,6 @@ use = ["chat", "compression", "summarization", "judge"] temperature = 1 ``` -`模型规则` 的 `use` 可填 `chat`(普通对话)/`compression`(消息压缩)/`summarization`(记忆总结)/`judge`(评分插话判断)/`image-understanding`(识图)/`text-embedding`(嵌入),可多选,不同行建议不重叠(重叠按行序逐键合并、后覆盖先)。`[request]` 可选:method/url/headers/content_type/auth_header_name/timeout,默认不写由插件按 provider 解析。模型本身来自「api连接」的自动拉取或 `models` 钉住清单:文本类进对话候选、带明确视觉标签(如 glm-4v/gemini/pixtral 或接口能力位)的进识图候选、嵌入白名单(如 text-embedding-*/bge-*)进嵌入候选、生图类(reranker/dall-e/cogview 等)不进任何候选。默认模型自动取该用途首个可用同类型模型(文本→对话类用途,视觉→识图,嵌入→嵌入);想换用 `.ai model <用途> <模型>` 显式绑定。嵌入输出维度取 text-embedding 规则 `[body] dimensions`(默认 1024),配置后长期记忆与知识库启用语义检索,未配置/不匹配自动降级为关键词检索。 +`模型规则` 的 `use` 可填 `chat`(普通对话)/`compression`(消息压缩)/`summarization`(记忆总结)/`judge`(评分插话判断)/`image-understanding`(识图)/`text-embedding`(嵌入),可多选,不同行建议不重叠(重叠按行序逐键合并、后覆盖先)。`[request]` 可选:method/url/headers/content_type/auth_header_name/timeout,默认不写由插件按 provider 解析。**模型类型判定优先级**:`[types]` 手动声明 > 命名终值(reranker/生图/嵌入白名单) > 接口自报能力位 > 命名能力位 > 兜底纯文本(手动声明即最终答案,与自动判定冲突时按声明执行)。模型本身来自「api连接」的自动拉取或 `models` 钉住清单:文本类进对话候选、带明确视觉标签(如 glm-4v/gemini/pixtral 或接口能力位)的进识图候选、嵌入白名单(如 text-embedding-*/bge-*)进嵌入候选、生图类(reranker/dall-e/cogview 等)不进任何候选。默认模型自动取该用途首个可用同类型模型(文本→对话类用途,视觉→识图,嵌入→嵌入);想换用 `.ai model <用途> <模型>` 显式绑定。嵌入输出维度取 text-embedding 规则 `[body] dimensions`(默认 1024),配置后长期记忆与知识库启用语义检索,未配置/不匹配自动降级为关键词检索。 diff --git "a/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" index 3d07857..5ab8ebc 100644 --- "a/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -24,6 +24,7 @@ - 简单配置(开关/数值/单行字符串/纯字符串数组)修改后自动生效(缓存最多 1 分钟,无需重载 JS);复杂配置(模型「api连接/模型规则」、触发/忽略正则、评分触发、角色扮演设定、MCP 总开关与服务器、技能、知识库、本地资源路径、音乐服务)启动时解析一次、常驻内存,修改后需重载 JS 才生效(其中「MCP服务器配置」「技能配置」「知识库」三类也可用 `.ai mcp refresh` / `.ai skill refresh` / `.ai kb refresh` 立即生效)。 - 嵌入输出维度取自「模型规则」text-embedding 用途组的 `[body] dimensions`(默认 1024,须与后端一致,如 text-embedding-v4 为 1024);未配置维度时记忆检索自动降级为关键词/分数检索、知识库检索自动降级为关键词匹配(嵌入仅作候选重排,加载知识库本身不请求嵌入)。 - 流式输出需要自建或使用公共后端,并在"后端 → 流式输出"配置 URL;在「模型规则」chat 用途组的 `[body]` 里设 `stream = true` 的模型才会走流式。 +- 模型类型识别顺序:「api连接」`[types]` **手动声明** → 命名终值(reranker/生图/嵌入白名单) → 接口自报能力位(vision/embed) → 命名能力位 → 兜底纯文本。网关自命名模型(如 `my-embed-1`、`inhouse-vl`)不会被自动识别,需用 `[types]` 手动声明 `text`/`vision`/`embed`,否则会被当纯文本模型、甚至被选为默认对话模型。声明写在该行最后、模型名含 `.` `:` `/` 时加引号;声明名未命中当前模型列表时日志会有一条 warning 提示(便于发现拼写错误)。 - `请求超时时限` 同时约束模型请求与工具调用,过小会导致长回复/慢工具超时。 ## 常见问题排查 @@ -51,14 +52,14 @@ AI 回复中的字面量 `\n` 或 `\r\n` 会在发送前转换为真实换行; ### 记忆/知识库检索不到 -- 记忆检索:确认已有嵌入模型可用(「api连接」含嵌入类模型,且 `.ai model text-embedding` 已绑定或存在可用默认),text-embedding 规则 `[body] dimensions` 与后端输出一致(默认 1024),且"启用长期记忆"开关打开;检索有内置相似度下限(0.8)过滤,条目太旧(衰减)或相似度过低不会展示。 +- 记忆检索:确认已有嵌入模型可用(「api连接」含嵌入类模型,且 `.ai model text-embedding` 已绑定或存在可用默认),text-embedding 规则 `[body] dimensions` 与后端输出一致(默认 1024),且"启用长期记忆"开关打开;检索有内置相似度下限(0.8)过滤,条目太旧(衰减)或相似度过低不会展示。模型名无法被自动识别为嵌入时(网关自命名),在「api连接」用 `[types]` 声明 `"模型名" = "embed"`。 - 知识库:"启用知识库记忆"开关打开后,内容来自"知识库"配置(Markdown 模板,每条一份完整文档,`#` 条目标题、`##`/`###` 小节,超长自动分块);不按角色加载,修改配置后需重载 JS 生效,可让 AI 通过 knowledge_search / knowledge_read 工具检索验证;嵌入未配置时自动降级为关键词匹配,不影响基本检索。 ### 图片识别异常 - 确认图片 URL 可在浏览器访问(过期或 QQ 图床 bug 时更换协议端版本)。 - 模型不支持 QQ 图床时,把"识别图片时将url转换为base64"设为"总是"或"自动"。 -- 图片转文字依赖视觉模型:先在「api连接」配置含视觉模型的连接(自动拉取或 models 钉住,模型需带"识图"标签,如 glm-4v/gemini/pixtral),再用 `.ai model image-understanding <模型>` 绑定。 +- 图片转文字依赖视觉模型:先在「api连接」配置含视觉模型的连接(自动拉取或 models 钉住,模型需带"识图"标签,如 glm-4v/gemini/pixtral;未命中命名白名单的模型用 `[types]` 声明 `"模型名" = "vision"`),再用 `.ai model image-understanding <模型>` 绑定。 ### HTTP 请求出错 diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index dd2c497..727c1bc 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -51,6 +51,7 @@ import { ARCHIVE_CHUNK_TOKENS, buildRoundSegments, Context, dropOldestRound, est import Agent from "../src/agent/agent"; import { streamService } from "../src/agent/stream"; import Model from "../src/model/model"; +import Logger from "../src/logger"; import { resetModelConfigCacheForTest, setModelListDepsForTest } from "../src/config/configs/model"; import { Session } from "../src/session/session"; import { JudgeManager } from "../src/judge/judge_manager"; @@ -246,6 +247,11 @@ function pinConn(provider: string, models: string[], baseUrl = 'https://x', apiK return { provider, apiKey, baseUrl, ignore: false, models, request: {} }; } +/** 构造一条带 [types] 手动类型声明的 pinned 连接 */ +function pinConnTyped(provider: string, models: string[], modelTypes: Record, baseUrl = 'https://x', apiKey = 'k'): any { + return { provider, apiKey, baseUrl, ignore: false, models, modelTypes, request: {} }; +} + /** 播种 pinned 连接(可选带规则模板)并复位注册表 */ function seedPinnedConns(conns: any[], rules: any[] = []) { Model.reset(); @@ -3905,6 +3911,157 @@ export const tests: Record void | Promise> = { } }, + /** 模型类型手动声明(api连接 [types]):声明后立即进对应用途候选,嵌入模型不再误入对话候选 */ + testModelTypesManualDeclaration(): void { + try { + // 未声明:网关自命名模型(名字不含 embed/vision 等关键词)全部归 text,嵌入模型被误用为对话模型、嵌入/识图无候选 + seedPinnedConns([pinConn('custom', ['gw-vec-1', 'gw-vl-1', 'gw-chat-1'])]); + assert.equal(Model.entries.every(e => e.tags.includes('text')), true, '未声明时应全部归 text'); + assert.equal(Model.getEmbeddingModel('text-embedding'), null); + assert.equal(Model.getMultimodalModel('image-understanding'), null); + assert.equal(Model.getChatModel('chat')?.name, 'gw-vec-1', '未声明时首个候选被误用为对话模型'); + + // 声明后:嵌入/识图各有候选,对话候选只剩纯文本 + seedPinnedConns([pinConnTyped('custom', ['gw-vec-1', 'gw-vl-1', 'gw-chat-1'], { + 'gw-vec-1': 'embed', + 'gw-vl-1': 'vision', + 'gw-chat-1': 'text', + })]); + const embed = Model.getEmbeddingModel('text-embedding'); + assert.equal(embed?.name, 'gw-vec-1', 'embed 声明应进嵌入候选'); + assert.equal(embed?.tags.includes('embed'), true); + assert.equal(Model.getChatModel('chat')?.name, 'gw-vl-1', '对话候选按列表顺序取首个非嵌入/非生图模型(vision 可当对话模型)'); + assert.equal(Model.listModelsForUse('chat').some(m => m.name === 'gw-vec-1'), false, '嵌入声明后不得再进对话候选'); + const vl = Model.getMultimodalModel('image-understanding'); + assert.equal(vl?.name, 'gw-vl-1', 'vision 声明应进识图候选'); + assert.equal(vl?.isMultimodal, true, 'vision 声明应让模型按多模态处理'); + } finally { + Model.reset(); + } + }, + + /** 手动声明绝对优先:可把视觉模型声明为纯文本、把嵌入模型声明为多模态(两个方向都成立) */ + testModelTypesManualAbsolutePriority(): void { + try { + // 方向一:视觉模型 → text(摘出识图候选,仍可作对话模型) + seedPinnedConns([pinConnTyped('zhipu', ['glm-4v'], { 'glm-4v': 'text' })]); + assert.equal(Model.entries[0].tags.includes('text'), true, '声明应覆盖命名视觉白名单'); + assert.equal(Model.getMultimodalModel('image-understanding'), null, '声明 text 应摘出识图候选'); + assert.equal(Model.getChatModel('chat')?.name, 'glm-4v', '声明 text 后仍可作对话模型'); + + // 方向二:嵌入模型 → vision(摘出嵌入候选,进识图与对话候选) + seedPinnedConns([pinConnTyped('openai', ['text-embedding-3-small'], { 'text-embedding-3-small': 'vision' })]); + assert.equal(Model.getEmbeddingModel('text-embedding'), null, '声明 vision 应摘出嵌入候选'); + assert.equal(Model.getMultimodalModel('image-understanding')?.name, 'text-embedding-3-small'); + assert.equal(Model.getChatModel('chat')?.name, 'text-embedding-3-small', 'vision 声明可作对话候选'); + + // 方向三:覆盖生图/排序等命名终值白名单 + seedPinnedConns([pinConnTyped('openai', ['dall-e-3', 'bge-reranker-v2'], { 'dall-e-3': 'text', 'bge-reranker-v2': 'text' })]); + assert.equal(Model.getChatModel('chat')?.name, 'dall-e-3', '手动声明应覆盖生图白名单'); + assert.equal(Model.getChatModel('compression')?.name, 'dall-e-3'); + } finally { + Model.reset(); + } + }, + + /** [types] 解析:合法值生效(大小写/空格/带点号引号键)、非法值只忽略该键、TOML 顺序坑整行跳过 */ + testModelTypesParsingAndInvalidValues(): void { + const orig = TC.templateConfigs['api连接']; + const origRules = TC.templateConfigs['模型规则']; + const loadConn = () => { + resetConfigCache(); + (Config as any).model; + }; + try { + // 1) 合法值:大小写与空格容错;含点号的模型名用引号键(该名字本身不含任何关键词,只能靠声明进候选) + TC.templateConfigs['api连接'] = [ + 'api_key = "k"\nprovider = "custom"\nbase_url = "https://gw/v1"\nmodels = ["gw-vec-1", "gw.model.v1", "plain"]\n\n[types]\n"gw-vec-1" = " EMBED "\n"gw.model.v1" = "Vision"\n"plain" = "text"', + ]; + TC.templateConfigs['模型规则'] = []; + loadConn(); + assert.equal(Model.states[0].status, 'ok'); + assert.equal(Model.getEmbeddingModel('text-embedding')?.name, 'gw-vec-1', '大小写/空格应容错'); + assert.equal(Model.getMultimodalModel('image-understanding')?.name, 'gw.model.v1', '带点号的模型名用引号键应生效'); + assert.equal(Model.getChatModel('chat')?.name, 'gw.model.v1', '对话候选按列表顺序(vision 可当对话模型)'); + assert.equal(Model.listModelsForUse('chat').some(m => m.name === 'gw-vec-1'), false, '嵌入声明后不得再进对话候选'); + + // 2) 非法值:只忽略该键,整行连接仍可用(该模型回退命名猜测) + TC.templateConfigs['api连接'] = [ + 'api_key = "k"\nprovider = "custom"\nbase_url = "https://gw/v1"\nmodels = ["a", "b"]\n\n[types]\n"a" = "多模态"\n"b" = "vision"', + ]; + loadConn(); + assert.equal(Model.states[0].status, 'ok', '非法 [types] 值不应影响整行解析'); + assert.equal(Model.getMultimodalModel('image-understanding')?.name, 'b'); + assert.equal(Model.entries.find(e => e.name === 'a')?.tags.includes('text'), true, '非法值该键被忽略'); + + // 3) 裸键带点号 → TOML 解析成嵌套表:该键不生效(回退命名猜测),但连接正常(仅记 error 日志) + TC.templateConfigs['api连接'] = [ + 'api_key = "k"\nprovider = "custom"\nbase_url = "https://gw/v1"\nmodels = ["gw.model.v2"]\n\n[types]\ngw.model.v2 = "embed"', + ]; + loadConn(); + assert.equal(Model.states[0].status, 'ok'); + assert.equal(Model.getEmbeddingModel('text-embedding'), null, '裸键带点号不生效'); + assert.equal(Model.getChatModel('chat')?.name, 'gw.model.v2', '该模型回退为 text 猜测'); + + // 4) [types] 写在标量键之前 → api_key/models 被吞进 types 表 → 整行因缺 api_key 被跳过 + TC.templateConfigs['api连接'] = [ + 'provider = "custom"\nbase_url = "https://gw/v1"\n\n[types]\n"a" = "embed"\napi_key = "k"\nmodels = ["a"]', + ]; + loadConn(); + assert.equal(Model.states.length, 0, 'TOML 顺序错误应整行跳过'); + } finally { + if (orig === undefined) delete TC.templateConfigs['api连接']; else TC.templateConfigs['api连接'] = orig; + if (origRules === undefined) delete TC.templateConfigs['模型规则']; else TC.templateConfigs['模型规则'] = origRules; + resetModelConfigCacheForTest(); + setModelListDepsForTest(); + resetConfigCache(); + Model.reset(); + } + }, + + /** [types] 声明未命中模型列表:打一行 warning(同一声明只打一次),列表未就绪/已命中时不提示 */ + testModelTypesUnusedDeclarationWarning(): void { + const origEmit = (Logger as any).emit; + const warnings: string[] = []; + (Logger as any).emit = (level: string, ...data: any[]) => { + if (level !== '警告') return; + warnings.push(data.map(d => typeof d === 'string' ? d : JSON.stringify(d)).join(' ')); + }; + try { + // 1) 拼写错误的声明 → 恰好一条 warning,且含声明的模型名 + seedPinnedConns([pinConnTyped('custom', ['real-model'], { 'ghost-model': 'embed' })]); + assert.equal(warnings.length, 1, '未命中的声明应恰好提示一次'); + assert.ok(warnings[0].includes('ghost-model'), '提示应含声明的模型名'); + + // 2) 重建/重拉不重复提示 + Model.rebuildEntries(); + assert.equal(warnings.length, 1, '同一进程内同一声明不重复提示'); + + // 3) 命中的声明不提示 + warnings.length = 0; + seedPinnedConns([pinConnTyped('custom', ['real-model'], { 'real-model': 'embed' })]); + assert.equal(warnings.length, 0, '命中的声明不应提示'); + + // 4) 列表未就绪(拉取中/失败)不误报 + warnings.length = 0; + Model.reset(); + Model.bootstrap([{ + provider: 'openai', apiKey: 'k', baseUrl: 'https://o', + ignore: false, models: null, modelTypes: { 'x': 'embed' }, request: {}, + }], [], { fetch: async () => { throw new Error('boom'); } }); + assert.equal(Model.states[0].status, 'pending', '拉取完成前不判定'); + assert.equal(warnings.length, 0, '列表未就绪时不应误报'); + + // 5) ignore=1 的忽略连接不提示 + warnings.length = 0; + seedPinnedConns([{ provider: 'custom', apiKey: 'k', baseUrl: 'https://x', ignore: true, models: ['real-model'], modelTypes: { 'ghost': 'embed' }, request: {} }]); + assert.equal(warnings.length, 0, '忽略连接不应提示'); + } finally { + (Logger as any).emit = origEmit; + Model.reset(); + } + }, + /** Agent.chat:多模态 user content 为内容块数组、纯文本为字符串;请求层收到解析出的模型实例 */ async testAgentChatPassesResolvedModel(): Promise { const origSend = (streamService as any).sendChatRequest; diff --git a/src/changelog.ts b/src/changelog.ts index 72eac84..ed8ba37 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -1,6 +1,12 @@ // 版本更新日志(changelog),供启动时展示更新说明 // 版本更新日志,格式为 "版本号": "更新内容",版本号格式为 "x.y.z",按照时间顺序从新到旧排列。 export const changelog: { [version: string]: string } = { + "4.23.0": `## 新功能 +- 模型类型可手动声明:「模型」页 api连接 新增可选 [types] 表(每行 "模型名" = "text" / "vision" / "embed"),**优先级最高**,可覆盖命名猜测——网关自命名的嵌入模型不再被误当对话模型、未命中命名白名单的多模态模型也能进识图候选;模型名含 . : / 等字符需加引号,[types] 必须写在该行最后(其后不能再写 api_key 等键),无效值只忽略该键并记日志 +- 手动类型声明未命中当前模型列表时(模型名拼写错误 / 已移出清单)记一条 warning 提示,不再静默失效 +## 配置变更 +- 「模型」页 api连接:新增可选 [types] 表(模型名 → text/vision/embed,优先级最高) +`, "4.22.0": `## 新功能 - 模型配置 v4:模型配置改为两个 TOML ——「模型」页的 **api连接**(每行一个服务商连接:api_key 必填;provider 选填,省略按 OpenAI 兼容处理(此时需显式填 base_url);base_url 选填,省略取该服务商默认;可选 models 钉住清单(填写=跳过自动拉取,离线/无列表接口时用);可选 ignore(1=忽略该连接);可选 [request](列表拉取与余额查询等连接级覆盖))与 **模型规则**(每行一个"用途组"请求模板:use 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 [body]/[request],**不写任何模型名**,命中这些用途的模型统一套用,行序重叠逐键合并、后覆盖先) - 连接启动时自动获取可用模型列表(OpenAI 兼容 GET /models;anthropic 走 x-api-key 的 /v1/models 并自动翻页),失败按连接降级展示、不拖垮其它连接;列表**只存内存、不持久化**(.ai model list 读加载/最近一次拉取结果);默认模型自动取该用途**首个可用**的同类型模型(文本→chat/压缩/总结/评分,视觉→识图,嵌入→嵌入),想换用 .ai model 显式绑定;ignore=1 的忽略连接不出现在任何指令与默认选择里 diff --git a/src/cmd/sub_cmd/model.ts b/src/cmd/sub_cmd/model.ts index abf4962..d5c10c7 100644 --- a/src/cmd/sub_cmd/model.ts +++ b/src/cmd/sub_cmd/model.ts @@ -167,6 +167,7 @@ export function registerCmdModel() { 【.ai model <用途>】查看指定用途当前模型与该用途候选(默认=该用途首个可用模型) 【.ai model <用途> <模型>】设置指定用途的全局模型(支持编号/裸名/[连接序号]:模型名) 用途: chat / compression / summarization / judge / image-understanding / text-embedding +模型类型: 自动按命名/接口能力位判定;可在「api连接」用 [types] 手动声明(text/vision/embed,优先级最高) 说明: 全量模型列表用 .ai model list;ignore=1 的忽略连接不参与展示/统计`; cmd.priv = { priv: M }; cmd.solve = async (scc: SubCmdContext) => { diff --git a/src/config/configs/model.ts b/src/config/configs/model.ts index 7f6051f..041229f 100644 --- a/src/config/configs/model.ts +++ b/src/config/configs/model.ts @@ -6,6 +6,7 @@ import { load } from 'js-toml' import Logger from "../../logger"; +import { DeclarableModelTag } from "../../model/catalog"; import { fetchModelList, ListFetchFn } from "../../model/list"; import Model, { ConnConfigLike, isIgnoredConfig } from "../../model/model"; import { ModelRuleTemplate, resetRuleRowsForTest } from "../../model/request_rules"; @@ -72,6 +73,9 @@ api_key = "sk-xxxx" # 必填,API 密钥 provider = "deepseek" # 可选,服务商,省略时自动识别 base_url = "https://api.deepseek.com/v1" # 可选,API 地址,省略时取服务商默认 models = ["deepseek-v4-flash"] # 可选,模型清单:填写=跳过自动拉取直接用该清单 +# [types] # 可选,必须写在本行最后(其后不能再写 api_key 等键):手动声明模型类型,优先级最高 +# "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号;无效值只忽略该键并记日志 +# "inhouse-vl" = "vision" # [request] # 可选,列表/余额查询覆盖(默认不写,由插件按 provider 解析) # list_url = "https://your-gateway/v1/models" # 自定义列表端点(服务商无 /models 时用) @@ -93,7 +97,7 @@ provider = "alibaba" # 可选,服务商,省略时自动识别 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" # 可选,API 地址,省略时取服务商默认 models = ["text-embedding-v4"] # 可选,模型清单:填写=跳过自动拉取直接用该清单 ignore = 1 # 可选,1=忽略该条配置,0/不写=正常,使用前删除该行`, - ], `每框一个 API 连接(TOML)。必填:provider(服务商)、api_key(密钥)。可选:base_url(API 地址,省略取服务商默认)、models(模型钉住清单:填写则跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商)、ignore(1=忽略该连接)。未写 models 的连接启动时自动请求模型列表接口(OpenAI 兼容 GET /models;anthropic 走 x-api-key 的 /v1/models 并自动翻页),失败按连接降级展示,不会拖垮其他连接。下方默认值即完整示例,可直接修改:出厂默认钉住 deepseek-v4-flash,删掉 models 行即改为启动自动拉取。连接行序 = 连接序号;重名模型用 [连接序号]:模型名 区分。修改后需重载 JS 生效。余额查询(.ai balance):deepseek/moonshot/siliconflow 连接无需配置即可查;其余平台未开放余额接口(仅控制台);one-api/new-api 等网关可在连接 [request] 里配 balance_url + balance_json_path(配合 auth_header_name/headers)后查询。`, CONFIG_GROUP); + ], `每框一个 API 连接(TOML)。必填:provider(服务商)、api_key(密钥)。可选:base_url(API 地址,省略取服务商默认)、models(模型钉住清单:填写则跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商)、[types](可选,手动声明模型类型:每行写作 "模型名" = "text" / "vision" / "embed",优先级最高,可覆盖命名猜测与接口自报能力位;模型名含 . : / 等字符必须加引号;必须写在该行最后,其后不能再写 api_key 等键,否则整行解析失败;无效值只忽略该键并记日志)、ignore(1=忽略该连接)。未写 models 的连接启动时自动请求模型列表接口(OpenAI 兼容 GET /models;anthropic 走 x-api-key 的 /v1/models 并自动翻页),失败按连接降级展示,不会拖垮其他连接。下方默认值即完整示例,可直接修改:出厂默认钉住 deepseek-v4-flash,删掉 models 行即改为启动自动拉取。连接行序 = 连接序号;重名模型用 [连接序号]:模型名 区分。修改后需重载 JS 生效。余额查询(.ai balance):deepseek/moonshot/siliconflow 连接无需配置即可查;其余平台未开放余额接口(仅控制台);one-api/new-api 等网关可在连接 [request] 里配 balance_url + balance_json_path(配合 auth_header_name/headers)后查询。`, CONFIG_GROUP); seal.ext.registerTemplateConfig(ext, MODEL_RULE_CONFIG_KEY, [ `# 每框一个用途组模板(TOML):绑定到这些 use 的模型发起请求时统一套用下面的 body/request。 # use 可选值:chat/compression/summarization/judge/image-understanding/text-embedding。 @@ -142,6 +146,7 @@ class ApiConnectionItem { base_url: 'string', ignore: 'any', models: { array: 'string' }, + types: { objectValue: 'any' }, request: { objectValue: 'any' }, } provider: string; @@ -149,6 +154,7 @@ class ApiConnectionItem { base_url: string; ignore: any; models: string[]; + types: Record; request: any; constructor() { this.provider = ""; @@ -156,6 +162,7 @@ class ApiConnectionItem { this.base_url = ""; this.ignore = 0; this.models = []; + this.types = {}; this.request = {}; } } @@ -180,6 +187,32 @@ function trimLines(list: string[]): string[] { return list.map(s => String(s ?? '').trim()).filter(s => s !== ''); } +/** [types] 可声明的类型(内部排除值 gen 不可声明:生图/reranker 仅由命名白名单判定) */ +const DECLARABLE_TAGS: DeclarableModelTag[] = ['text', 'vision', 'embed']; + +/** + * 解析一行「api连接」的 [types] 表:模型名 → 手动声明的类型(优先级最高)。 + * 非法值只忽略该键并记 error 日志,不影响整行连接;模型名含点号未加引号会被 TOML 解析成嵌套表,单独提示。 + */ +function parseModelTypes(raw: any, rowIndex: number): Record { + const out: Record = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + for (const key of Object.keys(raw)) { + const value = raw[key]; + const tag = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if ((DECLARABLE_TAGS as string[]).includes(tag)) { + out[String(key).trim()] = tag as DeclarableModelTag; + continue; + } + if (value && typeof value === 'object') { + Logger.error(`「${API_CONNECTION_CONFIG_KEY}」第 ${rowIndex + 1} 行 [types] 的 "${key}" 值非法:模型名含点号时未加引号(被解析成嵌套表),请写作 "模型名" = "embed" 形式,已忽略该键`); + } else { + Logger.error(`「${API_CONNECTION_CONFIG_KEY}」第 ${rowIndex + 1} 行 [types] 的 "${key}" 值非法: ${String(value)}(可选 text/vision/embed),已忽略该键`); + } + } + return out; +} + function buildModelConfig(): ModelConfigData { // api连接:行序即连接序号(含 ignore 行,保证序号稳定) const conns: ConnConfigLike[] = []; @@ -207,6 +240,7 @@ function buildModelConfig(): ModelConfigData { baseUrl, ignore: isIgnoredConfig(item.ignore), models: pinned && pinned.length > 0 ? pinned : null, + modelTypes: parseModelTypes(item.types, index), request: item.request && typeof item.request === 'object' ? item.request : {}, }); Logger.info(`api连接[${index}]解析成功: ${provider} ${baseUrl}${pinned ? '(钉住 ' + pinned.length + ' 个模型)' : ''}`); diff --git a/src/model/catalog.ts b/src/model/catalog.ts index b43addd..ec7ca4f 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -6,10 +6,22 @@ // text —— 普通文本(对话候选) // 原则:只有能明确分辨的类别才会自动成为默认(embed 白名单/vision 证据或白名单); // 未知命名一律归 text 候选,绝不擅自当作 vision/embed 默认。 +// 判定优先级:手动声明(「api连接」[types]) > 命名终值(rerank/生图/嵌入白名单) > 接口能力位(仅正向证据) > 命名能力位 > 兜底 text; +// 手动声明即最终答案,与自动判定冲突时不记日志。当前每条分支恰好产出 1 个标签(数组形态沿用历史结构)。 // 纯模块:不依赖 seal / Config,便于单元测试。 export type ModelTag = 'text' | 'vision' | 'embed' | 'gen'; +/** 可手动声明的类型(不含内部排除值 gen:生图/reranker 仅由命名白名单判定) */ +export type DeclarableModelTag = 'text' | 'vision' | 'embed'; + +/** 分类外部证据:manual=手动声明(绝对优先);vision/embed=接口自报能力位(只取正向证据) */ +export interface ClassifyExtra { + manual?: DeclarableModelTag; + vision?: boolean; + embed?: boolean; +} + const GENERIC_EMBED_PATTERNS: RegExp[] = [ /(^|[^a-z0-9])text-embedding/i, /(^|[^a-z0-9])(?:embedding|embed)([^a-z0-9]|$)/i, @@ -61,27 +73,21 @@ function matchAny(name: string, patterns: RegExp[]): boolean { * 对模型名打能力标签。 * @param provider 服务商标识(部分 provider 有命名特例) * @param name 模型名(与平台一致) - * @param extra 可选外部证据(如 Moonshot supports_image_in 等列表接口返回的能力位) + * @param extra 外部证据:manual(手动声明,绝对优先)/ vision、embed(接口自报能力位) */ -export function classifyModel(_provider: string, name: string, extra: { vision?: boolean } = {}): ModelTag[] { - const tags: ModelTag[] = []; - // reranker(排序模型)既不能对话也不能向量化检索,先于 embed 排除,语义归 gen(不出现在任何候选) - if (/reranker/i.test(name) || /rerank/i.test(name)) { - tags.push('gen'); - return tags; - } - if (matchAny(name, GENERIC_EMBED_PATTERNS)) { - tags.push('embed'); - return tags; // 嵌入类不会同时是对话/生图 - } - if (matchAny(name, GENERIC_GEN_PATTERNS)) { - tags.push('gen'); - return tags; // 生图类不参与对话/嵌入候选 - } - const vision = extra.vision === true || matchAny(name, GENERIC_VISION_PATTERNS); - if (vision) tags.push('vision'); - if (!vision) tags.push('text'); - return tags; +export function classifyModel(_provider: string, name: string, extra: ClassifyExtra = {}): ModelTag[] { + // 1) 手动声明:绝对优先(含用显式 text 覆盖命名猜测) + if (extra.manual) return [extra.manual]; + // 2) 命名终值:reranker(排序模型)既不能对话也不能向量化检索,语义归 gen(不出现在任何候选); + // 生图/嵌入白名单同理属「终值/排除」语义,优先级高于网关自报元数据(元数据质量不可控) + if (/reranker/i.test(name) || /rerank/i.test(name)) return ['gen']; + if (matchAny(name, GENERIC_EMBED_PATTERNS)) return ['embed']; // 嵌入类不会同时是对话/生图 + if (matchAny(name, GENERIC_GEN_PATTERNS)) return ['gen']; // 生图类不参与对话/嵌入候选 + // 3) 接口能力位:只取正向证据(不产出 text,避免把明显多模态的模型降级为纯文本) + if (extra.embed === true) return ['embed']; + if (extra.vision === true) return ['vision']; + // 4) 命名能力位 + 兜底 + return matchAny(name, GENERIC_VISION_PATTERNS) ? ['vision'] : ['text']; } /** 嵌入命名是否命中白名单(供自动默认兜底判定:明确可分辨才默认) */ diff --git a/src/model/model.ts b/src/model/model.ts index 71b60ea..8deaaa8 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -5,7 +5,7 @@ import Logger from "../logger"; import { buildProviderBody, parseProviderResponse } from "./adapter"; -import { classifyModel, ModelTag } from "./catalog"; +import { classifyModel, DeclarableModelTag, ModelTag } from "./catalog"; import { describeListError, fetchModelList, ListFetchFn } from "./list"; import { requestModel } from "./provider"; import { bodyDefaultsFor, ModelRuleTemplate, requestOverridesFor, setRuleRows } from "./request_rules"; @@ -41,6 +41,8 @@ export interface ConnConfigLike { ignore: boolean; /** models 钉住清单:null=不钉住(启动自动拉取) */ models: string[] | null; + /** 「api连接」[types]:模型名 → 手动声明的类型(优先级最高),默认 {} */ + modelTypes?: Record; /** 连接配置 [request](列表拉取覆盖),默认 {} */ request?: Record; } @@ -176,6 +178,8 @@ export default class Model { private static connByIndex = new Map(); private static fetcher: ListFetchFn = fetchModelList; private static activeLoad: Promise | null = null; + /** 已提示过的「[types] 声明未命中模型列表」键(连接序号:模型名),避免同一声明重复刷屏 */ + private static warnedMissingTypes = new Set(); /** 重置注册表(测试/重载用;规则模板由 configs/model 负责重置) */ static reset() { @@ -185,6 +189,7 @@ export default class Model { Model.connByIndex = new Map(); Model.activeLoad = null; Model.fetcher = fetchModelList; + Model.warnedMissingTypes.clear(); ModelEntry.vectorCache = {}; } @@ -316,13 +321,35 @@ export default class Model { st.provider, st.baseUrl, conn?.apiKey ?? '', - classifyModel(st.provider, name), + classifyModel(st.provider, name, { manual: conn?.modelTypes?.[name] }), st.source, ); entry.ref = (counts.get(name) || 0) > 1 ? `[${st.connIndex}]:${name}` : name; return entry; }); Model.entries = entries; + Model.warnUnusedModelTypes(); + } + + /** + * 「api连接」[types] 的手动声明未命中当前模型列表时提示一次(warning): + * 模型名拼写错误/大小写不符/已从清单移除时,声明不再静默失效。 + * 只在连接列表就绪(status=ok)时判定,避免拉取失败/未完成时误报;同一声明每进程只提示一次。 + */ + private static warnUnusedModelTypes() { + for (const st of Model.states) { + if (st.status !== 'ok') continue; + const declared = Model.connByIndex.get(st.connIndex)?.modelTypes; + if (!declared) continue; + const names = new Set(st.modelNames); + for (const name of Object.keys(declared)) { + if (names.has(name)) continue; + const key = `${st.connIndex}:${name}`; + if (Model.warnedMissingTypes.has(key)) continue; + Model.warnedMissingTypes.add(key); + log.warning(`api连接[${st.connIndex}] 的 [types] 声明 "${name}" = "${declared[name]}" 未命中当前模型列表(请检查模型名拼写;该连接当前 ${st.modelNames.length} 个模型)`); + } + } } // ---- 用途候选 / 默认解析 ---- From 0cf34d7e9b81f467d2ce58a63df466833029af4d Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Sat, 12 Sep 2026 00:44:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(model):=20=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=83=BD=E5=8A=9B=E4=BD=8D=E8=AF=86=E5=88=AB=EF=BC=88?= =?UTF-8?q?vision/embed=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list.ts:新增 ModelCapabilityHints / ModelListEntry / ModelListResult;ListFetchFn 返回 ModelListResult(string[] 是其等价子集,既有单测桩与老路径零改动) - 列表响应对象项提取供应商自报能力位 extractHints():type/model_type/kind/task(LM Studio、vLLM 等)、architecture.input_modalities(OpenRouter 等)、capabilities(数组或对象,Ollama 等)、supports_vision/vision;未知取值(含 anthropic 的 type:"model")一律忽略;只产出 vision/embed 正向证据(不产出 text,避免保守元数据把多模态模型降级) - normalizeModelListResult():归一化为「模型名(保序去重)+ 能力位映射(同名多次出现逐键合并)」,解析失败文案与超时/翻页/认证语义保持不变;仍只发一次列表请求,不做任何额外探测 - model.ts:ConnState 增加 hints(只存内存);fetchConnState 走归一化(失败时清空 hints);rebuildEntries 把 hints 传给 classifyModel - 单测 3 项:真实解析路径(桩 HTTP 响应)下 7 种字段/取值的能力位分类 + 候选生效(嵌入不进对话候选)+ 名字保序去重;只取正向证据(capabilities:["completion"] 不降级 glm-4v、命名终值优先于接口的 type:"vlm");老式 string[] 结果行为不变 - 文档同步:changelog 能力位条目、README 分类说明字段表、docs/03 list.ts 模块说明、docs/02 视觉标签来源优先级 --- README.md | 2 +- ...66\346\236\204\350\256\276\350\256\241.md" | 2 +- ...41\345\235\227\350\257\246\350\247\243.md" | 2 +- scripts/unit-test-entry.ts | 91 +++++++++++++++- src/changelog.ts | 1 + src/model/list.ts | 100 ++++++++++++++++-- src/model/model.ts | 19 ++-- 7 files changed, 196 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 726a52b..9f81566 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ use = ["chat", "compression", "summarization", "judge"] temperature = 1 ``` -> 模型类型判定优先级:**`[types]` 手动声明 > 命名终值(reranker/生图/嵌入白名单) > 接口自报能力位 > 命名能力位 > 兜底纯文本**(手动声明即最终答案,与自动判定冲突时按声明执行)。模型来自「api连接」的自动拉取或 `models` 钉住清单,并按能力分类进各用途候选:文本类进对话候选;带明确视觉标签的模型(如 glm-4v/gemini/pixtral,或接口返回视觉能力位)进识图与对话候选;嵌入白名单命名(如 `text-embedding-*`/`bge-*`/`gemini-embedding-*`)进嵌入候选;生图/reranker 类不进任何候选。默认模型自动取该用途第一个候选(首个可用的同类型模型);想换别的模型用全局覆盖指定。 +> 模型类型判定优先级:**`[types]` 手动声明 > 命名终值(reranker/生图/嵌入白名单) > 接口自报能力位 > 命名能力位 > 兜底纯文本**(手动声明即最终答案,与自动判定冲突时按声明执行)。模型来自「api连接」的自动拉取或 `models` 钉住清单,并按能力分类进各用途候选:文本类进对话候选;带明确视觉标签的模型(如 glm-4v/gemini/pixtral,或接口自报能力位:`type`/`model_type`/`task`、`architecture.input_modalities`、`capabilities`、`supports_vision`)进识图与对话候选;嵌入白名单命名(如 `text-embedding-*`/`bge-*`/`gemini-embedding-*`)进嵌入候选;生图/reranker 类不进任何候选。默认模型自动取该用途第一个候选(首个可用的同类型模型);想换别的模型用全局覆盖指定。 > > 全局分用途覆盖:`.ai model` 查看各用途当前模型与连接状态;`.ai model list` 查看加载/最近一次拉取到内存的模型列表(不联网、不持久化,按 `[连接序号]` 分组);`.ai model pull` 立即重拉全部连接并展示(无视 models 钉住清单,强制网络);`.ai model <用途>` 查看指定用途候选;`.ai model <用途> <模型>` 设置(支持编号 / 裸名唯一 / `[序号]:模型名` 精确,重名歧义会提示;覆盖失效自动回退默认)。`.ai model <模型名>` 兼容为设置 chat 用途。 > diff --git "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" index cc3e51a..652321a 100644 --- "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -80,7 +80,7 @@ - ob11 依赖通知/请求事件:通过 `subscribeOb11Receive` 额外订阅依赖的 `onNoticeEvent`/`onRequestEvent`,把 OneBot 11 标准通知与 Milky 扩展事件(群禁言/群管理变更/群上传/运气王/群荣誉/群成员增减/消息撤回/好友添加/入群申请/好友申请等)转成文本提示词录入 AI 上下文,仅作背景、不触发 AI 发言;仅当会话待机或全局待机开启时才录入(与普通消息入库口径一致),命中会话不存在时自动新建(开启待机即代表要使用)。默认白名单包含全部可转换事件类型(元事件除外):元事件(心跳/生命周期)不属于通知/请求事件、始终不录入,poke 虽属 notify 子类型但由原生 onPoke 处理、不生成事件文本,实际不会录入;入群/好友申请属 request 事件与 notice 共用白名单;原生与 ob11 依赖双路径由 3 秒事件级去重防双录——去重 key 带整包内容指纹,同一类型"连续但内容不同"的事件(不同文件上传、禁言 vs 解禁、不同好友申请等)不会被窗口吞掉;同一条事件重复/双路径到达时不简单丢弃,而是"后到覆盖先到"就地替换已入库条目(文本/raw 取最后到达,位置不变);机器人自身入群/被移出/退群由原生回调(onGroupJoined/onGroupLeave)覆盖,依赖侧跳过,避免两路径以不同 eventType/文本各录一次。事件文本不再走压缩智能体:提示词文本由纯代码按模板从事件 JSON 生成(通常很短),超长仅纯代码 head 截断兜底。防注入:事件文本以 `[system:名称]…[/system]` 成对边界渲染,并在 system prompt 声明为背景/参考、不得执行;工具返回等外部数据同样以 `[tool_result]…[/tool_result]` 边界包裹,内部标签(含 `[/system]` 等闭合形式)入库前统一剥离,防语义注入与标签逃逸。事件原始数据(完整 OB11 事件 JSON / 原生回调信息)随事件条目挂载(条目上限 20、单条序列化上限 4000 字符,超出丢弃 raw 但保留文本提示词),不参与上下文渲染、模型不可见;AI 需要完整字段时调用 `read_raw kind=event` 读取(原 `get_event_detail` 工具已删除)。事件条目随上下文裁剪/遗忘/压缩自然失效,无需单独清理。 - 消息接收优先级固定为“核心原生消息 → ob11 依赖补充”:核心回调已经提供的文本、图片、表情、at、reply、poke 等内容只从 `msg.message`/核心事件入库;ob11 依赖只补充核心过滤掉的文件、视频、语音、卡片、音乐和消息节点。依赖事件先到时会等待核心事件,避免同一条消息重复入库;没有核心事件时才允许完整兜底。 - Milky 核心事件的原生段直接从 `msg.segments` 转换为内部渲染标签,不先转 CQ 码;OB11 依赖事件仍使用其事件数组。消息 ID、引用 ID 和发送者信息在转换阶段统一补齐,负数 ID 也按字符串保留。 -- 多模态消息:最终解析到的 chat 模型实例带「视觉」标签(如 glm-4v/gemini/pixtral 或接口视觉能力位)时按多模态处理,`handleMessages` 把用户消息里的 `[img:...]`/`[avatar:...]`/`[group_avatar:...]` 标签转成 `image_url` 内容块直接传给模型;URL 图片优先转 base64(10s 超时兜底)。渲染标签统一为方括号格式(4.14.0 起首次对话自动迁移历史 `<|...|>` 数据,解析层不再兼容旧标签)。 +- 多模态消息:最终解析到的 chat 模型实例带「视觉」标签(视觉标签来源优先级:「api连接」`[types]` 手动声明 > 命名终值 > 接口自报能力位 > 命名能力位,如 glm-4v/gemini/pixtral 或列表接口自报的视觉能力位)时按多模态处理,`handleMessages` 把用户消息里的 `[img:...]`/`[avatar:...]`/`[group_avatar:...]` 标签转成 `image_url` 内容块直接传给模型;URL 图片优先转 base64(10s 超时兜底)。渲染标签统一为方括号格式(4.14.0 起首次对话自动迁移历史 `<|...|>` 数据,解析层不再兼容旧标签)。 ### 2. 指令消息(handleCommand) diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index d5ab739..caad1d0 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -80,7 +80,7 @@ - `model.ts`:`Model` 静态注册表(`states` 连接状态 + `entries` 模型实例)与全局分用途覆盖 `purposeModelOverrides`(use → 模型标识:全注册表唯一名 → 裸名,跨连接重名 → `[连接序号]:模型名`;由 `.ai model` 写入 `modelPurposeOverrides` 存储键)。模型实例来自「api连接」的 `models` 钉住清单(pinned)/启动自动拉取(auto),**只存内存、不持久化**,按能力标签(`catalog.ts`:手动 `[types]` 声明 > 命名终值 > 接口能力位 > 命名能力位 > 兜底 text)分类进各用途候选;`rebuildEntries()` 重建注册表时会对「声明了但当前列表里不存在」的 `[types]` 条目记一条 warning(模型名拼错不再静默失效)。`getChatModel(use)`/`getMultimodalModel(use)`/`getEmbeddingModel(use)` 先按用途查覆盖(覆盖失效自动回退),再取「该用途第一个候选」为默认;`findModelByRef` 按标识反查;`listModelsForUse(use)` 返回候选列表(`.ai model` 与余额不足自动切模型共用);`ensureLoaded()/pull()` 供首条消息等待与 `.ai model pull` 立即重拉(无视 models 钉住清单,强制网络);`reset()` 供测试/热重载。 - `catalog.ts`:模型能力分类(text/vision/embed/gen)——判定优先级 **手动声明(「api连接」`[types]`) > 命名终值(reranker/生图/嵌入白名单,如 text-embedding-*/bge-*/dall-e) > 接口自报能力位(仅正向证据:vision/embed) > 命名能力位(视觉白名单 glm-4v/gemini/pixtral 等) > 兜底 text**;手动声明即最终答案(与自动判定冲突时按声明执行,不记日志)。`DeclarableModelTag` 限定用户可声明值为 text/vision/embed(内部排除值 gen 不可声明);`classifyModel(provider, name, extra)` 的 `extra` 承载 manual 与接口能力位;当前每条分支恰好产出 1 个标签(数组形态沿用历史结构)。 - `request_rules.ts`:模型规则(use 组 body/request 模板)存储与合并;`bodyDefaultsFor(use)` 返回「代码兜底默认 < 规则模板(行序后覆盖先)」的请求体,`requestOverridesFor(use)` 返回调用期 `[request]` 覆盖(headers/auth_header_name/content_type/timeout)。 -- `list.ts`:连接级「获取可用模型列表」适配——OpenAI 兼容 `GET {base}/models`(Bearer);anthropic 走 `x-api-key` + `/v1/models` 并 `limit=1000` 循环翻页;支持连接 `[request]`(list_url/auth_header_name/headers/timeout)覆盖;拉取函数可注入便于单测。 +- `list.ts`:连接级「获取可用模型列表」适配——OpenAI 兼容 `GET {base}/models`(Bearer);anthropic 走 `x-api-key` + `/v1/models` 并 `limit=1000` 循环翻页;支持连接 `[request]`(list_url/auth_header_name/headers/timeout)覆盖。对象项额外提取供应商自报能力位(vision/embed:type/model_type/kind/task、architecture.input_modalities、capabilities、supports_vision/vision;未知取值含 anthropic 的 `type: "model"` 一律忽略),`normalizeModelListResult` 归一化为「模型名(保序去重)+能力位映射」交上层分类;拉取结果类型为 `ModelListResult`(`string[]` 是其等价子集,兼容既有单测桩);**不发额外请求**(不做 /api/show、逐模型详情等探测);拉取函数可注入便于单测。 - `balance.ts`:连接级账户余额查询(.ai balance 用)——deepseek/moonshot/siliconflow 内置余额端点;其余平台支持在连接 `[request]` 配置 `balance_url`/`balance_json_path`(+balance_divisor/balance_currency)后查询自定义网关(one-api/new-api 等)。 - `api_error.ts`:`ApiError` 与语义分类——把 OpenAI 兼容/Anthropic/Google/智谱/阿里/SiliconFlow/网关等非 2xx 报错归一化为 balance(余额不足)/context_length(上下文超长)/rate_limit(限速)/overload(过载)/auth/permission/content/parameter/server/unknown 类别,并携带 HTTP 状态码、服务商与响应体原文。 - `provider.ts`:`requestModel()` 统一请求入口:超时(`TIMEOUT` 或 use 规则 `[request].timeout`)、按类别退避重试(429/过载/服务端/网络类,尊重 `Retry-After`,其余不重试)、成功时上报 token 用量;错误统一抛带 status/provider/响应体/类别的 `ApiError`(对外 message 文案不变);`adapter.ts` 按提供商转换请求/响应格式(当前支持 anthropic)。 diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index 727c1bc..98542f5 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -52,6 +52,7 @@ import Agent from "../src/agent/agent"; import { streamService } from "../src/agent/stream"; import Model from "../src/model/model"; import Logger from "../src/logger"; +import { ModelListResult } from "../src/model/list"; import { resetModelConfigCacheForTest, setModelListDepsForTest } from "../src/config/configs/model"; import { Session } from "../src/session/session"; import { JudgeManager } from "../src/judge/judge_manager"; @@ -258,15 +259,15 @@ function seedPinnedConns(conns: any[], rules: any[] = []) { Model.bootstrap(conns, rules); } -/** 播种「自动拉取」连接:注入假 fetcher(返回模型名数组;可跨次调用改变),随后等待拉取完成 */ -async function seedAutoConns(conns: any[], fetchFn: (ctx: any) => Promise, rules: any[] = []) { +/** 播种「自动拉取」连接:注入假 fetcher(返回模型名数组或带能力位的列表项;可跨次调用改变),随后等待拉取完成 */ +async function seedAutoConns(conns: any[], fetchFn: (ctx: any) => Promise, rules: any[] = []) { Model.reset(); Model.bootstrap(conns, rules, { fetch: fetchFn as any }); await Model.ensureLoaded(); } /** 从「api连接」模板配置播种(解析 → Model.bootstrap),供解析/降级类测试使用 */ -function seedFromTemplate(connToml: string[], ruleToml: string[] = [], fetchFn?: (ctx: any) => Promise) { +function seedFromTemplate(connToml: string[], ruleToml: string[] = [], fetchFn?: (ctx: any) => Promise) { resetModelConfigCacheForTest(); TC.templateConfigs['api连接'] = connToml; TC.templateConfigs['模型规则'] = ruleToml; @@ -4062,6 +4063,90 @@ export const tests: Record void | Promise> = { } }, + /** 列表接口能力位:真实解析路径(桩 HTTP 响应)下,供应商自报能力位参与分类 */ + async testModelListCapabilityHints(): Promise { + const origFetch = (globalThis as any).fetch; + try { + const data = { + data: [ + { id: 'gw-vec', type: 'embeddings' }, + { id: 'gw-vl', architecture: { input_modalities: ['text', 'image'] } }, + { id: 'gw-task', task: 'embed' }, + { id: 'gw-caps', capabilities: ['completion', 'vision'] }, + { id: 'gw-flag', supports_vision: true }, + { id: 'claude-x', type: 'model' }, // anthropic 风格:type=model 不应误判 + { id: 'plain-model' }, // 无能力位字段:退回命名猜测 + ], + }; + (globalThis as any).fetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify(data) }); + Model.reset(); + Model.bootstrap([{ provider: 'openrouter', apiKey: 'k', baseUrl: 'https://o', ignore: false, models: null, request: {} }], []); + await Model.ensureLoaded(); + assert.equal(Model.states[0].status, 'ok'); + + const tagsOf = (name: string) => Model.entries.find(e => e.name === name)?.tags ?? []; + assert.deepEqual(tagsOf('gw-vec'), ['embed'], 'type=embeddings 应识别为嵌入'); + assert.deepEqual(tagsOf('gw-vl'), ['vision'], 'input_modalities 含 image 应识别为多模态'); + assert.deepEqual(tagsOf('gw-task'), ['embed'], 'task=embed 应识别为嵌入'); + assert.deepEqual(tagsOf('gw-caps'), ['vision'], 'capabilities 含 vision 应识别为多模态'); + assert.deepEqual(tagsOf('gw-flag'), ['vision'], 'supports_vision 应识别为多模态'); + assert.deepEqual(tagsOf('claude-x'), ['text'], 'type=model 不应误判'); + assert.deepEqual(tagsOf('plain-model'), ['text'], '无能力位字段应退回命名猜测'); + + // 候选生效:嵌入进嵌入候选且不进对话候选;多模态进识图候选 + assert.equal(Model.getEmbeddingModel('text-embedding')?.name, 'gw-vec'); + assert.equal(Model.listModelsForUse('chat').some(m => m.name === 'gw-vec'), false, '接口声明的嵌入模型不应进对话候选'); + assert.equal(Model.getMultimodalModel('image-understanding')?.name, 'gw-vl'); + + // states 侧:模型名保序去重;能力位只存内存 + assert.deepEqual(Model.states[0].modelNames, ['gw-vec', 'gw-vl', 'gw-task', 'gw-caps', 'gw-flag', 'claude-x', 'plain-model']); + assert.deepEqual(Model.states[0].hints['gw-vl'], { vision: true }); + } finally { + (globalThis as any).fetch = origFetch; + Model.reset(); + } + }, + + /** 接口能力位只取正向证据:保守元数据不降级命名判定;命名终值优先于接口能力位 */ + async testModelListHintsNoDowngrade(): Promise { + const origFetch = (globalThis as any).fetch; + try { + const data = { + data: [ + { id: 'glm-4v', capabilities: ['completion'] }, // 保守元数据:不产出 text,命名仍判视觉 + { id: 'text-embedding-3-small', type: 'vlm' }, // 命名终值优先于接口(防网关元数据质量差) + ], + }; + (globalThis as any).fetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify(data) }); + Model.reset(); + Model.bootstrap([{ provider: 'openai', apiKey: 'k', baseUrl: 'https://o', ignore: false, models: null, request: {} }], []); + await Model.ensureLoaded(); + + const tagsOf = (name: string) => Model.entries.find(e => e.name === name)?.tags ?? []; + assert.deepEqual(tagsOf('glm-4v'), ['vision'], '无正向证据时不应把视觉模型降级为 text'); + assert.deepEqual(tagsOf('text-embedding-3-small'), ['embed'], '命名终值应优先于接口能力位'); + assert.equal(Model.getEmbeddingModel('text-embedding')?.name, 'text-embedding-3-small'); + assert.equal(Model.getMultimodalModel('image-understanding')?.name, 'glm-4v'); + } finally { + (globalThis as any).fetch = origFetch; + Model.reset(); + } + }, + + /** 老式 string[] 拉取结果仍按原行为工作(无能力位 → 纯命名猜测) */ + async testModelListLegacyStringResult(): Promise { + try { + await seedAutoConns([{ provider: 'openai', apiKey: 'k', baseUrl: 'https://o', ignore: false, models: null, request: {} }], async () => ['m1', 'text-embedding-3-small']); + assert.deepEqual(Model.states[0].modelNames, ['m1', 'text-embedding-3-small']); + assert.deepEqual(Model.states[0].hints, {}, '字符串结果不应产生能力位'); + assert.equal(Model.getChatModel('chat')?.name, 'm1'); + assert.equal(Model.getEmbeddingModel('text-embedding')?.name, 'text-embedding-3-small'); + assert.ok(Model.entries.some(e => e.tags.includes('embed')), '拉取模型应完成分类'); + } finally { + Model.reset(); + } + }, + /** Agent.chat:多模态 user content 为内容块数组、纯文本为字符串;请求层收到解析出的模型实例 */ async testAgentChatPassesResolvedModel(): Promise { const origSend = (streamService as any).sendChatRequest; diff --git a/src/changelog.ts b/src/changelog.ts index ed8ba37..e0a9a0e 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -4,6 +4,7 @@ export const changelog: { [version: string]: string } = { "4.23.0": `## 新功能 - 模型类型可手动声明:「模型」页 api连接 新增可选 [types] 表(每行 "模型名" = "text" / "vision" / "embed"),**优先级最高**,可覆盖命名猜测——网关自命名的嵌入模型不再被误当对话模型、未命中命名白名单的多模态模型也能进识图候选;模型名含 . : / 等字符需加引号,[types] 必须写在该行最后(其后不能再写 api_key 等键),无效值只忽略该键并记日志 - 手动类型声明未命中当前模型列表时(模型名拼写错误 / 已移出清单)记一条 warning 提示,不再静默失效 +- 列表接口能力位自动识别:连接拉取模型列表时读取供应商自报的能力位(type / model_type / kind / task、architecture.input_modalities、capabilities、supports_vision),自动区分多模态与嵌入模型(适配 OpenRouter / LM Studio / vLLM / Ollama 风格字段);只取正向证据(保守元数据不会把模型降级),命名终值白名单(如 text-embedding-*)优先于接口元数据;只返回纯模型名、不自报能力的网关行为完全不变。仍只发一次列表请求,不做任何额外探测 ## 配置变更 - 「模型」页 api连接:新增可选 [types] 表(模型名 → text/vision/embed,优先级最高) `, diff --git a/src/model/list.ts b/src/model/list.ts index 1b22729..b983711 100644 --- a/src/model/list.ts +++ b/src/model/list.ts @@ -1,6 +1,7 @@ // 模型列表拉取:连接级「获取可用模型列表」适配(结果只存内存、不持久化)。 // 默认 OpenAI 兼容:GET {base}/models(Bearer),解析 data[].id; // Anthropic 特判:GET {base}/models,x-api-key + anthropic-version,limit=1000 循环翻页到 has_more=false。 +// 对象项额外提取供应商自报的能力位(vision/embed,仅正向证据),无相关字段时由上层退回命名猜测; // 连接配置里可选 [request](list_url/list_headers/auth_header_name/timeout)可覆盖默认行为; // 智谱/部分兼容网关无 /models 端点时自然报错,由上层按连接降级处理(可走 models 钉住清单)。 import { logger } from "../logger"; @@ -16,8 +17,23 @@ export interface ListRequestContext { listOverride?: Record; } -/** 拉取函数签名:便于单元测试注入桩(返回模型名数组,失败抛 Error) */ -export type ListFetchFn = (ctx: ListRequestContext) => Promise; +/** 供应商自报的能力位(只记"是",不记"不是":避免用保守元数据把模型降级) */ +export interface ModelCapabilityHints { + vision?: boolean; + embed?: boolean; +} + +/** 列表项:裸字符串(纯 id 网关/老写法)或带能力位的对象 */ +export interface ModelListEntry { + id: string; + hints?: ModelCapabilityHints; +} + +/** 拉取结果:`string[]` 是等价子集(单测注入的桩可继续返回字符串数组) */ +export type ModelListResult = Array; + +/** 拉取函数签名:便于单元测试注入桩(失败抛 Error) */ +export type ListFetchFn = (ctx: ListRequestContext) => Promise; export const LIST_FETCH_TIMEOUT_MS = 10000; @@ -58,18 +74,84 @@ function pickHeaders(listOverride: Record | undefined): Record typeof m === 'string' && (m.toLowerCase() === 'image' || m.toLowerCase() === 'video'))) { + hints.vision = true; + } + } + const capabilities = item.capabilities; + if (Array.isArray(capabilities)) { + if (capabilities.some((c: any) => typeof c === 'string' && c.toLowerCase() === 'vision')) hints.vision = true; + if (capabilities.some((c: any) => typeof c === 'string' && c.toLowerCase() === 'embedding')) hints.embed = true; + } else if (capabilities && typeof capabilities === 'object') { + if (capabilities.vision === true) hints.vision = true; + if (capabilities.embedding === true) hints.embed = true; + } + if (item.supports_vision === true || item.vision === true) hints.vision = true; + return (hints.vision || hints.embed) ? hints : undefined; +} + +/** OpenAI 兼容列表响应 → 列表项(对象项额外提取能力位;字符串项保持原样) */ +function parseOpenAiModelEntries(data: any): ModelListResult { const arr = Array.isArray(data?.data) ? data.data : null; if (!arr) { throw new Error('列表响应缺少 data 数组'); } - const ids: string[] = []; + const items: ModelListResult = []; for (const item of arr) { const id = typeof item === 'string' ? item : item?.id; - if (typeof id === 'string' && id.trim()) ids.push(id.trim()); + if (typeof id !== 'string' || !id.trim()) continue; + items.push(typeof item === 'string' ? id.trim() : { id: id.trim(), hints: extractHints(item) }); + } + return items; +} + +/** 归一化拉取结果:模型名(保序去重,与历史行为一致)+ 能力位映射(同名多次出现时逐键合并) */ +export function normalizeModelListResult(raw: any): { names: string[]; hints: Record } { + const names: string[] = []; + const hints: Record = {}; + const seen = new Set(); + for (const item of Array.isArray(raw) ? raw : []) { + const id = typeof item === 'string' ? item : (item && typeof item.id === 'string' ? item.id : null); + const name = typeof id === 'string' ? id.trim() : ''; + if (!name) continue; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + const itemHints = item && typeof item === 'object' ? item.hints : undefined; + if (itemHints && typeof itemHints === 'object') { + hints[name] = { ...(hints[name] ?? {}), ...itemHints }; + } } - return ids; + return { names, hints }; } /** Anthropic:x-api-key + anthropic-version,翻页直到 has_more=false */ @@ -99,7 +181,7 @@ async function fetchAnthropicModels(ctx: ListRequestContext, listUrl: string, ti } /** 默认拉取实现:Anthropic 特判,其余按 OpenAI 兼容处理 */ -export async function fetchModelList(ctx: ListRequestContext): Promise { +export async function fetchModelList(ctx: ListRequestContext): Promise { const listOverride = ctx.listOverride ?? {}; const timeoutMs = Number(listOverride.timeout) > 0 ? Number(listOverride.timeout) * 1000 : LIST_FETCH_TIMEOUT_MS; let listUrl = joinUrl(ctx.baseUrl, '/models'); @@ -121,7 +203,7 @@ export async function fetchModelList(ctx: ListRequestContext): Promise headers[authHeader] = authHeader === 'Authorization' ? `Bearer ${ctx.apiKey}` : ctx.apiKey; const data = await readJson(listUrl, headers, timeoutMs); - return parseOpenAiModelIds(data); + return parseOpenAiModelEntries(data); } /** 将拉取抛出的错误转成可展示的短文案(按连接降级展示用) */ diff --git a/src/model/model.ts b/src/model/model.ts index 8deaaa8..2216b32 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -6,7 +6,7 @@ import Logger from "../logger"; import { buildProviderBody, parseProviderResponse } from "./adapter"; import { classifyModel, DeclarableModelTag, ModelTag } from "./catalog"; -import { describeListError, fetchModelList, ListFetchFn } from "./list"; +import { describeListError, fetchModelList, ListFetchFn, ModelCapabilityHints, normalizeModelListResult } from "./list"; import { requestModel } from "./provider"; import { bodyDefaultsFor, ModelRuleTemplate, requestOverridesFor, setRuleRows } from "./request_rules"; import { ChatModelUse, EmbeddingModelUse, ModelUse, MultimodalModelUse } from "./types"; @@ -29,6 +29,8 @@ export interface ConnState { updatedAt: number; /** 该连接当前可用模型名(pinned 钉住/启动拉取结果),按列表顺序 */ modelNames: string[]; + /** 供应商自报的能力位(仅自动拉取会填;只存内存、不持久化) */ + hints: Record; } /** 连接配置原始形态(configs/model.ts 解析 TOML 后传入) */ @@ -207,11 +209,11 @@ export default class Model { const rawIndex = typeof c.connIndex === 'number' ? c.connIndex : arrayIndex; if (!c.ignore) Model.connByIndex.set(rawIndex, c); if (c.ignore) { - connStates.push({ connIndex: rawIndex, provider: c.provider, baseUrl: c.baseUrl, source: 'none' as const, status: 'ignored' as const, updatedAt: Date.now(), modelNames: [] }); + connStates.push({ connIndex: rawIndex, provider: c.provider, baseUrl: c.baseUrl, source: 'none' as const, status: 'ignored' as const, updatedAt: Date.now(), modelNames: [], hints: {} }); } else if (c.models && c.models.length > 0) { - connStates.push({ connIndex: rawIndex, provider: c.provider, baseUrl: c.baseUrl, source: 'pinned' as const, status: 'ok' as const, updatedAt: Date.now(), modelNames: [...c.models] }); + connStates.push({ connIndex: rawIndex, provider: c.provider, baseUrl: c.baseUrl, source: 'pinned' as const, status: 'ok' as const, updatedAt: Date.now(), modelNames: [...c.models], hints: {} }); } else { - connStates.push({ connIndex: rawIndex, provider: c.provider, baseUrl: c.baseUrl, source: 'none' as const, status: 'pending' as const, updatedAt: 0, modelNames: [] }); + connStates.push({ connIndex: rawIndex, provider: c.provider, baseUrl: c.baseUrl, source: 'none' as const, status: 'pending' as const, updatedAt: 0, modelNames: [], hints: {} }); } }); Model.states = connStates; @@ -249,15 +251,17 @@ export default class Model { /** 单连接拉取并更新 state(成功 → auto;失败 → error,只存内存) */ private static async fetchConnState(state: ConnState, conn: ConnConfigLike): Promise { try { - const names = await Model.fetcher({ + const raw = await Model.fetcher({ provider: conn.provider, baseUrl: conn.baseUrl, apiKey: conn.apiKey, listOverride: conn.request ?? {}, }); + const { names, hints } = normalizeModelListResult(raw); state.status = 'ok'; state.source = 'auto'; state.modelNames = names; + state.hints = hints; state.updatedAt = Date.now(); delete state.errorKind; delete state.errorText; @@ -265,6 +269,7 @@ export default class Model { const d = describeListError(e); state.status = 'error'; state.modelNames = []; + state.hints = {}; state.errorKind = d.kind; state.errorText = d.text; } @@ -315,13 +320,15 @@ export default class Model { } const entries = pairs.map(({ st, name }) => { const conn = Model.connByIndex.get(st.connIndex); + const manual = conn?.modelTypes?.[name]; + const hints = st.hints?.[name]; const entry = new ModelEntry( st.connIndex, name, st.provider, st.baseUrl, conn?.apiKey ?? '', - classifyModel(st.provider, name, { manual: conn?.modelTypes?.[name] }), + classifyModel(st.provider, name, { manual, ...(hints ?? {}) }), st.source, ); entry.ref = (counts.get(name) || 0) > 1 ? `[${st.connIndex}]:${name}` : name; From 3970018819b23be81d3c8655a684aa2a5bfdd9f9 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Sat, 12 Sep 2026 00:55:12 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(config):=20=E6=A8=A1=E6=9D=BF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8F=8F=E8=BF=B0=E7=BB=9F=E4=B8=80=E6=8C=89=E3=80=8C?= =?UTF-8?q?=E6=A1=86=E3=80=8D=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板配置的每个元素是一个文本框,不能用「行」描述元素;框内内容自身的「行」表述保持原样 - 模型配置:[types] 提示改「必须写在本框最后」,描述改「在本框末尾追加 [types] 表,表内每个模型写一条 …」「否则整框解析失败」「框序 = 连接序号(自上而下,第一个框为 0)」;模板注释「按行序逐键合并」→「按框顺序逐键合并」;parseModelTypes 与 buildModelConfig 内部注释同步 - 其余模板配置:「每行一个」→「每框一个」——预设上下文、自动切换触发错误、忽略消息正则、触发正则、本地图片/语音/文件/视频路径、角色扮演设定、子代理禁止调用工具、禁止调用与默认关闭的函数、禁止调用与默认关闭的 OB11 action、可调用指令白名单;音乐服务配置「每行一条」→「每框一条」 - 刻意保留(属框内内容语义,非模板元素,改了才是误伤):角色设定「第一行为角色设定名称」、事件白名单「每行一个事件类型」(parseNoticeWhitelist 按逗号/换行切分,一个框内可写多个)、TOML/JSON「逐行解析」、模板示例内「使用前删除该行」「删掉 models 行」、知识库正文「每个小节第一行…」 - changelog 同步(含 4.23.0 条目措辞);单测 238 项全绿,lint/tsc 无输出 --- scripts/unit-test-entry.ts | 2 +- src/changelog.ts | 3 ++- src/config/configs/context.ts | 2 +- src/config/configs/error.ts | 2 +- src/config/configs/model.ts | 12 ++++++------ src/config/configs/received.ts | 2 +- src/config/configs/resource.ts | 8 ++++---- src/config/configs/role.ts | 2 +- src/config/configs/subagent.ts | 2 +- src/config/configs/tool.ts | 12 ++++++------ src/config/configs/trigger.ts | 2 +- 11 files changed, 25 insertions(+), 24 deletions(-) diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index 98542f5..4db773d 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -6070,7 +6070,7 @@ description: 茶库 assert.equal(ov?.timeout, 5); assert.equal(Model.requestOverridesFor('summarization'), null, '未命中规则无 request 覆盖'); - // 重叠行按行序逐键合并、后覆盖先 + // 重叠规则按框顺序逐键合并、后面的框覆盖前面的 seedPinnedConns([pinConn('deepseek', ['text-a'])], [ { use: ['chat'], body: { max_tokens: 1000, temperature: 0.1 }, request: {} }, { use: ['chat'], body: { max_tokens: 2000 }, request: {} }, diff --git a/src/changelog.ts b/src/changelog.ts index e0a9a0e..2c703ba 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -2,11 +2,12 @@ // 版本更新日志,格式为 "版本号": "更新内容",版本号格式为 "x.y.z",按照时间顺序从新到旧排列。 export const changelog: { [version: string]: string } = { "4.23.0": `## 新功能 -- 模型类型可手动声明:「模型」页 api连接 新增可选 [types] 表(每行 "模型名" = "text" / "vision" / "embed"),**优先级最高**,可覆盖命名猜测——网关自命名的嵌入模型不再被误当对话模型、未命中命名白名单的多模态模型也能进识图候选;模型名含 . : / 等字符需加引号,[types] 必须写在该行最后(其后不能再写 api_key 等键),无效值只忽略该键并记日志 +- 模型类型可手动声明:「模型」页 api连接 新增可选 [types] 表(在本框末尾追加,表内每个模型写一条 "模型名" = "text" / "vision" / "embed"),**优先级最高**,可覆盖命名猜测——网关自命名的嵌入模型不再被误当对话模型、未命中命名白名单的多模态模型也能进识图候选;模型名含 . : / 等字符需加引号,[types] 必须写在本框最后(其后不能再写 api_key 等键),无效值只忽略该键并记日志 - 手动类型声明未命中当前模型列表时(模型名拼写错误 / 已移出清单)记一条 warning 提示,不再静默失效 - 列表接口能力位自动识别:连接拉取模型列表时读取供应商自报的能力位(type / model_type / kind / task、architecture.input_modalities、capabilities、supports_vision),自动区分多模态与嵌入模型(适配 OpenRouter / LM Studio / vLLM / Ollama 风格字段);只取正向证据(保守元数据不会把模型降级),命名终值白名单(如 text-embedding-*)优先于接口元数据;只返回纯模型名、不自报能力的网关行为完全不变。仍只发一次列表请求,不做任何额外探测 ## 配置变更 - 「模型」页 api连接:新增可选 [types] 表(模型名 → text/vision/embed,优先级最高) +- 配置描述统一按「框」表述模板配置的每个文本框:模板配置的每个元素是一个文本框,不再用「行」描述元素(如「每行一个」→「每框一个」);框内内容自身的「行」表述(如角色设定「第一行为名称」、TOML/JSON 语法行)保持原样 `, "4.22.0": `## 新功能 - 模型配置 v4:模型配置改为两个 TOML ——「模型」页的 **api连接**(每行一个服务商连接:api_key 必填;provider 选填,省略按 OpenAI 兼容处理(此时需显式填 base_url);base_url 选填,省略取该服务商默认;可选 models 钉住清单(填写=跳过自动拉取,离线/无列表接口时用);可选 ignore(1=忽略该连接);可选 [request](列表拉取与余额查询等连接级覆盖))与 **模型规则**(每行一个"用途组"请求模板:use 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 [body]/[request],**不写任何模型名**,命中这些用途的模型统一套用,行序重叠逐键合并、后覆盖先) diff --git a/src/config/configs/context.ts b/src/config/configs/context.ts index d67cb2d..1155606 100644 --- a/src/config/configs/context.ts +++ b/src/config/configs/context.ts @@ -6,7 +6,7 @@ export default class ContextConfig { static register() { seal.ext.registerIntConfig(ext, "上下文最大token", 1000000, "持久化上下文 token 上限;0/负数视为无效并回退默认 1000000", "上下文"); seal.ext.registerIntConfig(ext, "对话保存轮数", 5, "上下文超过最大 token 后保留的最近真实用户轮数;更早消息会先归档总结再删除", "上下文"); - seal.ext.registerTemplateConfig(ext, "预设上下文", [""], "每行一条预设上下文,role 按 user/assistant 轮流出现,帮助模型学习对话语气", "上下文"); + seal.ext.registerTemplateConfig(ext, "预设上下文", [""], "每框一条预设上下文,role 按 user/assistant 轮流出现,帮助模型学习对话语气", "上下文"); seal.ext.registerIntConfig(ext, "插入system message间隔轮数", 0, "需要小于限制轮数的二分之一才能生效,为0时不生效,预设上下文不计入轮数", "上下文"); seal.ext.registerIntConfig(ext, "消息压缩阈值", 2000, "用户消息(含连续多条合并后)超过该字符数时,使用压缩智能体压缩后存入上下文;压缩前原文自动保留,AI 可用 read_raw kind=user 按 msg_id/blk:id 查看原文", "上下文"); } diff --git a/src/config/configs/error.ts b/src/config/configs/error.ts index 3d87a8c..4f2b052 100644 --- a/src/config/configs/error.ts +++ b/src/config/configs/error.ts @@ -35,7 +35,7 @@ export default class ErrorConfig { seal.ext.registerBoolConfig(ext, "启用报错自动处理", true, "模型请求报错时按语义类别自动处理(上下文超长归档重试 / 余额不足切模型 / 限速退避等),未覆盖或无法处理的错误仅记日志不回复", "错误处理"); seal.ext.registerBoolConfig(ext, "上下文超长自动归档重试", true, "模型返回上下文超长时,把会话历史按「观察归档+删除」链路压到模型窗口内后自动重发一次;关闭则该场景仅记日志", "错误处理"); seal.ext.registerBoolConfig(ext, "余额不足自动切换模型", true, "对话模型报余额不足/欠费/额度用尽时,自动把 chat 用途切换到备用模型(写入全局模型覆盖,管理员可 .ai model 改回)", "错误处理"); - seal.ext.registerTemplateConfig(ext, "自动切换触发错误", ['balance'], "每行一个触发自动切换模型的错误类别:balance(余额不足)/permission(权限不足);其余类别仅退避重试或记日志", "错误处理"); + seal.ext.registerTemplateConfig(ext, "自动切换触发错误", ['balance'], "每框一个触发自动切换模型的错误类别:balance(余额不足)/permission(权限不足);其余类别仅退避重试或记日志", "错误处理"); seal.ext.registerOptionConfig(ext, "自动切换策略", "跨厂商优先", ["跨厂商优先", "配置顺序"], "跨厂商优先=优先切到不同服务商的模型;配置顺序=按纯文本模型列表顺序取下一个不同模型", "错误处理"); seal.ext.registerBoolConfig(ext, "切换后发送通知", true, "自动切换模型后用 ctx.notice 向当前会话发送一条切换通知", "错误处理"); } diff --git a/src/config/configs/model.ts b/src/config/configs/model.ts index 041229f..a212093 100644 --- a/src/config/configs/model.ts +++ b/src/config/configs/model.ts @@ -73,7 +73,7 @@ api_key = "sk-xxxx" # 必填,API 密钥 provider = "deepseek" # 可选,服务商,省略时自动识别 base_url = "https://api.deepseek.com/v1" # 可选,API 地址,省略时取服务商默认 models = ["deepseek-v4-flash"] # 可选,模型清单:填写=跳过自动拉取直接用该清单 -# [types] # 可选,必须写在本行最后(其后不能再写 api_key 等键):手动声明模型类型,优先级最高 +# [types] # 可选,必须写在本框最后(其后不能再写 api_key 等键):手动声明模型类型,优先级最高 # "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号;无效值只忽略该键并记日志 # "inhouse-vl" = "vision" @@ -97,12 +97,12 @@ provider = "alibaba" # 可选,服务商,省略时自动识别 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" # 可选,API 地址,省略时取服务商默认 models = ["text-embedding-v4"] # 可选,模型清单:填写=跳过自动拉取直接用该清单 ignore = 1 # 可选,1=忽略该条配置,0/不写=正常,使用前删除该行`, - ], `每框一个 API 连接(TOML)。必填:provider(服务商)、api_key(密钥)。可选:base_url(API 地址,省略取服务商默认)、models(模型钉住清单:填写则跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商)、[types](可选,手动声明模型类型:每行写作 "模型名" = "text" / "vision" / "embed",优先级最高,可覆盖命名猜测与接口自报能力位;模型名含 . : / 等字符必须加引号;必须写在该行最后,其后不能再写 api_key 等键,否则整行解析失败;无效值只忽略该键并记日志)、ignore(1=忽略该连接)。未写 models 的连接启动时自动请求模型列表接口(OpenAI 兼容 GET /models;anthropic 走 x-api-key 的 /v1/models 并自动翻页),失败按连接降级展示,不会拖垮其他连接。下方默认值即完整示例,可直接修改:出厂默认钉住 deepseek-v4-flash,删掉 models 行即改为启动自动拉取。连接行序 = 连接序号;重名模型用 [连接序号]:模型名 区分。修改后需重载 JS 生效。余额查询(.ai balance):deepseek/moonshot/siliconflow 连接无需配置即可查;其余平台未开放余额接口(仅控制台);one-api/new-api 等网关可在连接 [request] 里配 balance_url + balance_json_path(配合 auth_header_name/headers)后查询。`, CONFIG_GROUP); + ], `每框一个 API 连接(TOML)。必填:provider(服务商)、api_key(密钥)。可选:base_url(API 地址,省略取服务商默认)、models(模型钉住清单:填写则跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商)、[types](可选,手动声明模型类型:在本框末尾追加 [types] 表,表内每个模型写一条 "模型名" = "text" / "vision" / "embed",优先级最高,可覆盖命名猜测与接口自报能力位;模型名含 . : / 等字符必须加引号;必须写在本框最后,其后不能再写 api_key 等键,否则整框解析失败;无效值只忽略该键并记日志)、ignore(1=忽略该连接)。未写 models 的连接启动时自动请求模型列表接口(OpenAI 兼容 GET /models;anthropic 走 x-api-key 的 /v1/models 并自动翻页),失败按连接降级展示,不会拖垮其他连接。下方默认值即完整示例,可直接修改:出厂默认钉住 deepseek-v4-flash,删掉 models 行即改为启动自动拉取。框序 = 连接序号(自上而下,第一个框为 0);重名模型用 [连接序号]:模型名 区分。修改后需重载 JS 生效。余额查询(.ai balance):deepseek/moonshot/siliconflow 连接无需配置即可查;其余平台未开放余额接口(仅控制台);one-api/new-api 等网关可在连接 [request] 里配 balance_url + balance_json_path(配合 auth_header_name/headers)后查询。`, CONFIG_GROUP); seal.ext.registerTemplateConfig(ext, MODEL_RULE_CONFIG_KEY, [ `# 每框一个用途组模板(TOML):绑定到这些 use 的模型发起请求时统一套用下面的 body/request。 # use 可选值:chat/compression/summarization/judge/image-understanding/text-embedding。 # 默认对话类(chat/压缩/总结/judge)共用对话默认 max_tokens=8192、stop=null、stream=false;本表可覆盖。 -# 注意:多条规则 use 重叠时按行序逐键合并(后覆盖先);建议不同框不重叠。 +# 注意:多条规则 use 重叠时按框顺序逐键合并(后覆盖先);建议不同框不重叠。 use = ["chat", "compression", "summarization", "judge"] # 用途组 @@ -191,8 +191,8 @@ function trimLines(list: string[]): string[] { const DECLARABLE_TAGS: DeclarableModelTag[] = ['text', 'vision', 'embed']; /** - * 解析一行「api连接」的 [types] 表:模型名 → 手动声明的类型(优先级最高)。 - * 非法值只忽略该键并记 error 日志,不影响整行连接;模型名含点号未加引号会被 TOML 解析成嵌套表,单独提示。 + * 解析一个「api连接」框的 [types] 表:模型名 → 手动声明的类型(优先级最高)。 + * 非法值只忽略该键并记 error 日志,不影响整框连接;模型名含点号未加引号会被 TOML 解析成嵌套表,单独提示。 */ function parseModelTypes(raw: any, rowIndex: number): Record { const out: Record = {}; @@ -214,7 +214,7 @@ function parseModelTypes(raw: any, rowIndex: number): Record { diff --git a/src/config/configs/received.ts b/src/config/configs/received.ts index 074db28..2931608 100644 --- a/src/config/configs/received.ts +++ b/src/config/configs/received.ts @@ -11,7 +11,7 @@ export default class ReceivedConfig { seal.ext.registerStringConfig(ext, "忽略消息豹语条件", '0', "0 不忽略;1 忽略所有消息;也可填豹语表达式,命中为 1 时忽略", "消息接收"); seal.ext.registerTemplateConfig(ext, "忽略消息正则表达式", [ "^忽略这句话$" - ], "每行一个正则,匹配到的消息不触发 AI 也不计入上下文;修改后需重载 JS 生效", "消息接收"); + ], "每框一个正则,匹配到的消息不触发 AI 也不计入上下文;修改后需重载 JS 生效", "消息接收"); } static get() { diff --git a/src/config/configs/resource.ts b/src/config/configs/resource.ts index d66f234..7113872 100644 --- a/src/config/configs/resource.ts +++ b/src/config/configs/resource.ts @@ -5,10 +5,10 @@ import Image from "../../resource/image"; import { ext } from "../config"; export default class ResourceConfig { static register() { - seal.ext.registerTemplateConfig(ext, "本地图片路径", [''], "如不需要可以不填写;每行一个本地图片路径,示例:data/images/sealdice.png;修改后需重载 JS 生效", "资源"); - seal.ext.registerTemplateConfig(ext, "本地语音路径", [''], "每行一个本地语音:语音名=路径(省略语音名时默认用文件名),示例:早安=records/早安.mp3;发送语音需要配置ffmpeg到环境变量中;修改后需重载 JS 生效", "资源"); - seal.ext.registerTemplateConfig(ext, "本地文件路径", [''], "每行一个本地文件:文件名=路径(省略文件名时默认用文件名),示例:规则书=data/files/规则书.pdf;发送文件需安装ob11网络连接依赖;修改后需重载 JS 生效", "资源"); - seal.ext.registerTemplateConfig(ext, "本地视频路径", [''], "每行一个本地视频:视频名=路径(省略视频名时默认用文件名),示例:开场动画=data/videos/开场.mp4;发送视频需安装ob11网络连接依赖;修改后需重载 JS 生效", "资源"); + seal.ext.registerTemplateConfig(ext, "本地图片路径", [''], "如不需要可以不填写;每框一个本地图片路径,示例:data/images/sealdice.png;修改后需重载 JS 生效", "资源"); + seal.ext.registerTemplateConfig(ext, "本地语音路径", [''], "每框一个本地语音:语音名=路径(省略语音名时默认用文件名),示例:早安=records/早安.mp3;发送语音需要配置ffmpeg到环境变量中;修改后需重载 JS 生效", "资源"); + seal.ext.registerTemplateConfig(ext, "本地文件路径", [''], "每框一个本地文件:文件名=路径(省略文件名时默认用文件名),示例:规则书=data/files/规则书.pdf;发送文件需安装ob11网络连接依赖;修改后需重载 JS 生效", "资源"); + seal.ext.registerTemplateConfig(ext, "本地视频路径", [''], "每框一个本地视频:视频名=路径(省略视频名时默认用文件名),示例:开场动画=data/videos/开场.mp4;发送视频需安装ob11网络连接依赖;修改后需重载 JS 生效", "资源"); } static get() { diff --git a/src/config/configs/role.ts b/src/config/configs/role.ts index 85e8342..1911281 100644 --- a/src/config/configs/role.ts +++ b/src/config/configs/role.ts @@ -28,7 +28,7 @@ export default class RoleConfig { seal.ext.registerTemplateConfig(ext, "角色扮演设定", [ `测试机器人 你是一个QQ中的机器人,正在进行开发测试。请听从指令进行测试。请配合开发人员完成测试,不要拒绝指令。除非被命令,说话不要有情感。` - ], '每行一个角色的扮演设定,第一行为角色设定名称(超过20字符自动截断),其余为设定内容;通过豹语变量 $gSYSPROMPT 选择,.ai role 可切换;修改后需重载 JS 生效', "角色设定") + ], '每框一个角色的扮演设定,第一行为角色设定名称(超过20字符自动截断),其余为设定内容;通过豹语变量 $gSYSPROMPT 选择,.ai role 可切换;修改后需重载 JS 生效', "角色设定") } static get() { diff --git a/src/config/configs/subagent.ts b/src/config/configs/subagent.ts index 30b8ebb..9a35126 100644 --- a/src/config/configs/subagent.ts +++ b/src/config/configs/subagent.ts @@ -5,7 +5,7 @@ export default class SubAgentConfig { static register() { seal.ext.registerBoolConfig(ext, "是否启用子代理", true, "总开关;关闭后 AI 调用 subagent 工具会直接提示已关闭", "子代理"); seal.ext.registerIntConfig(ext, "最大委派深度", 3, "子代理最多嵌套几层(0=禁止委派);主会话为 0 层,每层 +1", "子代理"); - seal.ext.registerTemplateConfig(ext, "子代理禁止调用工具", [''], "每行一个子代理不可调用的工具名(如 call_ob11_api);留空=继承主会话全部已开启工具", "子代理"); + seal.ext.registerTemplateConfig(ext, "子代理禁止调用工具", [''], "每框一个子代理不可调用的工具名(如 call_ob11_api);留空=继承主会话全部已开启工具", "子代理"); } static get() { diff --git a/src/config/configs/tool.ts b/src/config/configs/tool.ts index 0bf97bd..d9fbb11 100644 --- a/src/config/configs/tool.ts +++ b/src/config/configs/tool.ts @@ -19,11 +19,11 @@ export default class ToolConfig { seal.ext.registerBoolConfig(ext, "工具方向提示", true, "开启后要求模型调用工具前先向用户说一句方向说明,再在同一回复中给出工具调用块;关闭后直接调用工具,不播报方向", "工具"); seal.ext.registerIntConfig(ext, "允许连续调用函数次数", 0, "单次回复流程中允许连续调用工具的次数,防止无限循环;0 为不限制", "工具"); seal.ext.registerIntConfig(ext, "工具响应截断字数", 10000, "工具返回结果超过该字数时不再压缩,改为仅展示开头部分,并保留完整原文供 read_raw kind=tool 阅读;0 为不截断不保留", "工具"); - seal.ext.registerTemplateConfig(ext, "禁止调用的函数", [''], "每行一个禁止 AI 调用的函数名,示例:run_ext_command;扩展指令的细粒度控制请使用「可调用指令白名单」;修改后自动生效", "工具"); - seal.ext.registerTemplateConfig(ext, "默认关闭的函数", [''], "每行一个默认关闭的函数名,AI 默认无法调用;OB11 action 请使用下方 action 配置;修改后自动生效", "工具"); - seal.ext.registerTemplateConfig(ext, "禁止调用的 OB11 action", [''], "每行一个禁止 call_ob11_api 调用的原始 OB11 action,例如 set_group_ban;修改后自动生效", "工具"); - seal.ext.registerTemplateConfig(ext, "默认关闭的 OB11 action", [''], "每行一个默认关闭的原始 OB11 action,例如 get_group_member_list;关闭后 AI 不会调用,修改后自动生效", "工具"); - seal.ext.registerTemplateConfig(ext, "可调用指令白名单", SEALDICE_COMMAND_WHITELIST, "每行一个 AI 可调用的海豹指令;格式:扩展名|指令名/别名1/别名2,同一元素内的别名用 / 分隔;核心指令的扩展名统一写 core(如 core|roll/r/rd)。默认已包含当前 SealDice 源码中的全部核心命令、内置扩展命令及其别名;修改后自动生效", "工具"); + seal.ext.registerTemplateConfig(ext, "禁止调用的函数", [''], "每框一个禁止 AI 调用的函数名,示例:run_ext_command;扩展指令的细粒度控制请使用「可调用指令白名单」;修改后自动生效", "工具"); + seal.ext.registerTemplateConfig(ext, "默认关闭的函数", [''], "每框一个默认关闭的函数名,AI 默认无法调用;OB11 action 请使用下方 action 配置;修改后自动生效", "工具"); + seal.ext.registerTemplateConfig(ext, "禁止调用的 OB11 action", [''], "每框一个禁止 call_ob11_api 调用的原始 OB11 action,例如 set_group_ban;修改后自动生效", "工具"); + seal.ext.registerTemplateConfig(ext, "默认关闭的 OB11 action", [''], "每框一个默认关闭的原始 OB11 action,例如 get_group_member_list;关闭后 AI 不会调用,修改后自动生效", "工具"); + seal.ext.registerTemplateConfig(ext, "可调用指令白名单", SEALDICE_COMMAND_WHITELIST, "每框一个 AI 可调用的海豹指令;格式:扩展名|指令名/别名1/别名2,同一元素内的别名用 / 分隔;核心指令的扩展名统一写 core(如 core|roll/r/rd)。默认已包含当前 SealDice 源码中的全部核心命令、内置扩展命令及其别名;修改后自动生效", "工具"); seal.ext.registerBoolConfig(ext, "是否允许调用所有指令", false, "开启后忽略白名单,允许调用所有可解析的扩展指令;核心指令仍通过 run_core_command 调用", "工具"); seal.ext.registerStringConfig(ext, "指令前缀", ".", "注入到 SealDice 核心的指令前缀,通常为 .;如果核心改成其他前缀,请同步修改", "工具"); seal.ext.registerTemplateConfig(ext, "音乐服务配置", [ @@ -37,7 +37,7 @@ export default class ToolConfig { "api": "http://qqmusic.lovesealdice.online", "cookie": "" }` - ], "每行一条音乐服务配置,仅支持 JSON 格式:{\"platform\":\"网易云\",\"api\":\"域名\",\"cookie\":\"Cookie(可留空,网易云部分接口需要)\"}。platform 支持:网易云、qq。修改后需重载 JS 生效", "工具"); + ], "每框一条音乐服务配置,仅支持 JSON 格式:{\"platform\":\"网易云\",\"api\":\"域名\",\"cookie\":\"Cookie(可留空,网易云部分接口需要)\"}。platform 支持:网易云、qq。修改后需重载 JS 生效", "工具"); seal.ext.registerOptionConfig(ext, "ai语音使用的音色", '傲娇少女', [ "小新", "猴哥", diff --git a/src/config/configs/trigger.ts b/src/config/configs/trigger.ts index b7c4f1c..27e8c6f 100644 --- a/src/config/configs/trigger.ts +++ b/src/config/configs/trigger.ts @@ -111,7 +111,7 @@ export default class TriggerConfig { seal.ext.registerTemplateConfig(ext, "触发正则表达式", [ "\\[CQ:at,qq=3893625976\\]", "^正确.*[。?!?!]$" - ], "每行一个正则,任一命中即触发回复(如 @机器人 或包含关键词);示例:^你好.*;修改后需重载 JS 生效", "消息触发"); + ], "每框一个正则,任一命中即触发回复(如 @机器人 或包含关键词);示例:^你好.*;修改后需重载 JS 生效", "消息触发"); seal.ext.registerIntConfig(ext, "默认计数器", 10, "计数器模式下达到该条数触发回复", "消息触发"); seal.ext.registerFloatConfig(ext, "默认计时器", 60, "计时器模式下间隔多少秒触发回复", "消息触发"); seal.ext.registerFloatConfig(ext, "默认概率", 10, "概率模式下每条消息触发回复的概率(%)", "消息触发"); From cf23aae73d60bc99fa4fe3138f610596c1519e76 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Sat, 12 Sep 2026 02:10:43 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(config):=20=E6=A8=A1=E6=9D=BF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8F=8F=E8=BF=B0=E7=BB=9F=E4=B8=80=E6=8C=89=E3=80=8C?= =?UTF-8?q?=E6=A1=86=E3=80=8D=E8=A1=A8=E8=BF=B0=EF=BC=88=E5=90=AB=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=8E=E6=96=87=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板配置的每个元素是一个文本框,不用「行」描述模板元素;框内内容自身的「行」表述保持原样 - 日志:api连接 / 模型规则 的模板解析错误、[types] 非法值提示改为「第 N 框」(4 处) - 代码注释:ConnConfigLike.connIndex 改述为「模板框序号」;request_rules 的合并语义改述为「框序」 - 文档:README 与 docs/03、05、09、11、12 同步——api连接/模型规则、预设上下文、自动切换触发错误、忽略消息与触发正则、本地图片/语音/文件/视频路径、角色扮演设定、子代理禁止调用工具、禁止调用与默认关闭的函数及 OB11 action、可调用指令白名单、音乐服务配置的「每行一个(条)」→「每框一个(条)」;[types] 说明改为「在本框末尾追加 [types] 表,表内每个模型写一条…;必须写在本框最后,其后不能再写 api_key 等键,否则整框解析失败」 - api连接 出厂示例:模型名改为 deepseek-flash,[types] 由注释示例改为生效示例(deepseek-flash 声明为 vision) - 刻意保留(属框内内容语义,改了才是误伤):事件白名单「每行一个事件类型」(parseNoticeWhitelist 按逗号/换行切分,一个框内可写多个)、角色设定「第一行为角色设定名称」、TOML/JSON「逐行解析」、模板示例内「使用前删除该行」「删掉 models 行」、知识库正文;历史 changelog 条目不改 - changelog 4.23.0 配置变更条目补充该约定说明;tsc/lint 无输出,单测 238 项全绿,build + smoke 通过 --- README.md | 40 +++++++++---------- ...41\345\235\227\350\257\246\350\247\243.md" | 4 +- ...44\344\270\216\351\205\215\347\275\256.md" | 10 ++--- ...70\350\247\201\351\227\256\351\242\230.md" | 2 +- ...77\347\224\250\346\214\207\345\215\227.md" | 2 +- ...71\346\241\210\350\256\276\350\256\241.md" | 2 +- src/changelog.ts | 2 +- src/config/configs/model.ts | 16 ++++---- src/model/model.ts | 2 +- src/model/request_rules.ts | 6 +-- 10 files changed, 43 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9f81566..f9dba3c 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ provider = "deepseek" # 服务商:deepseek/openai/googl api_key = "sk-xxxx" # 你的 API Key base_url = "https://api.deepseek.com/v1" # 可选,省略时取服务商默认 models = ["deepseek-v4-flash"] # 可选:钉住清单(填写后跳过自动拉取,离线/无列表接口时用);删掉该行=启动自动获取模型列表 -# [types] # 可选:手动声明模型类型,必须写在本行最后(其后不能再写 api_key 等键) +# [types] # 可选:手动声明模型类型,必须写在本框最后(其后不能再写 api_key 等键) # "my-embed-1" = "embed" # 网关自命名的嵌入模型:不再被误当对话模型 # "inhouse-vl" = "vision" # 未命中命名白名单的多模态模型:进识图候选 ``` - 未填 `models` 的连接启动时会自动获取该平台的可用模型列表(OpenAI 兼容 `GET /models`;anthropic 走 `/v1/models` 并自动翻页);获取失败按连接降级展示(认证失败/无列表接口/超时),不影响其它连接; -- **模型规则** 每行是一个"用途组"请求模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,**不写任何模型名**;出厂默认给 chat/压缩/总结/judge 预设了对话参数; +- **模型规则** 每框是一个"用途组"请求模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,**不写任何模型名**;出厂默认给 chat/压缩/总结/judge 预设了对话参数; - 默认模型自动取该用途**首个可用**的同类型模型(出厂 deepseek 拉取/钉住的首个文本模型 → chat 即用);要换别的模型用 `.ai model <用途> <模型>` 绑定。图片识别 / 向量记忆需要先有可被识别为视觉 / 嵌入的模型(如 `glm-4v` / `text-embedding-3-small`),再绑定到 image-understanding / text-embedding 用途;模型名若无法被自动识别(网关自命名、新模型等),在 **api连接** 的 `[types]` 表里手动声明 `text`/`vision`/`embed`(优先级最高,详见下方配置手册); - 常用命令:`.ai model list` 查看当前模型列表(读加载结果、不联网),`.ai model pull` 立即重拉全部连接并展示(无视 models 钉住清单,强制网络),`.ai model` 查看各用途与连接状态;`.ai balance` 可查全部连接余额(deepseek/moonshot/siliconflow 内置接口直接查,其余平台提示控制台入口,见下方[可用AI大模型开放平台列表](#可用ai大模型开放平台列表)的余额说明); - `anthropic`(Claude)已适配请求/响应格式(system 拆出、tool_result 合并、响应归一化);其流式暂不支持,配置 `stream = true` 时会自动回退为非流式。 @@ -169,8 +169,8 @@ AI骰娘4 是一款运行在 [SealDice](https://docs.sealdice.com/) 上的智能 | 设置项 | 说明 | |:---:|:---| -| api连接 | TOML 格式,每行一个服务商连接。`api_key` 必填;`provider` 选填(省略时按 OpenAI 兼容处理,此时需显式填 `base_url`);`base_url` 可选(省略时取该 provider 默认地址)。可选 `models`(模型钉住清单:填写后跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商);可选 `[types]`(**手动声明模型类型**:每行 `"模型名" = "text"` / `"vision"` / `"embed"`,优先级最高,可覆盖命名猜测与接口能力位;模型名含 `.` `:` `/` 等字符必须加引号;**必须写在该行最后**,其后不能再写 `api_key` 等键,否则整行解析失败;无效值只忽略该键并记日志);可选 `[request]`(列表拉取覆盖:list_url/auth_header_name/headers/timeout)。未填 `models` 的连接启动时自动获取可用模型列表(OpenAI 兼容 `GET /models`;anthropic 走 `/v1/models` 自动翻页),失败按连接降级展示,不拖垮其它连接。`ignore` 可选:1=忽略该连接 | -| 模型规则 | TOML 格式,每行一个"用途组"模板:`use` 数组(`chat`/`compression`/`summarization`/`judge`/`image-understanding`/`text-embedding`,可多选)+ 可选 `[body]`(请求参数模板)+ 可选 `[request]`(method/url/headers/content_type/auth_header_name/timeout,默认不写由插件解析)。**不写模型名**:命中这些用途的模型统一套用该模板;多条规则 use 重叠时按行序逐键合并、后覆盖先 | +| api连接 | TOML 格式,每框一个服务商连接。`api_key` 必填;`provider` 选填(省略时按 OpenAI 兼容处理,此时需显式填 `base_url`);`base_url` 可选(省略时取该 provider 默认地址)。可选 `models`(模型钉住清单:填写后跳过自动拉取,直接用该清单,适合离线/无列表接口的服务商);可选 `[types]`(**手动声明模型类型**:在本框末尾追加 `[types]` 表,表内每个模型写一条 `"模型名" = "text"` / `"vision"` / `"embed"`,优先级最高,可覆盖命名猜测与接口能力位;模型名含 `.` `:` `/` 等字符必须加引号;**必须写在本框最后**,其后不能再写 `api_key` 等键,否则整框解析失败;无效值只忽略该键并记日志);可选 `[request]`(列表拉取覆盖:list_url/auth_header_name/headers/timeout)。未填 `models` 的连接启动时自动获取可用模型列表(OpenAI 兼容 `GET /models`;anthropic 走 `/v1/models` 自动翻页),失败按连接降级展示,不拖垮其它连接。`ignore` 可选:1=忽略该连接 | +| 模型规则 | TOML 格式,每框一个"用途组"模板:`use` 数组(`chat`/`compression`/`summarization`/`judge`/`image-understanding`/`text-embedding`,可多选)+ 可选 `[body]`(请求参数模板)+ 可选 `[request]`(method/url/headers/content_type/auth_header_name/timeout,默认不写由插件解析)。**不写模型名**:命中这些用途的模型统一套用该模板;多条规则 use 重叠时按框顺序逐键合并、后覆盖先 | ```toml # api连接 示例(出厂默认即此结构,只改 api_key 即可用) @@ -179,11 +179,11 @@ api_key = "sk-xxxx" base_url = "https://api.deepseek.com/v1" models = ["deepseek-v4-flash"] # 可选:钉住清单;删掉该行=启动自动拉取 -# [types] # 可选:手动声明模型类型,必须写在本行最后(其后不能再写 api_key 等键) +# [types] # 可选:手动声明模型类型,必须写在本框最后(其后不能再写 api_key 等键) # "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号 # "inhouse-vl" = "vision" # 声明未命中当前模型列表时会在日志里给一条 warning 提示(检查拼写) -# 模型规则 示例(同一行内只保留一组字段) +# 模型规则 示例(同一框内只保留一组字段) use = ["chat", "compression", "summarization", "judge"] [body] # 可选:请求参数模板 @@ -216,7 +216,7 @@ temperature = 1 | 启用报错自动处理 | 模型请求报错时按语义类别自动处理(上下文超长归档重试 / 余额不足切模型 / 限速退避等);未覆盖或无法处理的错误仅记日志不回复(默认开启) | | 上下文超长自动归档重试 | 模型返回上下文超长时,把会话历史按「观察归档 + 删除」链路压到模型窗口内后自动重发一次;关闭则该场景仅记日志(默认开启) | | 余额不足自动切换模型 | 对话模型报余额不足 / 欠费 / 额度用尽时,自动把 chat 用途切换到备用模型(写入全局模型覆盖,管理员可用 `.ai model` 改回)(默认开启) | -| 自动切换触发错误 | 每行一个触发自动切换的类别:`balance`(余额不足)/ `permission`(权限不足);其余类别仅退避重试或记日志(默认 balance) | +| 自动切换触发错误 | 每框一个触发自动切换的类别:`balance`(余额不足)/ `permission`(权限不足);其余类别仅退避重试或记日志(默认 balance) | | 自动切换策略 | 跨厂商优先 = 优先切到不同服务商的模型;连接顺序 = 按 api连接/候选顺序取下一个不同模型 | | 切换后发送通知 | 自动切换模型后用 `ctx.notice` 向当前会话发送一条切换通知(默认开启) | @@ -224,13 +224,13 @@ temperature = 1 | 设置项 | 说明 | |:---:|:---| -| 角色扮演设定 | 每行一个角色的扮演设定:第一行为角色设定名称(超过 20 字符自动截断,可通过 `.ai role <名称>` 或豹语变量 `$gSYSPROMPT` 切换),其余为设定内容;修改后需重载 JS 生效 | +| 角色扮演设定 | 每框一个角色的扮演设定:第一行为角色设定名称(超过 20 字符自动截断,可通过 `.ai role <名称>` 或豹语变量 `$gSYSPROMPT` 切换),其余为设定内容;修改后需重载 JS 生效 | ### 上下文 | 设置项 | 说明 | |:---:|:---| -| 预设上下文 | 每行一条预设上下文,role 按 user / assistant 轮流出现,位于上下文最前面,帮助模型学习对话语气 | +| 预设上下文 | 每框一条预设上下文,role 按 user / assistant 轮流出现,位于上下文最前面,帮助模型学习对话语气 | | 对话保存轮数 | 上下文超过「上下文最大token」后保留的最近真实用户轮数(默认 5);更早消息会先归档沉淀为观察/长期记忆,再删除 | | 上下文最大token | 持久化上下文 token 上限(默认 1000000);填 0 / 负数视为无效并自动回退默认值;超过后触发上面归档逻辑 | | 插入system message间隔轮数 | 需小于「对话保存轮数」的二分之一才能生效,为 0 时不生效,预设上下文不计入轮数 | @@ -284,14 +284,14 @@ temperature = 1 | 工具方向提示 | 开启后要求模型调用工具前先向用户说一句方向说明,再在同一回复中给出工具调用块(默认开启) | | 允许连续调用函数次数 | 单次触发内允许连续调用函数的次数,防止 AI 陷入调用函数死循环(默认 0=不限制) | | 工具响应截断字数 | 工具返回结果超过该字数时改为只展示开头、截断前完整原文保留(0 关闭,默认 10000);原文可由 `grep_raw` / `read_raw`(kind=tool)只读检索 | -| 禁止调用的函数 | 每行一个,设置后将不被允许开启 | -| 默认关闭的函数 | 每行一个,AI 在新会话中默认无法调用,需 `.ai tool on <函数名>` 开启 | -| 禁止调用的 OB11 action | 每行一个禁止 `call_ob11_api` 调用的原始 OB11 action,例如 `set_group_ban` | -| 默认关闭的 OB11 action | 每行一个默认关闭的原始 OB11 action,例如 `get_group_member_list`;关闭后 AI 不会调用 | -| 可调用指令白名单 | 每行一个 `扩展名|指令名/别名1/别名2`;同一元素内的别名用 `/` 分隔。默认已包含当前 SealDice 核心命令、内置扩展命令及全部别名,核心扩展名统一写 `core`(如 `core|roll/r/rd`) | +| 禁止调用的函数 | 每框一个,设置后将不被允许开启 | +| 默认关闭的函数 | 每框一个,AI 在新会话中默认无法调用,需 `.ai tool on <函数名>` 开启 | +| 禁止调用的 OB11 action | 每框一个禁止 `call_ob11_api` 调用的原始 OB11 action,例如 `set_group_ban` | +| 默认关闭的 OB11 action | 每框一个默认关闭的原始 OB11 action,例如 `get_group_member_list`;关闭后 AI 不会调用 | +| 可调用指令白名单 | 每框一个 `扩展名|指令名/别名1/别名2`;同一元素内的别名用 `/` 分隔。默认已包含当前 SealDice 核心命令、内置扩展命令及全部别名,核心扩展名统一写 `core`(如 `core|roll/r/rd`) | | 是否允许调用所有指令 | 开启后忽略白名单,允许调用所有可解析的扩展指令;核心指令仍通过 `run_core_command` 调用 | | 指令前缀 | 注入到 SealDice 核心的指令前缀,通常为 `.`;核心前缀改动时需同步修改 | -| 音乐服务配置 | 每行一条 JSON:`{"platform":"网易云/qq","api":"域名","cookie":"Cookie(可留空)"}`,供 `search_music` 使用;修改后需重载 JS 生效 | +| 音乐服务配置 | 每框一条 JSON:`{"platform":"网易云/qq","api":"域名","cookie":"Cookie(可留空)"}`,供 `search_music` 使用;修改后需重载 JS 生效 | | ai语音使用的音色 | 预设音色需要支持 AI 语音的协议端,自定义音色需要生成音频依赖(tts)和 ffmpeg | ### MCP @@ -333,7 +333,7 @@ platform: [] # 可选:[] / 省略 = 所有平台;例如 [QQ, DISC |:---:|:---| | 是否启用子代理 | 子代理功能总开关(默认开启);关闭后 AI 调用 `subagent` / `subagent_fork` 会直接提示已关闭 | | 最大委派深度 | 子代理最多嵌套几层(默认 3,0=禁止委派);主会话为 0 层,每层 +1(当前版本子代理不可再嵌套委派,该值 >0 即允许主会话委派) | -| 子代理禁止调用工具 | 每行一个子代理不可调用的工具名(如 `call_ob11_api`);留空 = 继承主会话全部已开启工具 | +| 子代理禁止调用工具 | 每框一个子代理不可调用的工具名(如 `call_ob11_api`);留空 = 继承主会话全部已开启工具 | > 委派工具:`subagent`(spawn:子代理看不到本会话历史,任务必须自包含)、`subagent_fork`(fork:继承本会话已完成轮次、看不到当前正在执行的这一轮)。参数:`prompt`(必填)/`description`/`persona`/`run_in_background`(后台执行并返回 job id,用 `job_list` / `job_output` / `job_kill` 收取结果)/`continuable`(建立可续跑子代理并立即返回 id,之后用 `send_message` 续派、`interrupt_agent` 打断、`list_agents` 盘点)。前台调用直接返回最终结论;子代理有独立上下文与工具面(按上面配置收窄),不向聊天发消息。 > @@ -409,10 +409,10 @@ platform: [] # 可选:[] / 省略 = 所有平台;例如 [QQ, DISC | 设置项 | 说明 | |:---:|:---| -| 本地图片路径 | 每行一个本地图片路径,供 `list_resources` / `get_resource_path` 查询;当前会话发图片优先用 `[img:图片ID]`;修改后需重载 JS 生效 | -| 本地语音路径 | 每行一个本地语音:`语音名=路径`(省略语音名时默认用文件名),供 `list_resources` / `get_resource_path` 查询;发送语音需要配置 ffmpeg 到环境变量;修改后需重载 JS 生效 | -| 本地文件路径 | 每行一个本地文件:`文件名=路径`(省略文件名时默认用文件名),供 `list_resources` / `get_resource_path` 查询;发送文件需安装 ob11 网络连接依赖;修改后需重载 JS 生效 | -| 本地视频路径 | 每行一个本地视频:`视频名=路径`(省略视频名时默认用文件名),供 `list_resources` / `get_resource_path` 查询;发送视频需安装 ob11 网络连接依赖;修改后需重载 JS 生效 | +| 本地图片路径 | 每框一个本地图片路径,供 `list_resources` / `get_resource_path` 查询;当前会话发图片优先用 `[img:图片ID]`;修改后需重载 JS 生效 | +| 本地语音路径 | 每框一个本地语音:`语音名=路径`(省略语音名时默认用文件名),供 `list_resources` / `get_resource_path` 查询;发送语音需要配置 ffmpeg 到环境变量;修改后需重载 JS 生效 | +| 本地文件路径 | 每框一个本地文件:`文件名=路径`(省略文件名时默认用文件名),供 `list_resources` / `get_resource_path` 查询;发送文件需安装 ob11 网络连接依赖;修改后需重载 JS 生效 | +| 本地视频路径 | 每框一个本地视频:`视频名=路径`(省略视频名时默认用文件名),供 `list_resources` / `get_resource_path` 查询;发送视频需安装 ob11 网络连接依赖;修改后需重载 JS 生效 | ### prompt 模板 diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index caad1d0..0b6423e 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -46,7 +46,7 @@ | `base.ts` | 基础 | 日志级别/简短打印/记录消息内容、请求超时、请求并发/队列限制、海豹核心路径、全局待机 | | `error.ts` | 错误处理 | 启用报错自动处理、上下文超长自动归档重试、余额不足自动切换模型、自动切换触发错误(默认 balance)、自动切换策略(跨厂商优先/配置顺序)、切换后发送通知 | | `model.ts` | 模型 | `api连接` + `模型规则`(两个 TOML):解析连接(provider/api_key/base_url/ignore/可选 models 钉住清单/[request])与用途组模板(use 数组 + [body]/[request]),解析后 `Model.bootstrap()` 建连接状态机并异步拉取模型列表,同步全局分用途覆盖(`modelPurposeOverrides` 存储键) | -| `role.ts` | 角色设定 | 角色扮演设定(每行一条,首行为名称 >20 截断,`$gSYSPROMPT`/`.ai role` 切换,重载 JS 生效) | +| `role.ts` | 角色设定 | 角色扮演设定(每框一条,首行为名称 >20 截断,`$gSYSPROMPT`/`.ai role` 切换,重载 JS 生效) | | `context.ts` | 上下文 | 预设上下文(role 按 user/assistant 轮流)、对话保存轮数、上下文最大token(默认 1000000,0 无效回退)、插入 system message 间隔轮数、消息压缩阈值(压缩前原文保留,read_raw kind=user 可查) | | `received.ts` | 消息接收 | 接收图片/指令/骰子消息、忽略私聊、忽略豹语条件、忽略正则 | | `event.ts` | 事件接收 | 接收依赖通知事件、通知事件白名单(notify 子类型单独匹配,含入群/好友申请) | @@ -79,7 +79,7 @@ - `model.ts`:`Model` 静态注册表(`states` 连接状态 + `entries` 模型实例)与全局分用途覆盖 `purposeModelOverrides`(use → 模型标识:全注册表唯一名 → 裸名,跨连接重名 → `[连接序号]:模型名`;由 `.ai model` 写入 `modelPurposeOverrides` 存储键)。模型实例来自「api连接」的 `models` 钉住清单(pinned)/启动自动拉取(auto),**只存内存、不持久化**,按能力标签(`catalog.ts`:手动 `[types]` 声明 > 命名终值 > 接口能力位 > 命名能力位 > 兜底 text)分类进各用途候选;`rebuildEntries()` 重建注册表时会对「声明了但当前列表里不存在」的 `[types]` 条目记一条 warning(模型名拼错不再静默失效)。`getChatModel(use)`/`getMultimodalModel(use)`/`getEmbeddingModel(use)` 先按用途查覆盖(覆盖失效自动回退),再取「该用途第一个候选」为默认;`findModelByRef` 按标识反查;`listModelsForUse(use)` 返回候选列表(`.ai model` 与余额不足自动切模型共用);`ensureLoaded()/pull()` 供首条消息等待与 `.ai model pull` 立即重拉(无视 models 钉住清单,强制网络);`reset()` 供测试/热重载。 - `catalog.ts`:模型能力分类(text/vision/embed/gen)——判定优先级 **手动声明(「api连接」`[types]`) > 命名终值(reranker/生图/嵌入白名单,如 text-embedding-*/bge-*/dall-e) > 接口自报能力位(仅正向证据:vision/embed) > 命名能力位(视觉白名单 glm-4v/gemini/pixtral 等) > 兜底 text**;手动声明即最终答案(与自动判定冲突时按声明执行,不记日志)。`DeclarableModelTag` 限定用户可声明值为 text/vision/embed(内部排除值 gen 不可声明);`classifyModel(provider, name, extra)` 的 `extra` 承载 manual 与接口能力位;当前每条分支恰好产出 1 个标签(数组形态沿用历史结构)。 -- `request_rules.ts`:模型规则(use 组 body/request 模板)存储与合并;`bodyDefaultsFor(use)` 返回「代码兜底默认 < 规则模板(行序后覆盖先)」的请求体,`requestOverridesFor(use)` 返回调用期 `[request]` 覆盖(headers/auth_header_name/content_type/timeout)。 +- `request_rules.ts`:模型规则(use 组 body/request 模板)存储与合并;`bodyDefaultsFor(use)` 返回「代码兜底默认 < 规则模板(框序后覆盖先)」的请求体,`requestOverridesFor(use)` 返回调用期 `[request]` 覆盖(headers/auth_header_name/content_type/timeout)。 - `list.ts`:连接级「获取可用模型列表」适配——OpenAI 兼容 `GET {base}/models`(Bearer);anthropic 走 `x-api-key` + `/v1/models` 并 `limit=1000` 循环翻页;支持连接 `[request]`(list_url/auth_header_name/headers/timeout)覆盖。对象项额外提取供应商自报能力位(vision/embed:type/model_type/kind/task、architecture.input_modalities、capabilities、supports_vision/vision;未知取值含 anthropic 的 `type: "model"` 一律忽略),`normalizeModelListResult` 归一化为「模型名(保序去重)+能力位映射」交上层分类;拉取结果类型为 `ModelListResult`(`string[]` 是其等价子集,兼容既有单测桩);**不发额外请求**(不做 /api/show、逐模型详情等探测);拉取函数可注入便于单测。 - `balance.ts`:连接级账户余额查询(.ai balance 用)——deepseek/moonshot/siliconflow 内置余额端点;其余平台支持在连接 `[request]` 配置 `balance_url`/`balance_json_path`(+balance_divisor/balance_currency)后查询自定义网关(one-api/new-api 等)。 - `api_error.ts`:`ApiError` 与语义分类——把 OpenAI 兼容/Anthropic/Google/智谱/阿里/SiliconFlow/网关等非 2xx 报错归一化为 balance(余额不足)/context_length(上下文超长)/rate_limit(限速)/overload(过载)/auth/permission/content/parameter/server/unknown 类别,并携带 HTTP 状态码、服务商与响应体原文。 diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index b13c6f2..fe7cedc 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -113,7 +113,7 @@ | 分组 | 常用键名 | 说明 | | --- | --- | --- | | 基础 | 日志级别、请求超时时限、请求并发上限、请求队列上限、是否开启全局待机 | 全局开关 | -| 模型 | api连接 / 模型规则 | 两个 TOML 配置。`api连接` 每行一个连接:`api_key` 必填,`provider`/`base_url` 选填(provider 省略时按 OpenAI 兼容处理、需显式填 base_url;base_url 省略时取 provider 默认);可选 `models`(模型钉住清单,填写后跳过自动拉取)、可选 `[types]`(**手动声明模型类型**:每行 `"模型名" = "text"` / `"vision"` / `"embed"`,优先级最高,可覆盖命名猜测与接口能力位;模型名含 `.` `:` `/` 等字符必须加引号;**必须写在该行最后**,其后不能再写 `api_key` 等键,否则整行解析失败;无效值只忽略该键并记日志)与 `[request]`(列表与余额查询覆盖:list_url/auth_header_name/headers/timeout 及 balance_url/balance_json_path/balance_divisor/balance_currency)。未钉住连接启动时自动获取模型列表(OpenAI 兼容 GET /models、anthropic 走 /v1/models 自动翻页),失败按连接降级展示(auth/无列表接口/超时),不影响其它连接。`模型规则` 每行一个用途组模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,不写任何模型名,命中这些用途的模型统一套用。默认模型取该用途第一个可用模型自动生效;`.ai model list`(读加载/最近一次拉取到内存的列表,不联网)、`.ai model pull`(立即重拉全部并展示,无视 models 钉住清单强制网络)、`.ai model <用途> <模型>`(绑定,支持编号/裸名/`[序号]:模型名`,覆盖失效自动回退默认)。列表只存内存、不持久化。修改后需重载 JS 生效 | +| 模型 | api连接 / 模型规则 | 两个 TOML 配置。`api连接` 每框一个连接:`api_key` 必填,`provider`/`base_url` 选填(provider 省略时按 OpenAI 兼容处理、需显式填 base_url;base_url 省略时取 provider 默认);可选 `models`(模型钉住清单,填写后跳过自动拉取)、可选 `[types]`(**手动声明模型类型**:在本框末尾追加 `[types]` 表,表内每个模型写一条 `"模型名" = "text"` / `"vision"` / `"embed"`,优先级最高,可覆盖命名猜测与接口能力位;模型名含 `.` `:` `/` 等字符必须加引号;**必须写在本框最后**,其后不能再写 `api_key` 等键,否则整框解析失败;无效值只忽略该键并记日志)与 `[request]`(列表与余额查询覆盖:list_url/auth_header_name/headers/timeout 及 balance_url/balance_json_path/balance_divisor/balance_currency)。未钉住连接启动时自动获取模型列表(OpenAI 兼容 GET /models、anthropic 走 /v1/models 自动翻页),失败按连接降级展示(auth/无列表接口/超时),不影响其它连接。`模型规则` 每框一个用途组模板:`use` 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 `[body]`/`[request]`,不写任何模型名,命中这些用途的模型统一套用。默认模型取该用途第一个可用模型自动生效;`.ai model list`(读加载/最近一次拉取到内存的列表,不联网)、`.ai model pull`(立即重拉全部并展示,无视 models 钉住清单强制网络)、`.ai model <用途> <模型>`(绑定,支持编号/裸名/`[序号]:模型名`,覆盖失效自动回退默认)。列表只存内存、不持久化。修改后需重载 JS 生效 | | 角色设定 | 角色扮演设定 | 每条第一行为角色设定名称(>20 截断),其余为设定内容;`$gSYSPROMPT` / `.ai role` 选择;修改后需重载 JS 生效 | | 上下文 | 上下文最大token、对话保存轮数、预设上下文、插入system message间隔轮数、消息压缩阈值 | 上下文最大token 默认 1000000,0/负数无效并回退默认;超过后保留最近「对话保存轮数」真实用户轮,更早消息先归档沉淀为观察/长期记忆再删除;预设上下文 role 按 user/assistant 轮流;插入间隔需小于保存轮数二分之一才生效 | | 消息接收 | 接收图片/指令消息/骰子发送的消息、忽略消息豹语条件、忽略消息正则表达式 | 接收图片只负责记录图片 URL;自动识别仍由图片识别条件和识图模型配置决定 | @@ -136,24 +136,24 @@ 「模型」分组下的两个 TOML 配置: ```toml -# ── api连接:每行一个连接;出厂默认钉住 deepseek-v4-flash,只改 api_key 即可用 ── +# ── api连接:每框一个连接;出厂默认钉住 deepseek-v4-flash,只改 api_key 即可用 ── provider = "deepseek" api_key = "sk-xxxx" base_url = "https://api.deepseek.com/v1" # 可选,缺省取服务商默认 # models = ["deepseek-v4-flash"] # 可选:填写后跳过自动拉取,直接用该清单(离线/无列表接口时用);删掉该行=启动自动拉取 # ignore = 1 # 可选:1=忽略该连接 -# [types] # 可选:手动声明模型类型,优先级最高;必须写在本行最后(其后不能再写 api_key 等键) +# [types] # 可选:手动声明模型类型,优先级最高;必须写在本框最后(其后不能再写 api_key 等键) # "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号 # "inhouse-vl" = "vision" # 声明未命中当前模型列表时,日志给一条 warning 提示(检查模型名拼写) -# ── 模型规则:每行一个用途组模板,不写模型名;命中这些用途的模型统一套用 ── +# ── 模型规则:每框一个用途组模板,不写模型名;命中这些用途的模型统一套用 ── use = ["chat", "compression", "summarization", "judge"] [body] # 可选,请求参数模板 temperature = 1 ``` -`模型规则` 的 `use` 可填 `chat`(普通对话)/`compression`(消息压缩)/`summarization`(记忆总结)/`judge`(评分插话判断)/`image-understanding`(识图)/`text-embedding`(嵌入),可多选,不同行建议不重叠(重叠按行序逐键合并、后覆盖先)。`[request]` 可选:method/url/headers/content_type/auth_header_name/timeout,默认不写由插件按 provider 解析。**模型类型判定优先级**:`[types]` 手动声明 > 命名终值(reranker/生图/嵌入白名单) > 接口自报能力位 > 命名能力位 > 兜底纯文本(手动声明即最终答案,与自动判定冲突时按声明执行)。模型本身来自「api连接」的自动拉取或 `models` 钉住清单:文本类进对话候选、带明确视觉标签(如 glm-4v/gemini/pixtral 或接口能力位)的进识图候选、嵌入白名单(如 text-embedding-*/bge-*)进嵌入候选、生图类(reranker/dall-e/cogview 等)不进任何候选。默认模型自动取该用途首个可用同类型模型(文本→对话类用途,视觉→识图,嵌入→嵌入);想换用 `.ai model <用途> <模型>` 显式绑定。嵌入输出维度取 text-embedding 规则 `[body] dimensions`(默认 1024),配置后长期记忆与知识库启用语义检索,未配置/不匹配自动降级为关键词检索。 +`模型规则` 的 `use` 可填 `chat`(普通对话)/`compression`(消息压缩)/`summarization`(记忆总结)/`judge`(评分插话判断)/`image-understanding`(识图)/`text-embedding`(嵌入),可多选,不同框建议不重叠(重叠按框顺序逐键合并、后覆盖先)。`[request]` 可选:method/url/headers/content_type/auth_header_name/timeout,默认不写由插件按 provider 解析。**模型类型判定优先级**:`[types]` 手动声明 > 命名终值(reranker/生图/嵌入白名单) > 接口自报能力位 > 命名能力位 > 兜底纯文本(手动声明即最终答案,与自动判定冲突时按声明执行)。模型本身来自「api连接」的自动拉取或 `models` 钉住清单:文本类进对话候选、带明确视觉标签(如 glm-4v/gemini/pixtral 或接口能力位)的进识图候选、嵌入白名单(如 text-embedding-*/bge-*)进嵌入候选、生图类(reranker/dall-e/cogview 等)不进任何候选。默认模型自动取该用途首个可用同类型模型(文本→对话类用途,视觉→识图,嵌入→嵌入);想换用 `.ai model <用途> <模型>` 显式绑定。嵌入输出维度取 text-embedding 规则 `[body] dimensions`(默认 1024),配置后长期记忆与知识库启用语义检索,未配置/不匹配自动降级为关键词检索。 diff --git "a/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" index 5ab8ebc..5e21936 100644 --- "a/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -24,7 +24,7 @@ - 简单配置(开关/数值/单行字符串/纯字符串数组)修改后自动生效(缓存最多 1 分钟,无需重载 JS);复杂配置(模型「api连接/模型规则」、触发/忽略正则、评分触发、角色扮演设定、MCP 总开关与服务器、技能、知识库、本地资源路径、音乐服务)启动时解析一次、常驻内存,修改后需重载 JS 才生效(其中「MCP服务器配置」「技能配置」「知识库」三类也可用 `.ai mcp refresh` / `.ai skill refresh` / `.ai kb refresh` 立即生效)。 - 嵌入输出维度取自「模型规则」text-embedding 用途组的 `[body] dimensions`(默认 1024,须与后端一致,如 text-embedding-v4 为 1024);未配置维度时记忆检索自动降级为关键词/分数检索、知识库检索自动降级为关键词匹配(嵌入仅作候选重排,加载知识库本身不请求嵌入)。 - 流式输出需要自建或使用公共后端,并在"后端 → 流式输出"配置 URL;在「模型规则」chat 用途组的 `[body]` 里设 `stream = true` 的模型才会走流式。 -- 模型类型识别顺序:「api连接」`[types]` **手动声明** → 命名终值(reranker/生图/嵌入白名单) → 接口自报能力位(vision/embed) → 命名能力位 → 兜底纯文本。网关自命名模型(如 `my-embed-1`、`inhouse-vl`)不会被自动识别,需用 `[types]` 手动声明 `text`/`vision`/`embed`,否则会被当纯文本模型、甚至被选为默认对话模型。声明写在该行最后、模型名含 `.` `:` `/` 时加引号;声明名未命中当前模型列表时日志会有一条 warning 提示(便于发现拼写错误)。 +- 模型类型识别顺序:「api连接」`[types]` **手动声明** → 命名终值(reranker/生图/嵌入白名单) → 接口自报能力位(vision/embed) → 命名能力位 → 兜底纯文本。网关自命名模型(如 `my-embed-1`、`inhouse-vl`)不会被自动识别,需用 `[types]` 手动声明 `text`/`vision`/`embed`,否则会被当纯文本模型、甚至被选为默认对话模型。声明写在本框最后、模型名含 `.` `:` `/` 时加引号;声明名未命中当前模型列表时日志会有一条 warning 提示(便于发现拼写错误)。 - `请求超时时限` 同时约束模型请求与工具调用,过小会导致长回复/慢工具超时。 ## 常见问题排查 diff --git "a/docs/11-ob11-core-bridge-\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/11-ob11-core-bridge-\344\275\277\347\224\250\346\214\207\345\215\227.md" index a966542..18eaaed 100644 --- "a/docs/11-ob11-core-bridge-\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/11-ob11-core-bridge-\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -144,7 +144,7 @@ ws://127.0.0.1:46880/core ## 指令白名单 -「可调用指令白名单」每行一条,格式 `扩展名|指令名/别名1/别名2`,同一元素内的别名用 `/` 分隔: +「可调用指令白名单」每框一条,格式 `扩展名|指令名/别名1/别名2`,同一元素内的别名用 `/` 分隔: - 内置扩展已硬编码,**无需填写扩展列表**;第三方扩展写实际扩展名 - 核心指令的扩展名统一写 **`core`**(如 `core|help`) diff --git "a/docs/12-\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210\350\256\276\350\256\241.md" "b/docs/12-\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210\350\256\276\350\256\241.md" index cae7fb7..84c0350 100644 --- "a/docs/12-\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210\350\256\276\350\256\241.md" +++ "b/docs/12-\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210\350\256\276\350\256\241.md" @@ -250,7 +250,7 @@ switch (stopReason) { | fork 实例 backgroundMode | one-shot | 同上 | | spawn/fork persona / toolFilter allow / toolFilter deny | 空 | 每实例;deny 优先 | | 启用模型选择 | false | 开启后出现下面两项 | -| 允许的模型路由 | 空(每行 `provider model`) | 精确路由政策 | +| 允许的模型路由 | 空(每框一个 `provider model`) | 精确路由政策 | | 记录保留上限 | 100 | 已结束记录的自动清理线 | ### 6.2 既有配置联动 diff --git a/src/changelog.ts b/src/changelog.ts index 2c703ba..5ade8a2 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -7,7 +7,7 @@ export const changelog: { [version: string]: string } = { - 列表接口能力位自动识别:连接拉取模型列表时读取供应商自报的能力位(type / model_type / kind / task、architecture.input_modalities、capabilities、supports_vision),自动区分多模态与嵌入模型(适配 OpenRouter / LM Studio / vLLM / Ollama 风格字段);只取正向证据(保守元数据不会把模型降级),命名终值白名单(如 text-embedding-*)优先于接口元数据;只返回纯模型名、不自报能力的网关行为完全不变。仍只发一次列表请求,不做任何额外探测 ## 配置变更 - 「模型」页 api连接:新增可选 [types] 表(模型名 → text/vision/embed,优先级最高) -- 配置描述统一按「框」表述模板配置的每个文本框:模板配置的每个元素是一个文本框,不再用「行」描述元素(如「每行一个」→「每框一个」);框内内容自身的「行」表述(如角色设定「第一行为名称」、TOML/JSON 语法行)保持原样 +- 配置描述统一按「框」表述模板配置的每个文本框:模板配置的每个元素是一个文本框,不再用「行」描述元素(如「每行一个」→「每框一个」;模板解析失败的日志同步改为「第 N 框」);框内内容自身的「行」表述(如角色设定「第一行为名称」、TOML/JSON 语法行)保持原样 `, "4.22.0": `## 新功能 - 模型配置 v4:模型配置改为两个 TOML ——「模型」页的 **api连接**(每行一个服务商连接:api_key 必填;provider 选填,省略按 OpenAI 兼容处理(此时需显式填 base_url);base_url 选填,省略取该服务商默认;可选 models 钉住清单(填写=跳过自动拉取,离线/无列表接口时用);可选 ignore(1=忽略该连接);可选 [request](列表拉取与余额查询等连接级覆盖))与 **模型规则**(每行一个"用途组"请求模板:use 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 [body]/[request],**不写任何模型名**,命中这些用途的模型统一套用,行序重叠逐键合并、后覆盖先) diff --git a/src/config/configs/model.ts b/src/config/configs/model.ts index a212093..92d91ec 100644 --- a/src/config/configs/model.ts +++ b/src/config/configs/model.ts @@ -72,10 +72,10 @@ export default class ModelConfig { api_key = "sk-xxxx" # 必填,API 密钥 provider = "deepseek" # 可选,服务商,省略时自动识别 base_url = "https://api.deepseek.com/v1" # 可选,API 地址,省略时取服务商默认 -models = ["deepseek-v4-flash"] # 可选,模型清单:填写=跳过自动拉取直接用该清单 -# [types] # 可选,必须写在本框最后(其后不能再写 api_key 等键):手动声明模型类型,优先级最高 -# "my-embed-1" = "embed" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号;无效值只忽略该键并记日志 -# "inhouse-vl" = "vision" +models = ["deepseek-flash"] # 可选,模型清单:填写=跳过自动拉取直接用该清单 + +[types] # 可选,必须写在本框最后(其后不能再写 api_key 等键):手动声明模型类型,优先级最高 +deepseek-flash = "vision" # 取值只能填 text/vision/embed;模型名含 . : / 等字符必须加引号;无效值只忽略该键并记日志 # [request] # 可选,列表/余额查询覆盖(默认不写,由插件按 provider 解析) # list_url = "https://your-gateway/v1/models" # 自定义列表端点(服务商无 /models 时用) @@ -205,9 +205,9 @@ function parseModelTypes(raw: any, rowIndex: number): Record Date: Mon, 14 Sep 2026 00:16:08 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(tool):=20.ai=20tool=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=BB=84=E5=B1=95=E7=A4=BA=E4=B8=8E=E6=95=B4=E7=BB=84?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内置工具按能力细分为 14 个分类(category),MCP 工具按服务器成组,`.ai tool` 从"全量列表"改为组概览 + 组明细 + 整组开关;技能/知识库不参与组维度。 - Tool 新增 category 字段与 Tool.withCategory(分类, 注册批次) 包装,62 个 new Tool 调用点零改动;未打分类的内置工具兜底归「其他」,外部插件注册的 工具归「外部插件」(agent/api.ts) - .ai tool:无参=组概览(组名+数量+开/关统计)、<组名>=组明细、<函数名>=详情、 all=原扁平全量视图;list 为概览别名 - .ai tool on/off:支持组名(--group= 显式指定,绕开 on/off/help/call/list/all 保留字与撞名);关闭时默认跳过核心常驻工具(CORE_TOOL_NAMES),无参 off 同样 适用,--force 才一并关闭;开启时跳过「禁止调用的函数」并如实回报变更/跳过个数; 工具名与无参用法完全保持原行为 - 新增 src/tool/tool_group.ts:setToolGroupState / formatGroupToggleReply, .ai tool on/off <组名> 与 .ai mcp on/off <服务器> 共用同一批量开关实现 - AI 侧同步口径:list_tools 组头与 search_tools 详情来源改用 groupDisplay (内置·记忆 / MCP 服务器名 / 技能 / 知识库);mcp= 过滤经 matchesGroupFilter 支持分类名、来源分组、组显示名(mcp=内置 仍匹配全部内置工具) - 文档:changelog / README(工具管理命令、可用工具函数表按工具组重排、禁止调用 的函数)/ docs/04(category 属性、工具权限与分组实现)/ docs/05(tool 子命令) - 单测:新增 testToolCategoryCoverage / testToolGroupsView / testToolGroupToggleRules / testAiToolCommandGroups,并同步既有分组断言(内置·其他) --- README.md | 43 +-- ...45\345\205\267\347\263\273\347\273\237.md" | 45 +-- ...44\344\270\216\351\205\215\347\275\256.md" | 13 +- scripts/unit-test-entry.ts | 259 ++++++++++++++- src/agent/api.ts | 5 +- src/changelog.ts | 6 + src/cmd/sub_cmd/mcp.ts | 14 +- src/cmd/sub_cmd/tool.ts | 296 ++++++++++++------ src/tool/skills.ts | 6 +- src/tool/tool.ts | 174 ++++++++-- src/tool/tool_group.ts | 67 ++++ src/tool/tools/core/init.ts | 14 +- src/tool/tools/core/tool_dispatch.ts | 38 +-- src/tool/tools/image/init.ts | 10 +- src/tool/tools/init.ts | 4 +- src/tool/tools/manage/init.ts | 6 +- src/tool/tools/memory/init.ts | 14 +- src/tool/tools/memory/tool_knowledge.ts | 10 +- src/tool/tools/ob11/init.ts | 10 +- src/tool/tools/pub/init.ts | 10 +- src/tool/tools/raw/init.ts | 6 +- src/tool/tools/resource/init.ts | 13 +- src/tool/tools/seal/init.ts | 6 +- src/tool/tools/subagent/init.ts | 10 +- src/tool/tools/web/init.ts | 10 +- 25 files changed, 859 insertions(+), 230 deletions(-) create mode 100644 src/tool/tool_group.ts diff --git a/README.md b/README.md index f9dba3c..607d784 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ temperature = 1 | 工具方向提示 | 开启后要求模型调用工具前先向用户说一句方向说明,再在同一回复中给出工具调用块(默认开启) | | 允许连续调用函数次数 | 单次触发内允许连续调用函数的次数,防止 AI 陷入调用函数死循环(默认 0=不限制) | | 工具响应截断字数 | 工具返回结果超过该字数时改为只展示开头、截断前完整原文保留(0 关闭,默认 10000);原文可由 `grep_raw` / `read_raw`(kind=tool)只读检索 | -| 禁止调用的函数 | 每框一个,设置后将不被允许开启 | +| 禁止调用的函数 | 每框一个,设置后将不被允许开启(`.ai tool on <组名>` 会跳过名单内的工具并回报跳过个数);该工具也不参与 `.ai tool` 的组统计 | | 默认关闭的函数 | 每框一个,AI 在新会话中默认无法调用,需 `.ai tool on <函数名>` 开启 | | 禁止调用的 OB11 action | 每框一个禁止 `call_ob11_api` 调用的原始 OB11 action,例如 `set_group_ban` | | 默认关闭的 OB11 action | 每框一个默认关闭的原始 OB11 action,例如 `get_group_member_list`;关闭后 AI 不会调用 | @@ -492,12 +492,16 @@ platform: [] # 可选:[] / 省略 = 所有平台;例如 [QQ, DISC | 命令 | 使用示例 | 说明 | |:---:|:---:|:---| -| `.ai tool` | - | 列出所有工具及开关状态 | +| `.ai tool` | - | 工具组概览:按「内置分类 + MCP 服务器」列出每组工具数与开/关统计(不再刷屏列出全部工具) | +| `.ai tool <组名>` | `.ai tool 记忆` | 查看该组内工具与开关状态(显式写法 `--group=<组名>`,可绕开 on/off/help/call/list/all 等保留字与撞名) | +| `.ai tool <函数名>` | `.ai tool memory_add` | 查看指定工具的详细说明和参数需求(等价 `.ai tool help <函数名>`) | +| `.ai tool all` | - | 扁平列出全部工具及开关状态(含技能/知识库工具) | +| `.ai tool [on/off] [<组名\|函数名>]` | `.ai tool off 记忆` | 开启/关闭整组或单个工具函数;不带参数=全部工具。关闭时默认**跳过核心常驻工具**(`list_tools`/`search_tools`/`list_mcps`/`call_tool`/`use_skill`/`call_ob11_api`/`run_ext_command`/`run_core_command`),确需一并关闭加 `--force`;开启时跳过「禁止调用的函数」并回报变更/跳过个数(on/off 需邀请者以上) | | `.ai tool help <函数名>` | `.ai tool help set_timer` | 查看指定工具的详细说明和参数需求 | -| `.ai tool [on/off]` | - | 开启/关闭全部工具函数 | -| `.ai tool [on/off] <函数名>` | `.ai tool on run_ext_command` | 开启/关闭指定工具函数 | | `.ai tool call <函数名> --参数=值` | `.ai tool call run_ext_command --action=call --extension=fun --command=jrrp` | 试用指定工具函数,输出调用返回信息;参数可尝试 JSON 解析,数字需要引号包裹 | +> 工具组 = 内置工具的 14 个能力分类(基础调度 / 指令 / 定时 / 触发 / 记忆 / 图片 / OB11 / 资源 / 属性 / 原文检索 / 黑名单 / 网页 / 公开会话 / 子代理;外部插件注册的工具归「外部插件」)+ 每台 MCP 服务器各一组,`.ai tool on/off <服务器名>` 与 `.ai mcp on/off <服务器名>` 等价。技能与知识库不参与工具组维度(其会话开关仍用 `.ai skill on/off`、`.ai kb on/off`),但单工具开关照旧可用(如 `.ai tool off use_skill`)。 + ### MCP 技能 知识库管理命令 | 命令 | 使用示例 | 说明 | @@ -567,31 +571,28 @@ platform: [] # 可选:[] / 省略 = 所有平台;例如 [QQ, DISC ## 🧰 可用工具函数 -以下为内置工具函数(基于当前源码),可通过 `.ai tool help ` 查看详细用法,也可按分类在群里使用: +以下为内置工具函数(基于当前源码),下表「工具组」列即 `.ai tool` 的工具组名(可直接 `.ai tool <工具组>` 查明细、`.ai tool on/off <工具组>` 整组开关),可通过 `.ai tool help ` 查看详细用法: -| 分类 | 工具函数 | +| 工具组 | 工具函数 | |:---:|:---| -| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_list`、`memory_mm_view`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete` | -| 知识库 | `knowledge_search`、`knowledge_read`、`knowledge_list`、`knowledge_docs`(只读检索,范围 = 当前平台可用 + 本会话开启的库;内容由配置维护) | -| OB11 API | `call_ob11_api`(通过 action 调用消息、查询、管理、文件和合并转发 API) | -| 特殊ID | `resolve_special_id`(还原上下文短 ID/句柄为原始字段,用于对接协议 API) | +| 基础调度 | `list_tools`(按工具组列出当前平台工具)、`search_tools`(按名称/关键词/工具组发现工具并取参数)、`list_mcps`(列出当前平台可用 MCP 服务器及工具)、`call_tool`(统一执行任意工具) | +| 指令 | `run_ext_command`(本地执行扩展指令)、`run_core_command`(经核心桥 WebSocket 调用核心指令) | | 定时 | `set_timer`、`show_timer_list`、`cancel_timer` | | 触发 | `set_trigger_condition` | -| 指令 | `run_ext_command`(本地执行扩展指令)、`run_core_command`(经核心桥 WebSocket 调用核心指令) | -| 工具调度 | `list_tools`(按来源分组列出当前平台工具)、`search_tools`(按名称/关键词/来源分组发现工具并取参数)、`list_mcps`(列出当前平台可用 MCP 服务器及工具)、`call_tool`(统一执行任意工具) | -| 音频资源 | `generate_audio`(生成 record 消息段,不直接发送) | -| 网页 | `web_search` | +| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_list`、`memory_mm_view`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete` | +| 图片 | `image_to_text`、`text_to_image`、`meme_list`、`get_meme_info`、`meme_generator` | +| OB11 | `call_ob11_api`(通过 action 调用消息、查询、管理、文件和合并转发 API;群资料、精华消息等均传入对应 action)、`resolve_special_id`(还原上下文短 ID/句柄为原始字段,用于对接协议 API) | +| 资源 | `list_resources`、`get_resource_path`(本地资源查询);`generate_audio`(生成 record 消息段,不直接发送)、`search_music`(返回 music 消息段,不直接发送) | | 属性 | `attr_get`、`attr_set` | -| 图片 | `image_to_text`、`text_to_image`、`meme_list`、`get_meme_info`、`meme_generator`、`render_markdown`、`render_html` | -| OB11 管理/查询 | 统一通过 `call_ob11_api` 传入 `set_group_ban`、`set_group_name`、`get_group_list` 等 action | -| 资源/群资料 | `list_resources`、`get_resource_path`;群资料统一通过 `call_ob11_api` 传入对应 action | -| 精华消息 | 统一通过 `call_ob11_api` 传入 `set_essence_msg`、`get_essence_msg_list`、`delete_essence_msg` | -| 音乐资源 | `search_music`(返回 music 消息段,不直接发送) | +| 原文检索 | `grep_raw`、`read_raw`(按 kind 检索/读取工具、用户消息、图片、事件被截断或压缩前的完整原文) | | 黑名单 | `suggest_block`(AI 建议拉黑,带冷却;默认需骰主确认)、`unblock_user`、`get_block_list` | -| 论坛 | `forum_get_posts`、`forum_get_post_detail`、`forum_search`、`forum_create_post`、`forum_manage_comment`、`forum_get_activity`、`forum_manage_post` | +| 网页 | `web_search`;论坛:`forum_get_posts`、`forum_get_post_detail`、`forum_search`、`forum_create_post`、`forum_manage_comment`、`forum_get_activity`、`forum_manage_post` | | 公开会话 | `pub_read`(分级浏览公开会话目录 / 读取其他会话上下文只读快照)、`pub_send`(以目标会话自己的机器人账号身份外发:QQ 系富媒体 / 其他平台纯文本) | | 子代理 | `subagent`、`subagent_fork`(委派)、`send_message`、`interrupt_agent`、`list_agents`(可续跑子代理控制)、`job_list`、`job_output`、`job_kill`(后台 job 管理);默认开启,详见上方「子代理」配置小节 | -| MCP / 技能 | 远端工具名(MCP 工具,来源分组 = 服务器名,同名冲突时跳过,仅当前平台可见)、`use_skill`、`skill_list`(技能;平台受限或本会话已关闭时明确返回不可用提示) | +| 外部插件 | 其他海豹插件经 `globalThis.aiplugin4.registerTool` 注册的工具 | +| 技能(来源分组,不参与工具组开关) | `use_skill`、`skill_list`(平台受限或本会话已关闭时明确返回不可用提示) | +| 知识库(来源分组,不参与工具组开关) | `knowledge_search`、`knowledge_read`、`knowledge_list`、`knowledge_docs`(只读检索,范围 = 当前平台可用 + 本会话开启的库;内容由配置维护) | +| MCP(来源分组 = 服务器名,可整组开关) | 远端工具名(同名冲突时跳过,仅当前平台可见);`render_markdown` / `render_html` 由默认服务器 `md-html-render` 提供 | > 指令类技能(今日人品、COC 模组抽取/搜索、属性展示、属性检定、san 检定等)通过 `use_skill` 按需获取内容,内部统一使用 `run_ext_command` / `run_core_command` 调用海豹指令,对应指令需加入「可调用指令白名单」。 diff --git "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" index d418c43..aaa0d03 100644 --- "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" +++ "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" @@ -13,7 +13,8 @@ | `sessionType` | 适用会话类型:`'any'`(默认)、`'group'`、`'user'` | | `callBack` | 是否回调智能体(当前统一为 true) | | `sensitive` | 敏感工具标记(发消息/禁言/改名等),执行日志显著记录 | -| `group` | 来源分组:`new Tool(info, sensitive?, group?, platforms?)`。内置工具不传(展示为「内置」);技能工具='技能';知识库工具='知识库';MCP 工具=所属服务器名 | +| `group` | 来源分组:`new Tool(info, sensitive?, group?, platforms?)`。内置工具不传(来源显示为「内置」);技能工具='技能';知识库工具='知识库';MCP 工具=所属服务器名 | +| `category` | **内置工具细分分类**(仅 `group` 为空时赋值,由 `Tool.withCategory(分类, 注册批次)` 在注册时注入;缺省兜底 '其他'):基础调度/指令/定时/触发/记忆/图片/OB11/资源/属性/原文检索/黑名单/网页/公开会话/子代理,外部插件注册的工具='外部插件'。仅用于 `.ai tool` 工具组概览与组开关、AI 侧组头显示,不改变 `group` 的来源语义 | | `platforms` | 平台白名单(继承自源单元 frontmatter 的 platform:MCP 服务器/技能/知识库条目),缺省/空 = 所有平台;工具发现、列表与调用均按当前平台过滤 | | `solve(ctx, msg, session, args)` | 工具实现,返回字符串作为给 AI 的结果 | @@ -71,32 +72,28 @@ ## 内置工具清单 -以下工具名来自 `src/tool/tools/` 各注册函数(基于当前代码): +以下工具名来自 `src/tool/tools/` 各注册函数(基于当前代码)。「工具组」列即 `category`(内置分类),可直接 `.ai tool <工具组>` 查明细、`.ai tool on/off <工具组>` 整组开关;技能/知识库/MCP 只有来源分组(`group`),技能与知识库不参与工具组维度: -| 分类 | 工具 | +| 工具组(category) | 工具 | | --- | --- | -| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_list`、`memory_mm_view`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete`(可按 model_id 或 question 删除) | -| 知识库 | `knowledge_search`、`knowledge_read`、`knowledge_list`、`knowledge_docs`(只读检索,范围 = 当前平台可用 + 本会话开启的库;内容由「知识库」配置维护) | -| OB11 API | `call_ob11_api`(通过 action 调用消息、查询、管理、文件和合并转发 API) | -| 特殊ID | `resolve_special_id`(还原上下文短 ID/句柄为原始字段,用于对接协议 API) | -| 事件 | `read_raw kind=event`(读取事件原始数据 JSON;原 `get_event_detail` 已删除) | -| 原文检索(工具/用户消息/图片/事件) | `grep_raw`、`read_raw`(按 kind 检索/读取被截断或压缩前保留的完整原文;旧 `grep_tool_raw`/`read_tool_raw` 已删除) | +| 基础调度 | `list_tools`(按工具组列出当前平台工具)、`search_tools`(按名称/关键词/工具组发现并取参数,详情带来源)、`list_mcps`(列出当前平台可用 MCP 服务器及工具开关)、`call_tool`(统一执行,校验来源与平台) | +| 指令 | `run_ext_command`、`run_core_command`(调用 SealDice 扩展与核心指令) | | 定时 | `set_timer`、`show_timer_list`、`cancel_timer` | | 触发 | `set_trigger_condition` | -| 音频资源 | `generate_audio`(生成 record 消息段,不直接发送) | -| 网页 | `web_search` | -| 指令 | `run_ext_command`、`run_core_command`(调用 SealDice 扩展与核心指令) | -| 属性 | `attr_show`、`attr_get`、`attr_set` | -| 图片 | `image_to_text`、`text_to_image`、`meme_list`、`get_meme_info`、`meme_generator`、`render_markdown`、`render_html` | -| OB11 管理/查询 | 统一通过 `call_ob11_api` 传入对应 action | -| 资源/群资料 | `list_resources`、`get_resource_path`;群资料统一通过 `call_ob11_api` 传入对应 action | -| 精华消息 | 统一通过 `call_ob11_api` 传入对应 action | -| 音乐资源 | `search_music`(返回 music 消息段,不直接发送) | +| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_list`、`memory_mm_view`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete`(可按 model_id 或 question 删除) | +| 图片 | `image_to_text`、`text_to_image`、`meme_list`、`get_meme_info`、`meme_generator` | +| OB11 | `call_ob11_api`(通过 action 调用消息、查询、管理、文件和合并转发 API;群资料/精华消息等统一传入对应 action)、`resolve_special_id`(还原上下文短 ID/句柄为原始字段,用于对接协议 API) | +| 资源 | `list_resources`、`get_resource_path`(本地资源查询);`generate_audio`(生成 record 消息段,不直接发送)、`search_music`(返回 music 消息段,不直接发送) | +| 属性 | `attr_get`、`attr_set` | +| 原文检索 | `grep_raw`、`read_raw`(按 kind 检索/读取被截断或压缩前保留的完整原文,`kind=event` 读事件原始 JSON;旧 `grep_tool_raw`/`read_tool_raw` 已删除) | | 黑名单 | `suggest_block`(AI 建议拉黑,带冷却;默认需骰主用 `.ai block` 确认,可在工具配置关闭)、`unblock_user`、`get_block_list` | -| 论坛 | `forum_get_posts`、`forum_get_post_detail`、`forum_search`、`forum_create_post`、`forum_manage_comment`、`forum_get_activity`、`forum_manage_post` | +| 网页 | `web_search`;论坛:`forum_get_posts`、`forum_get_post_detail`、`forum_search`、`forum_create_post`、`forum_manage_comment`、`forum_get_activity`、`forum_manage_post` | | 公开会话 | `pub_read`(分级浏览公开会话目录 / 读取其他会话上下文只读快照)、`pub_send`(以目标会话自己的机器人账号身份外发:QQ 系富媒体 / 其他平台纯文本) | -| 工具调度 | `list_tools`(按来源分组列出当前平台工具)、`search_tools`(按名称/关键词/来源分组发现并取参数,详情带来源)、`list_mcps`(列出当前平台可用 MCP 服务器及工具开关)、`call_tool`(统一执行,校验来源与平台) | | 子代理 | `subagent`(spawn 委派)、`subagent_fork`(fork 委派)、`send_message`、`interrupt_agent`、`list_agents`(可续跑控制)、`job_list`、`job_output`、`job_kill`(后台 job) | +| 外部插件 | 其他海豹插件经 `globalThis.aiplugin4.registerTool` 注册的工具 | +| — (来源分组=技能) | `use_skill`、`skill_list` | +| — (来源分组=知识库) | `knowledge_search`、`knowledge_read`、`knowledge_list`、`knowledge_docs`(只读检索,范围 = 当前平台可用 + 本会话开启的库;内容由「知识库」配置维护) | +| — (来源分组=MCP 服务器名) | 远端工具名(`render_markdown`/`render_html` 来自默认服务器 `md-html-render`) | > 注:`text_to_image` 支持可选 `image` 参数进行以图生图。参数可传图片 ID、`user_avatar:...`、`group_avatar:...`、图片 URL、data URL 或 base64;tti 模型配置需要在请求体中使用 `{image}`、`{image_url}` 或 `{image_base64}` 占位符。 @@ -114,10 +111,14 @@ ## 工具权限(按会话开关) - 每个会话有独立的 `session.tool.state`(`ToolState`),`session.toolState` getter 负责同步: - - `禁止调用的函数`(`BLOCKED`):强制关闭且无法开启。 + - `禁止调用的函数`(`BLOCKED`):强制关闭且无法开启,且不出现在工具组视图与统计里。 - `默认关闭的函数`(`DEFAULT_CLOSED`):新会话默认关闭,需通过 `.ai tool on ` 开启。 - 已删除工具(如旧版 `call_subagent`)的残留状态会被自动清理。 -- 工具列表/开关按**来源分组与当前平台**呈现:`.ai tool`(无参)与 `list_tools` 按「内置/技能/知识库/MCP 服务器名」分组、只列当前平台可用工具;技能/知识库还有独立的会话级开关(`session.skillState` / `session.kbState`,只记录被 off 的项,缺省=开启),对应管理命令 `.ai skill on/off`、`.ai kb on/off`,以及按服务器的批量开关 `.ai mcp on/off`。 +- **工具组**:内置工具按 `category` 分为 14 个能力分类,加上每台 MCP 服务器各一组(`Skill`/`知识库` 不参与组维度)。分组与批量开关实现集中在 `src/tool/tool.ts`(`getToolGroups` / `resolveToolGroup` / `groupDisplay` / `matchesGroupFilter` / `groupEntries`)与 `src/tool/tool_group.ts`(`setToolGroupState` / `formatGroupToggleReply`)。 + - `Tool.getToolGroups(session, platform)` 基于 `session.toolState` 聚合:只统计当前平台可用工具,自动继承 `BLOCKED` 剔除、`DEFAULT_CLOSED` 默认与子代理 `toolRestriction` 收窄;组顺序由 `BUILTIN_CATEGORY_ORDER` 固定(内置分类在前、MCP 服务器按名在后)。 + - `setToolGroupState(session, names, enable, { skipBlocked, skipCore })` 供 `.ai tool on/off <组名>` 与 `.ai mcp on/off <服务器>` 共用:`on` 跳过 `BLOCKED` 并计数,`off` 默认跳过 `CORE_TOOL_NAMES`(核心常驻工具,`--force` 才一并关闭),返回 `{ changed, unchanged, skippedBlocked, skippedCore }` 供命令侧如实回报。 +- 工具列表/开关按**工具组与当前平台**呈现:`.ai tool`(无参)输出组概览(组名+工具数+开/关统计),`.ai tool <组名>` 展开组内明细,`.ai tool <函数名>` 看详情,`.ai tool all` 保留扁平全量视图;解析优先级为 `--group=<组名>` > 函数名 > 组名 > 唯一包含匹配,`on/off/help/call/list/all` 为保留字(撞名时用 `--group=`)。技能/知识库还有独立的会话级开关(`session.skillState` / `session.kbState`,只记录被 off 的项,缺省=开启),对应管理命令 `.ai skill on/off`、`.ai kb on/off`,以及按服务器的批量开关 `.ai mcp on/off`。 +- AI 侧同口径:`list_tools` 组头与 `search_tools` 详情的来源用 `Tool.groupDisplay`(内置工具显示「内置·记忆」,其余为技能/知识库/MCP 服务器名);`list_tools(mcp=)` / `search_tools(mcp=)` 经 `Tool.matchesGroupFilter` 支持按分类名、来源分组或组显示名过滤(`mcp=内置` 匹配全部内置工具)。 - 子代理会话经 `session.toolRestriction`(allow/deny 求交)收窄继承自父会话的工具面;委派工具默认开启,受全局 BLOCKED/默认关闭与「子代理禁止调用工具」约束。 - 管理命令 `.ai tool`:列出/查看详情/开关/试用工具,详见 [05-命令与配置](05-命令与配置.md)。 diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index fe7cedc..99c2422 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -65,12 +65,21 @@ ### tool 子命令 ```text -.ai tool # 列出所有工具及开关状态 +.ai tool # 工具组概览(组名 + 工具数 + 开/关统计) +.ai tool <组名> # 查看组内工具与开关状态(等价 --group=<组名>) +.ai tool <函数名> # 查看工具详情与参数(等价 help <函数名>) +.ai tool all # 扁平列出全部工具(含技能/知识库) +.ai tool on|off [<组名|函数名>] # 开关整组或单个工具;无参=全部工具(on/off 需邀请者以上) .ai tool help <函数名> # 查看工具详情与参数 -.ai tool on|off [<函数名>] # 开关全部或指定工具(on/off 需邀请者以上) .ai tool call <函数名> --参数=值 # 试用工具(call 需骰主),参数可尝试 JSON 解析 ``` +工具组 = 内置工具的 14 个能力分类(基础调度/指令/定时/触发/记忆/图片/OB11/资源/属性/原文检索/黑名单/网页/公开会话/子代理,外部插件注册的工具归「外部插件」)+ 每台 MCP 服务器各一组;组内计数只统计当前平台可用工具,`禁止调用的函数` 中的工具不参与统计与开关。 + +解析优先级:`--group=<组名>` 显式指定 > 函数名(精确命中即单工具,行为不变)> 组名精确命中(分类名或 MCP 服务器名)> 唯一包含匹配;`on/off/help/call/list/all` 为保留字,组名与它们撞名时用 `--group=`。 + +`on/off` 不带参数=全部工具;**关闭时默认跳过核心常驻工具**(`list_tools`/`search_tools`/`list_mcps`/`call_tool`/`use_skill`/`call_ob11_api`/`run_ext_command`/`run_core_command`),避免一键关掉 AI 的工具发现与执行入口,确需一并关闭加 `--force`;开启时跳过 `禁止调用的函数`。`.ai mcp on/off <服务器>` 与 `.ai tool on/off <服务器>` 等价(同一批量开关实现)。技能与知识库不参与工具组维度,其会话开关用 `.ai skill on/off`、`.ai kb on/off`,但单工具开关照旧可用(如 `.ai tool off use_skill`)。 + ### token 子命令 ```text diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index 4db773d..837c697 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -58,7 +58,10 @@ import { Session } from "../src/session/session"; import { JudgeManager } from "../src/judge/judge_manager"; import { TimerInfo, TimerManager } from "../src/timer"; import Image from "../src/resource/image"; -import Tool, { toolMap } from "../src/tool/tool"; +import Tool, { BUILTIN_CATEGORY_ORDER, CORE_TOOL_NAMES, DEFAULT_CATEGORY, EXTERNAL_CATEGORY, toolMap } from "../src/tool/tool"; +import { setToolGroupState } from "../src/tool/tool_group"; +import { registerTools } from "../src/tool/tools/init"; +import { registerCmdTool } from "../src/cmd/sub_cmd/tool"; import { getSkillSummaries, getSkillSummariesBudgeted, registerSkills, SKILL_INJECT_MAX_CHARS, SKILL_INJECT_MAX_ITEMS } from "../src/tool/skills"; import { registerDispatchTools } from "../src/tool/tools/core/tool_dispatch"; import { createStopEvent, fireStopEvent, resetStopEvent, revive, StopError, normalizeMsgId, transformMsgId, transformMsgIdBack, withTimeout } from "../src/utils/utils"; @@ -6859,7 +6862,8 @@ platform: [QQ, DISCORD] assert.ok(String(callOut).includes('不可用'), `call_tool 应拦截平台外工具: ${callOut}`); const grouped = Tool.getGroupedAvailableTools(session, 'QQ'); const labels = grouped.map(g => g.group); - assert.ok(labels.includes('内置') && labels.includes('mcp-files-exec'), `分组应含内置与 mcp-files-exec: ${labels.join(',')}`); + // 未打分类的工具(本用例直接 new Tool 注册)落兜底分类:显示为「内置·其他」 + assert.ok(labels.includes(`内置·${DEFAULT_CATEGORY}`) && labels.includes('mcp-files-exec'), `分组应含内置·其他与 mcp-files-exec: ${labels.join(',')}`); assert.ok(!labels.includes('mcp-browser'), '平台过滤后的分组不应含 mcp-browser'); } finally { for (const k of ['zz_mcp_file', 'zz_browser', 'zz_plain_calc']) delete toolMap[k]; @@ -6894,12 +6898,257 @@ platform: [QQ, DISCORD] resetMCPCacheForTest(); } }, + + /** 工具组:内置工具必须打上分类;技能/知识库不参与组维度(无 category) */ + testToolCategoryCoverage(): void { + // 复位注册表后只跑内置注册链,断言不受其它用例临时注册的工具干扰 + Tool.reset(); + registerTools(); + registerSkills(); + + const builtin = Object.keys(toolMap).filter(n => !toolMap[n].group); + const missing = builtin.filter(n => !toolMap[n].category); + assert.deepEqual(missing, [], `内置工具必须打上分类(缺: ${missing.join('、')})`); + assert.equal(builtin.length, 58, `内置工具数应为 58,实际 ${builtin.length}`); + + const byCategory: { [k: string]: string[] } = {}; + for (const n of builtin) (byCategory[Tool.categoryOf(toolMap[n])] = byCategory[Tool.categoryOf(toolMap[n])] || []).push(n); + assert.equal(byCategory['基础调度'].length, 4, `基础调度应有 4 个: ${byCategory['基础调度']}`); + assert.ok(byCategory['基础调度'].includes('list_tools') && byCategory['基础调度'].includes('call_tool'), '调度组应含 list_tools/call_tool'); + assert.equal(byCategory['记忆'].length, 12, `记忆应有 12 个: ${byCategory['记忆']}`); + assert.equal(byCategory['子代理'].length, 8, `子代理应有 8 个: ${byCategory['子代理']}`); + assert.ok(byCategory['子代理'].includes('subagent') && byCategory['子代理'].includes('job_kill'), '子代理组应含 subagent/job_kill'); + assert.ok(byCategory['图片'].includes('meme_generator') && byCategory['资源'].includes('search_music'), '图片/资源分类应命中各自的工具'); + // 未列入固定顺序表的分类只允许兜底「其他」(防新增工具漏加分类) + const unknown = Object.keys(byCategory).filter(k => !BUILTIN_CATEGORY_ORDER.includes(k)); + assert.deepEqual(unknown, [], `分类必须在 BUILTIN_CATEGORY_ORDER 内: ${unknown.join('、')}`); + assert.equal(byCategory[DEFAULT_CATEGORY], undefined, '分类完整时不应出现兜底分类'); + + // 技能/知识库:来源分组明确、且不参与组维度 + assert.equal(toolMap['use_skill'].group, '技能'); + assert.equal(toolMap['skill_list'].group, '技能'); + assert.equal(toolMap['knowledge_search'].group, '知识库'); + for (const n of ['use_skill', 'skill_list', 'knowledge_search', 'knowledge_read', 'knowledge_list', 'knowledge_docs']) { + assert.equal(Tool.categoryOf(toolMap[n]), '', `${n} 不应参与工具组维度(无 category)`); + assert.equal(Tool.isGroupable(toolMap[n]), false, `${n} 不应是可切换组工具`); + } + // 外部插件注册的工具归入固定分类 + const external = Tool.withCategory(EXTERNAL_CATEGORY, () => new Tool({ + type: 'function', + function: { name: 'zz_external_probe', description: '外部插件工具', parameters: { type: 'object', properties: {} } } + })); + assert.equal(Tool.categoryOf(external), EXTERNAL_CATEGORY); + assert.equal(Tool.groupDisplay(external), `内置·${EXTERNAL_CATEGORY}`); + delete toolMap['zz_external_probe']; + }, + + /** 工具组:分组视图(内置分类 + MCP 服务器;排序、平台过滤、技能/知识库排除) */ + testToolGroupsView(): void { + registerTools(); + registerSkills(); + const fakeQQ = new Tool({ + type: 'function', + function: { name: 'zz_group_mcp_qq', description: 'MCP 工具', parameters: { type: 'object', properties: {} } } + }, false, 'zz-mcp-server', ['QQ']); + const fakeDC = new Tool({ + type: 'function', + function: { name: 'zz_group_mcp_dc', description: '仅 Discord 的 MCP 工具', parameters: { type: 'object', properties: {} } } + }, false, 'zz-mcp-server', ['DISCORD']); + try { + const session = new Session(); + session.sessionId = 'QQ:1'; + const groups = Tool.getToolGroups(session, 'QQ'); + const keys = groups.map(g => g.key); + + // 内置分类组按固定顺序排列,且都在顺序表内 + const builtinKeys = groups.filter(g => g.kind === 'builtin').map(g => g.key); + assert.deepEqual(builtinKeys, BUILTIN_CATEGORY_ORDER.filter(k => builtinKeys.includes(k)), `内置分类顺序错误: ${builtinKeys.join('、')}`); + // MCP 服务器组排在内置之后,键=服务器名 + const mcpKeys = groups.filter(g => g.kind === 'mcp').map(g => g.key); + assert.deepEqual(mcpKeys, ['zz-mcp-server'], `MCP 组应为服务器名: ${mcpKeys.join('、')}`); + assert.equal(groups[groups.length - 1].kind, 'mcp', 'MCP 组应排在内置分类之后'); + + // 平台过滤:DISCORD 限定工具不进 QQ 视图;组内计数与实际开关一致 + const memory = groups.find(g => g.key === '记忆')!; + assert.equal(memory.names.length, 12); + assert.equal(memory.on, 12); + assert.equal(memory.off, 0); + assert.equal(memory.label, '内置·记忆'); + const mcp = groups.find(g => g.key === 'zz-mcp-server')!; + assert.deepEqual(mcp.names, ['zz_group_mcp_qq'], '平台不符的 MCP 工具不应进组'); + assert.equal(Tool.isAllowedPlatform(toolMap['zz_group_mcp_dc'], 'QQ'), false); + + // 技能/知识库不作为组出现 + assert.ok(!keys.includes('技能') && !keys.includes('知识库'), `技能/知识库不应作为工具组: ${keys.join('、')}`); + assert.ok(!mcp.names.concat(memory.names).some(n => n.startsWith('knowledge_')), '知识库工具不应进任何组'); + + // 开关状态参与计数 + session.tool.state['memory_add'] = false; + session.tool.state['memory_delete'] = false; + const memory2 = Tool.getToolGroups(session, 'QQ').find(g => g.key === '记忆')!; + assert.equal(memory2.on, 10); + assert.equal(memory2.off, 2); + + // 显示名/分类名/服务器名都能解析;未命中与多命中给可读错误 + assert.equal((Tool.resolveToolGroup('记忆', groups) as any).key, '记忆'); + assert.equal((Tool.resolveToolGroup('内置·记忆', groups) as any).key, '记忆'); + assert.equal((Tool.resolveToolGroup('zz-mcp-server', groups) as any).key, 'zz-mcp-server'); + assert.equal((Tool.resolveToolGroup('mcp-server', groups) as any).key, 'zz-mcp-server', '唯一包含匹配应命中该组'); + assert.ok(typeof Tool.resolveToolGroup('不存在的组', groups) === 'string', '未命中应返回错误说明'); + // 多命中:再加一台同前缀服务器 → 报歧义而不是随便挑一个 + const fakeQQ2 = new Tool({ + type: 'function', + function: { name: 'zz_group_mcp_qq2', description: 'MCP 工具 2', parameters: { type: 'object', properties: {} } } + }, false, 'zz-mcp-server-2', ['QQ']); + const groups2 = Tool.getToolGroups(session, 'QQ'); + assert.ok(String(Tool.resolveToolGroup('zz-mcp', groups2)).includes('匹配多个'), '多命中应提示歧义'); + assert.deepEqual(groups2.filter(g => g.kind === 'mcp').map(g => g.key), ['zz-mcp-server', 'zz-mcp-server-2'], 'MCP 组应按服务器名排序'); + delete toolMap['zz_group_mcp_qq2']; + } finally { + delete toolMap['zz_group_mcp_qq']; + delete toolMap['zz_group_mcp_dc']; + delete toolMap['zz_group_mcp_qq2']; + } + }, + + /** 工具组:批量开关(核心常驻跳过 / --force 覆盖 / 禁用名单跳过) */ + testToolGroupToggleRules(): void { + registerTools(); + const session = new Session(); + session.sessionId = 'QQ:1'; + + // 普通分类:整组关闭 / 开启 + const memory = Tool.getToolGroups(session, 'QQ').find(g => g.key === '记忆')!; + const off = setToolGroupState(session, memory.names, false, { skipCore: true }); + assert.equal(off.changed, 12); + assert.equal(session.toolState['memory_add'], false); + const on = setToolGroupState(session, memory.names, true, { skipBlocked: true }); + assert.equal(on.changed, 12); + assert.equal(session.toolState['memory_add'], true); + const again = setToolGroupState(session, memory.names, true, { skipBlocked: true }); + assert.equal(again.changed, 0); + assert.equal(again.unchanged, 12, '已是目标状态应计入 unchanged'); + + // 核心常驻工具:默认跳过,--force(skipCore=false)才真正关闭 + const dispatch = Tool.getToolGroups(session, 'QQ').find(g => g.key === '基础调度')!; + assert.deepEqual(dispatch.names, ['call_tool', 'list_mcps', 'list_tools', 'search_tools']); + assert.ok(dispatch.names.every(n => CORE_TOOL_NAMES.includes(n)), '基础调度组应全部是核心常驻工具'); + const skipped = setToolGroupState(session, dispatch.names, false, { skipCore: true }); + assert.equal(skipped.changed, 0); + assert.equal(skipped.skippedCore, 4); + assert.equal(session.toolState['list_tools'], true, '核心常驻工具默认不被关闭'); + const forced = setToolGroupState(session, dispatch.names, false, { skipCore: false }); + assert.equal(forced.changed, 4); + assert.equal(session.toolState['list_tools'], false, '--force 应能关闭核心常驻工具'); + setToolGroupState(session, dispatch.names, true, { skipBlocked: true }); + + // 禁用名单:on 时跳过并计数;名单内工具也不进入组视图 + const prior = TC.templateConfigs['禁止调用的函数']; + TC.templateConfigs['禁止调用的函数'] = ['memory_add']; + resetConfigCache(); + try { + const s2 = new Session(); + s2.sessionId = 'QQ:2'; + const memory2 = Tool.getToolGroups(s2, 'QQ').find(g => g.key === '记忆')!; + assert.equal(memory2.names.includes('memory_add'), false, '禁止调用的函数不应出现在组内'); + assert.equal(memory2.names.length, 11); + s2.tool.state['memory_delete'] = false; + const r = setToolGroupState(s2, ['memory_add', 'memory_delete'], true, { skipBlocked: true }); + assert.equal(r.skippedBlocked, 1, '禁用名单内的工具应被跳过'); + assert.equal(r.changed, 1, '仅未被跳过的工具被开启'); + assert.equal(s2.toolState['memory_delete'], true); + } finally { + if (prior === undefined) delete TC.templateConfigs['禁止调用的函数']; else TC.templateConfigs['禁止调用的函数'] = prior; + resetConfigCache(); + } + }, + + /** .ai tool:组概览 / 组明细 / 组开关 / --group= / --force / 兼容旧用法 */ + async testAiToolCommandGroups(): Promise { + registerTools(); + registerSkills(); + if (!SubCmd.map['tool']) registerCmdTool(); + const session = new Session(); + session.sessionId = 'QQ:1'; + + // 组概览:只列组与统计,不展开工具名 + const overview = await runSubCmd('tool', [''], { session }); + assert.ok(overview.startsWith('工具组(当前平台 QQ'), `应输出组概览: ${overview.slice(0, 120)}`); + assert.ok(overview.includes('1. 基础调度(4,开 4 关 0)'), `概览应含基础调度统计: ${overview}`); + assert.ok(overview.includes('记忆(12,开 12 关 0)'), `概览应含记忆统计: ${overview}`); + assert.ok(!overview.includes('memory_add'), '概览不应展开组内工具'); + assert.ok(overview.includes('技能/知识库工具不在工具组范围'), '概览应提示技能/知识库管理入口'); + assert.ok(!overview.includes('技能('), '概览不应把技能列为工具组'); + + // 组明细 + const detail = await runSubCmd('tool', ['', '记忆'], { session }); + assert.ok(detail.includes('工具组「记忆」(内置):12 个,开 12 关 0'), `组明细头部错误: ${detail.slice(0, 120)}`); + assert.ok(detail.includes('memory_add[开]') && detail.includes('memory_recall[开]'), '组明细应列出组内工具与开关'); + assert.ok(detail.includes('.ai tool on/off 记忆'), '组明细应给出批量开关提示'); + + // 组开关:关 → 概览与状态同步,开 → 恢复 + const off = await runSubCmd('tool', ['', 'off', '记忆'], { session }); + assert.ok(off.includes('已关闭工具组「记忆」') && off.includes('实际变更 12 个'), `组关闭回复错误: ${off}`); + assert.equal(session.toolState['memory_add'], false); + const overviewOff = await runSubCmd('tool', [''], { session }); + assert.ok(overviewOff.includes('记忆(12,开 0 关 12)'), `关闭后概览统计应更新: ${overviewOff}`); + const on = await runSubCmd('tool', ['', 'on', '记忆'], { session }); + assert.ok(on.includes('已开启工具组「记忆」'), `组开启回复错误: ${on}`); + assert.equal(session.toolState['memory_add'], true); + + // 核心常驻:组 off 默认跳过并提示 --force;--force 才真正关闭 + const offCore = await runSubCmd('tool', ['', 'off', '基础调度'], { session }); + assert.ok(offCore.includes('跳过核心常驻工具 4 个'), `应报告跳过的核心工具: ${offCore}`); + assert.ok(offCore.includes('--force'), '应提示 --force 用法'); + assert.equal(session.toolState['list_tools'], true); + const forced = await runSubCmd('tool', ['', 'off', '基础调度'], { session }, [{ name: 'force', valueExists: false }]); + assert.ok(forced.includes('实际变更 4 个'), `--force 应真正关闭: ${forced}`); + assert.equal(session.toolState['list_tools'], false); + await runSubCmd('tool', ['', 'on', '基础调度'], { session }); + assert.equal(session.toolState['list_tools'], true); + + // 无参 off:跳过核心常驻工具(AI 的工具入口不会静默失效) + const offAll = await runSubCmd('tool', ['', 'off'], { session }); + assert.ok(offAll.includes('全部工具') && offAll.includes('跳过核心常驻工具'), `无参 off 应跳过核心工具: ${offAll}`); + assert.equal(session.toolState['list_tools'], true, '无参 off 默认保留核心常驻工具'); + assert.equal(session.toolState['memory_add'], false, '无参 off 应关闭普通工具'); + const offAllForced = await runSubCmd('tool', ['', 'off'], { session }, [{ name: 'force', valueExists: false }]); + assert.ok(offAllForced.includes('实际变更'), `无参 off --force 应执行: ${offAllForced}`); + assert.equal(session.toolState['list_tools'], false, '无参 off --force 应关闭核心常驻工具'); + await runSubCmd('tool', ['', 'on'], { session }); + + // 显式 --group= 写法(绕开保留字与撞名) + const byFlag = await runSubCmd('tool', [''], { session }, [{ name: 'group', value: '记忆', valueExists: true }]); + assert.ok(byFlag.includes('工具组「记忆」'), `--group= 应列出该组: ${byFlag.slice(0, 120)}`); + const offByFlag = await runSubCmd('tool', ['', 'off'], { session }, [{ name: 'group', value: '记忆', valueExists: true }]); + assert.ok(offByFlag.includes('已关闭工具组「记忆」'), `--group= off 应关闭该组: ${offByFlag}`); + await runSubCmd('tool', ['', 'on'], { session }); + + // 兼容:技能/知识库仍可按单工具开关;工具名照旧查看详情 + const offSkill = await runSubCmd('tool', ['', 'off', 'use_skill'], { session }); + assert.ok(offSkill.includes('已关闭工具函数 use_skill'), `单工具开关应保持原行为: ${offSkill}`); + assert.equal(session.toolState['use_skill'], false); + await runSubCmd('tool', ['', 'on', 'use_skill'], { session }); + const toolDetail = await runSubCmd('tool', ['', 'memory_add'], { session }); + assert.ok(toolDetail.includes('memory_add') && toolDetail.includes('来源:内置·记忆'), `工具名应展示详情: ${toolDetail.slice(0, 120)}`); + const helpDetail = await runSubCmd('tool', ['', 'help', 'memory_add'], { session }); + assert.ok(helpDetail.includes('来源:内置·记忆'), 'help 详情应标注分类来源'); + + // 未命中给可读提示;.ai tool all 保留扁平全量视图(含技能/知识库) + const miss = await runSubCmd('tool', ['', '不存在的组'], { session }); + assert.ok(miss.includes('未找到工具组') && miss.includes('可用组:'), `未命中应有候选提示: ${miss}`); + const all = await runSubCmd('tool', ['', 'all'], { session }); + assert.ok(all.includes('use_skill') && all.includes('knowledge_search'), '.ai tool all 应含技能/知识库工具'); + assert.ok(all.includes('内置·记忆:'), 'all 视图应沿用分类组头'); + const listAlias = await runSubCmd('tool', ['', 'list'], { session }); + assert.ok(listAlias.startsWith('工具组(当前平台 QQ'), 'list 别名应输出组概览'); + }, }; -/** 执行一个已注册的子命令(SubCmd.map),捕获最后一次回复文本 */ -async function runSubCmd(name: string, args: string[], sccOver: any = {}): Promise { +/** 执行一个已注册的子命令(SubCmd.map),捕获最后一次回复文本(kwargs 可选,如 --group=/--force) */ +async function runSubCmd(name: string, args: string[], sccOver: any = {}, kwargs: any[] = []): Promise { const scc = makeMemoScc(sccOver); - scc.cmdArgs = makeMemoArgs(args); + scc.cmdArgs = makeMemoArgs(args, kwargs); const origReply = (globalThis as any).seal.replyToSender; let replied = ''; (globalThis as any).seal.replyToSender = (_c: any, _m: any, text: string) => { replied = text; }; diff --git a/src/agent/api.ts b/src/agent/api.ts index 4f8b428..e3ced8c 100644 --- a/src/agent/api.ts +++ b/src/agent/api.ts @@ -5,7 +5,7 @@ import Model from "../model/model"; import Image from "../resource/image"; import { Session } from "../session/session"; import { SessionType } from "../session/types"; -import Tool, { toolMap } from "../tool/tool"; +import Tool, { EXTERNAL_CATEGORY, toolMap } from "../tool/tool"; import { ToolInfo } from "../tool/types"; import Agent from "./agent"; @@ -107,7 +107,8 @@ export function registerAgentApi(): void { return false; } try { - const tool = new Tool(info, options.sensitive || false); + // 外部插件注册的工具统一归入「外部插件」分类(.ai tool 组概览与组开关可见) + const tool = Tool.withCategory(EXTERNAL_CATEGORY, () => new Tool(info, options.sensitive || false)); (tool as any).apiRegistered = true; // 允许同名 API 工具在 JS 重载后重新注册 tool.sessionType = options.sessionType || 'any'; if (options.callBack !== undefined) tool.callBack = options.callBack; diff --git a/src/changelog.ts b/src/changelog.ts index 5ade8a2..894b5f7 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -8,6 +8,12 @@ export const changelog: { [version: string]: string } = { ## 配置变更 - 「模型」页 api连接:新增可选 [types] 表(模型名 → text/vision/embed,优先级最高) - 配置描述统一按「框」表述模板配置的每个文本框:模板配置的每个元素是一个文本框,不再用「行」描述元素(如「每行一个」→「每框一个」;模板解析失败的日志同步改为「第 N 框」);框内内容自身的「行」表述(如角色设定「第一行为名称」、TOML/JSON 语法行)保持原样 + +## 命令与工具变更 +- .ai tool 新增**工具组**维度:内置工具按能力细分为 14 个分类(基础调度 / 指令 / 定时 / 触发 / 记忆 / 图片 / OB11 / 资源 / 属性 / 原文检索 / 黑名单 / 网页 / 公开会话 / 子代理,外部插件注册的工具归「外部插件」),MCP 工具按服务器成组;.ai tool(无参)改为**组概览**(组名 + 工具数 + 开/关统计,不再刷屏列出 60+ 行),.ai tool <组名> 查看组内明细,.ai tool <函数名> 直接看工具详情,.ai tool all 保留原扁平全量视图 +- .ai tool on/off <组名> 支持**整组批量开关**(显式写法 --group=<组名>,可绕开 on/off/help/call/list/all 等保留字与撞名);**关闭时默认跳过核心常驻工具**(list_tools / search_tools / list_mcps / call_tool / use_skill / call_ob11_api / run_ext_command / run_core_command),避免一键关掉 AI 的工具发现与执行入口——无参 .ai tool off 同样适用,确需全关加 --force;开启时跳过「禁止调用的函数」并如实回报变更/跳过个数 +- 技能与知识库不参与工具组维度(其会话开关仍由 .ai skill / .ai kb 管理,单工具开关 .ai tool on/off use_skill 等行为不变);.ai mcp on/off <服务器> 与 .ai tool on/off <服务器> 等价,两者共用同一批量开关实现 +- AI 侧工具发现同步分组口径:list_tools 组头与 search_tools 详情的「来源」改为「内置·记忆」这类两级显示名,list_tools / search_tools 的 mcp= 参数可按分类名(如 记忆、图片、子代理)或 MCP 服务器名过滤,mcp=内置 仍匹配全部内置工具 `, "4.22.0": `## 新功能 - 模型配置 v4:模型配置改为两个 TOML ——「模型」页的 **api连接**(每行一个服务商连接:api_key 必填;provider 选填,省略按 OpenAI 兼容处理(此时需显式填 base_url);base_url 选填,省略取该服务商默认;可选 models 钉住清单(填写=跳过自动拉取,离线/无列表接口时用);可选 ignore(1=忽略该连接);可选 [request](列表拉取与余额查询等连接级覆盖))与 **模型规则**(每行一个"用途组"请求模板:use 数组(chat/compression/summarization/judge/image-understanding/text-embedding)+ 可选 [body]/[request],**不写任何模型名**,命中这些用途的模型统一套用,行序重叠逐键合并、后覆盖先) diff --git a/src/cmd/sub_cmd/mcp.ts b/src/cmd/sub_cmd/mcp.ts index 55ac632..336001d 100644 --- a/src/cmd/sub_cmd/mcp.ts +++ b/src/cmd/sub_cmd/mcp.ts @@ -1,7 +1,7 @@ // .ai mcp:MCP 服务器查看 / 批量开关其工具 / refresh 重走配置解析 -import Config from "../../config/config"; import { getConfiguredMCPServers, isMCPEnabled, refreshMCP } from "../../tool/mcp"; import { toolMap } from "../../tool/tool"; +import { setToolGroupState } from "../../tool/tool_group"; import { matchesPlatform, platformOf } from "../../utils/target_id"; import { aliasToCmd } from "../../utils/utils"; import { I, M, U } from "../privilege"; @@ -53,7 +53,7 @@ export function registerCmdMcp() { } if (names.length === 0) lines.push(' (工具未注册,服务器不可达或未同步,可 .ai mcp refresh)'); } - lines.push('工具开关按会话保存;.ai mcp on/off <服务器> 可批量切换。'); + lines.push('工具开关按会话保存;.ai mcp on/off <服务器> 可批量切换(等价 .ai tool on/off <服务器>)。'); seal.replyToSender(ctx, msg, lines.join('\n')); return ret; } @@ -66,7 +66,6 @@ export function registerCmdMcp() { seal.replyToSender(ctx, msg, '当前平台没有可用的 MCP 服务器(未配置或平台不匹配)'); return ret; } - const blocked = Config.tool.BLOCKED; const enable = op === 'on'; let serversToUse = servers; if (target) { @@ -81,11 +80,10 @@ export function registerCmdMcp() { let skipped = 0; for (const s of serversToUse) { const names = Object.keys(toolMap).filter(k => toolMap[k].group === s.name); - for (const n of names) { - if (enable && blocked.includes(n)) { skipped++; continue; } - session.tool.state[n] = enable; - changed++; - } + // 与 .ai tool on/off <组名> 共用批量开关实现(MCP 工具不涉及核心常驻工具) + const result = setToolGroupState(session, names, enable, { skipBlocked: enable }); + changed += result.changed + result.unchanged; + skipped += result.skippedBlocked; } const scope = target ? `服务器 ${target}` : '全部当前平台服务器'; if (changed === 0) { diff --git a/src/cmd/sub_cmd/tool.ts b/src/cmd/sub_cmd/tool.ts index 5337c52..d8cdccb 100644 --- a/src/cmd/sub_cmd/tool.ts +++ b/src/cmd/sub_cmd/tool.ts @@ -1,20 +1,131 @@ -// .ai tool:查看/开关/调用工具函数(列表按来源分组、只显示当前平台) +// .ai tool:工具组概览 / 组内明细 / 工具与工具组开关 / 工具详情 / 试用 +// 组维度 = 内置工具分类(category)+ MCP 服务器(group);技能/知识库由 .ai skill / .ai kb 管理,不参与组维度。 import Config from "../../config/config"; import { logger } from "../../logger"; -import Tool, { toolMap } from "../../tool/tool"; -import { platformOf } from "../../utils/target_id"; +import { getConfiguredMCPServers, isMCPEnabled } from "../../tool/mcp"; +import Tool, { ToolGroupInfo, toolMap } from "../../tool/tool"; +import { formatGroupToggleReply, setToolGroupState } from "../../tool/tool_group"; +import { matchesPlatform, platformOf } from "../../utils/target_id"; import { aliasToCmd } from "../../utils/utils"; import { I, M, U } from "../privilege"; import { SubCmd, SubCmdContext } from "../root_cmd"; +function helpText(): string { + return `帮助: + 【.ai tool】工具组概览(组名 + 工具数 + 开关统计) + 【.ai tool <组名>】查看组内工具明细(等价写法 --group=<组名>) + 【.ai tool <函数名>】查看工具详情 + 【.ai tool all】扁平列出全部工具(含技能/知识库) + 【.ai tool [on/off] [<组名|函数名>]】开启或关闭整组或单个工具;无参=全部工具(off 默认跳过核心常驻工具,加 --force 一并关闭) + 【.ai tool help <函数名>】查看工具详情 + 【.ai tool call <函数名> --参数名=具体参数】试用工具函数 + 说明:技能/知识库工具不参与工具组维度,请用 .ai skill / .ai kb 开关; + 组名与保留字(on/off/help/call/list/all)冲突时用 --group=<组名>`; +} + +/** 读取带值的 kwarg(如 --group=记忆);缺省/无值返回空串 */ +function kwargValue(cmdArgs: seal.CmdArgs, name: string): string { + const kwarg = cmdArgs.kwargs.find(k => k.name === name); + if (!kwarg || !kwarg.valueExists) return ''; + return String(kwarg.value || '').trim(); +} + +/** 判断布尔开关 kwarg(如 --force):出现即为真,--force=0/false 视为假 */ +function hasFlag(cmdArgs: seal.CmdArgs, name: string): boolean { + const kwarg = cmdArgs.kwargs.find(k => k.name === name); + if (!kwarg) return false; + if (!kwarg.valueExists) return true; + const value = String(kwarg.value || '').trim().toLowerCase(); + return value === '' || value === '1' || value === 'true' || value === 'yes'; +} + +/** 工具详情文本(.ai tool <函数名> 与 .ai tool help <函数名> 共用) */ +function renderToolDetail(name: string): string { + const tool = toolMap[name]; + return `${tool.toolInfo.function.name} + 来源:${Tool.groupDisplay(tool)} + 描述:${tool.toolInfo.function.description} + + 参数信息: + ${JSON.stringify(tool.toolInfo.function.parameters.properties, null, 2)} + + 必需参数:${(tool.toolInfo.function.parameters.required || []).join(',')}`; +} + +/** 组概览:内置分类在前、MCP 服务器在后,只统计当前平台可用工具 */ +function renderOverview(session: SubCmdContext['session'], platform: string): string { + const groups = Tool.getToolGroups(session, platform); + const builtin = groups.filter(g => g.kind === 'builtin'); + const mcp = groups.filter(g => g.kind === 'mcp'); + const sum = (list: ToolGroupInfo[]) => list.reduce((acc, g) => acc + g.names.length, 0); + const lines: string[] = [ + `工具组(当前平台 ${platform || '未知'};内置 ${sum(builtin)} 个 / ${builtin.length} 组${mcp.length > 0 ? `,MCP ${sum(mcp)} 个 / ${mcp.length} 台` : ''}):` + ]; + + if (builtin.length > 0) { + lines.push('[内置]'); + builtin.forEach((g, i) => lines.push(` ${i + 1}. ${g.key}(${g.names.length},开 ${g.on} 关 ${g.off})`)); + } + if (mcp.length > 0) { + lines.push('[MCP 服务器]'); + for (const g of mcp) lines.push(` ${g.key}(${g.names.length},开 ${g.on} 关 ${g.off})`); + } + if (builtin.length === 0 && mcp.length === 0) lines.push(' (没有可用工具)'); + + // MCP 配置了但没注册到工具(服务器不可达/未同步)时给一条提示 + if (isMCPEnabled()) { + const registered = new Set(mcp.map(g => g.key)); + const emptyServers = getConfiguredMCPServers() + .filter(s => matchesPlatform(s.platforms, platform) && !registered.has(s.name)) + .map(s => s.name); + if (emptyServers.length > 0) { + lines.push(`(MCP 服务器 ${emptyServers.join('、')} 未注册到工具:不可达或未同步,可 .ai mcp refresh)`); + } + } + + const blocked = (Config.tool.BLOCKED || []).filter(n => String(n || '').trim() !== ''); + if (blocked.length > 0) lines.push(`(「禁止调用的函数」${blocked.length} 个不参与统计与开关)`); + + lines.push('用 .ai tool <组名> 查看组内工具,.ai tool on/off <组名> 批量开关(--group= 强制按组解析)。'); + lines.push('技能/知识库工具不在工具组范围:见 .ai skill list / .ai kb list;全部工具:.ai tool all。'); + return lines.join('\n'); +} + +/** 组内明细 */ +function renderGroupDetail(group: ToolGroupInfo, session: SubCmdContext['session']): string { + const state = session.toolState; + const kind = group.kind === 'mcp' ? 'MCP 服务器' : '内置'; + const lines = [`工具组「${group.key}」(${kind}):${group.names.length} 个,开 ${group.on} 关 ${group.off}`]; + group.names.forEach((name, i) => lines.push(` ${i + 1}. ${name}[${state[name] ? '开' : '关'}]`)); + const equivalent = group.kind === 'mcp' ? `(等价 .ai mcp on/off ${group.key})` : ''; + lines.push(`批量开关:.ai tool on/off ${group.key}${equivalent}`); + return lines.join('\n'); +} + +/** 扁平全量列表(.ai tool all):按来源/分类分组,含技能与知识库 */ +function renderAllTools(session: SubCmdContext['session'], platform: string): string { + const byGroup: { [label: string]: { name: string; status: string }[] } = {}; + for (const name of Object.keys(session.toolState)) { + const tool = toolMap[name]; + if (!tool) continue; + if (platform && !Tool.isAllowedPlatform(tool, platform)) continue; + const label = Tool.groupDisplay(tool); + (byGroup[label] = byGroup[label] || []).push({ name, status: session.toolState[name] ? '开' : '关' }); + } + const lines = ['工具函数(当前平台,按来源/分类分组):']; + for (const label of Object.keys(byGroup).sort()) { + lines.push(`${label}:`); + for (const item of byGroup[label].sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(` ${item.name}[${item.status}]`); + } + } + return lines.join('\n'); +} + export function registerCmdTool() { const cmd = new SubCmd('tool'); cmd.desc = '工具相关操作'; - cmd.help = `帮助: - 【.ai tool】列出当前平台可用工具(按来源分组) - 【.ai tool [on/off] <函数名>】开启或关闭工具函数 - 【.ai tool help <函数名>】查看工具详情 - 【.ai tool call <函数名> --参数名=具体参数】试用工具函数`; + cmd.help = helpText(); cmd.priv = { priv: U, args: { on: { priv: I }, @@ -25,91 +136,94 @@ export function registerCmdTool() { } }; cmd.solve = async (scc: SubCmdContext) => { - const { ctx, msg, cmdArgs, session, ret } = scc; + const { ctx, msg, cmdArgs, session, ret } = scc; + const platform = platformOf(ctx); + const reply = (text: string) => seal.replyToSender(ctx, msg, text); + + /** 组开关(含 --force) */ + const toggleGroup = (group: ToolGroupInfo, enable: boolean, force: boolean) => { + const result = setToolGroupState(session, group.names, enable, { skipBlocked: enable, skipCore: !enable && !force }); + reply(formatGroupToggleReply(`工具组「${group.key}」`, enable, result)); + }; + + /** 解析组名:返回 ToolGroupInfo,或已回复过的错误 */ + const pickGroup = (arg: string, groups: ToolGroupInfo[]): ToolGroupInfo | null => { + const resolved = Tool.resolveToolGroup(arg, groups); + if (typeof resolved === 'string') { + reply(resolved); + return null; + } + return resolved; + }; const val2 = cmdArgs.getArgN(2); switch (aliasToCmd(val2)) { - case 'on': { - const val3 = cmdArgs.getArgN(3); - if (val3) { - if (!Object.prototype.hasOwnProperty.call(toolMap, val3)) { - seal.replyToSender(ctx, msg, `工具函数 ${val3} 不存在`); - return ret; - } - const blockedTools = Config.tool.BLOCKED; - if (blockedTools.includes(val3)) { - seal.replyToSender(ctx, msg, `工具函数 ${val3} 不被允许开启`); - return ret; - } + case 'on': + case 'off': { + const enable = aliasToCmd(val2) === 'on'; + const force = hasFlag(cmdArgs, 'force'); + const groupArg = kwargValue(cmdArgs, 'group'); + const target = String(cmdArgs.getArgN(3) || '').trim(); + const groups = Tool.getToolGroups(session, platform); - session.tool.state[val3] = true; - seal.replyToSender(ctx, msg, `已开启工具函数 ${val3}`); - session.save(); + // 1) 显式 --group=<组名> 优先(绕开保留字与撞名) + if (groupArg) { + const group = pickGroup(groupArg, groups); + if (group) toggleGroup(group, enable, force); return ret; } - const blockedTools = Config.tool.BLOCKED; - for (const key in session.toolState) { - session.tool.state[key] = blockedTools.includes(key) ? false : true; + + // 2) 无参:全部工具(off 默认跳过核心常驻工具) + if (!target) { + const names = Object.keys(session.toolState); + const result = setToolGroupState(session, names, enable, { skipBlocked: enable, skipCore: !enable && !force }); + const extra = !enable && !force ? '\n(核心常驻工具默认保留,关掉后 AI 将无法发现/执行其余工具;如需一并关闭加 --force)' : ''; + reply(formatGroupToggleReply('全部工具', enable, result) + extra); + return ret; } - seal.replyToSender(ctx, msg, '已开启全部工具函数'); - session.save(); - return ret; - } - case 'off': { - const val3 = cmdArgs.getArgN(3); - if (val3) { - if (!Object.prototype.hasOwnProperty.call(toolMap, val3)) { - seal.replyToSender(ctx, msg, `工具函数 ${val3} 不存在`); + + // 3) 工具名:单工具开关(行为与历史一致,不跳过核心工具) + if (Object.prototype.hasOwnProperty.call(toolMap, target)) { + if (enable && Config.tool.BLOCKED.includes(target)) { + reply(`工具函数 ${target} 不被允许开启`); return ret; } - session.tool.state[val3] = false; - seal.replyToSender(ctx, msg, `已关闭工具函数 ${val3}`); + session.tool.state[target] = enable; + reply(`已${enable ? '开启' : '关闭'}工具函数 ${target}`); session.save(); return ret; } - for (const key in session.toolState) { - session.tool.state[key] = false; + + // 4) 组名:整组开关 + const resolved = Tool.resolveToolGroup(target, groups); + if (typeof resolved === 'string') { + reply(`${resolved}(若本意是开关单个工具函数,请核对函数名;.ai tool all 列出全部工具)`); + } else { + toggleGroup(resolved, enable, force); } - seal.replyToSender(ctx, msg, '已关闭全部工具函数'); - session.save(); return ret; } case 'help': { const val3 = cmdArgs.getArgN(3); if (!val3) { - seal.replyToSender(ctx, msg, `帮助: - 【.ai tool】列出所有工具 - 【.ai tool [on/off] <函数名>】开启或关闭工具函数 - 【.ai tool help <函数名>】查看工具详情 - 【.ai tool call <函数名> --参数名=具体参数】试用工具函数`); + reply(helpText()); return ret; } - if (!Object.prototype.hasOwnProperty.call(toolMap, val3)) { - seal.replyToSender(ctx, msg, '没有这个工具函数'); + reply('没有这个工具函数'); return ret; } - - const tool = toolMap[val3]; - const s = `${tool.toolInfo.function.name} - 描述:${tool.toolInfo.function.description} - - 参数信息: - ${JSON.stringify(tool.toolInfo.function.parameters.properties, null, 2)} - - 必需参数:${(tool.toolInfo.function.parameters.required || []).join(',')}`; - - seal.replyToSender(ctx, msg, s); + reply(renderToolDetail(val3)); return ret; } case 'call': { const val3 = cmdArgs.getArgN(3); if (!val3) { - seal.replyToSender(ctx, msg, `调用函数缺少工具函数名`); + reply(`调用函数缺少工具函数名`); return ret; } if (!Object.prototype.hasOwnProperty.call(toolMap, val3)) { - seal.replyToSender(ctx, msg, `调用函数失败:未注册的函数:${val3}`); + reply(`调用函数失败:未注册的函数:${val3}`); return ret; } const tool = toolMap[val3]; @@ -127,7 +241,7 @@ export function registerCmdTool() { for (const key of (tool.toolInfo.function.parameters.required || [])) { if (!Object.prototype.hasOwnProperty.call(args, key)) { logger.warning(`调用函数失败:缺少必需参数 ${key}`); - seal.replyToSender(ctx, msg, `调用函数失败:缺少必需参数 ${key}`); + reply(`调用函数失败:缺少必需参数 ${key}`); return ret; } } @@ -138,42 +252,48 @@ export function registerCmdTool() { const MAX_TOOL_CALL_OUTPUT_LENGTH = 500; if (content.length > MAX_TOOL_CALL_OUTPUT_LENGTH) { logger.logLong(`[tool] 返回内容过长(${content.length}字符),已仅记录日志,未发送`, content); - seal.replyToSender(ctx, msg, `返回内容过长(${content.length} 字符),未发送,已记录到海豹日志([tool] 指令调用 tool=${val3})`); + reply(`返回内容过长(${content.length} 字符),未发送,已记录到海豹日志([tool] 指令调用 tool=${val3})`); } else { - seal.replyToSender(ctx, msg, `返回内容: + reply(`返回内容: ${content}`); } return ret; } catch (e) { - const s = `调用函数 (${val3}) 失败:${e instanceof Error ? e.message : String(e)}`; - seal.replyToSender(ctx, msg, s); + reply(`调用函数 (${val3}) 失败:${e instanceof Error ? e.message : String(e)}`); return ret; } } default: { - const toolStatus = session.toolState; - const platform = platformOf(ctx); - - const s = '工具函数(当前平台,按来源分组):'; - const lines = [s]; - // 按来源分组:仅显示当前平台可用工具 - const groups: { [group: string]: { name: string; status: string }[] } = {}; - for (const key of Object.keys(toolStatus)) { - const tool = toolMap[key]; - if (!tool) continue; - if (platform && !Tool.isAllowedPlatform(tool, platform)) continue; - const status = toolStatus[key] ? '开' : '关'; - const g = Tool.groupLabel(tool.group); - (groups[g] = groups[g] || []).push({ name: key, status }); + // 无参或 list:组概览 + const arg = String(val2 || '').trim(); + const groupArg = kwargValue(cmdArgs, 'group'); + + if (groupArg) { + const group = pickGroup(groupArg, Tool.getToolGroups(session, platform)); + if (group) reply(renderGroupDetail(group, session)); + return ret; } - for (const g of Object.keys(groups).sort()) { - lines.push(`${g}:`); - for (const item of groups[g].sort((a, b) => a.name.localeCompare(b.name))) { - lines.push(` ${item.name}[${item.status}]`); - } + if (arg === '' || aliasToCmd(arg) === 'list') { + reply(renderOverview(session, platform)); + return ret; } - - seal.replyToSender(ctx, msg, lines.join('\n')); + if (arg === 'all') { + reply(renderAllTools(session, platform)); + return ret; + } + // 工具名 → 详情;组名 → 组明细 + if (Object.prototype.hasOwnProperty.call(toolMap, arg)) { + reply(renderToolDetail(arg)); + return ret; + } + const groups = Tool.getToolGroups(session, platform); + const group = Tool.resolveToolGroup(arg, groups); + if (typeof group === 'string') { + const candidates = groups.map(g => g.key).slice(0, 12).join('、'); + reply(`${group}\n可用组:${candidates};全部工具:.ai tool all`); + return ret; + } + reply(renderGroupDetail(group, session)); return ret; } } diff --git a/src/tool/skills.ts b/src/tool/skills.ts index 5fe8e85..c0391b8 100644 --- a/src/tool/skills.ts +++ b/src/tool/skills.ts @@ -5,7 +5,7 @@ import Logger from "../logger"; import { splitFrontmatter } from "../utils/frontmatter"; import { matchesPlatform, platformOf } from "../utils/target_id"; -import Tool from "./tool"; +import Tool, { SKILL_GROUP } from "./tool"; interface Skill { name: string; @@ -171,7 +171,7 @@ export function registerSkills() { } } } - }, false, '技能'); + }, false, SKILL_GROUP); toolList.solve = async (ctx, _msg, session, args) => { const { page = 1, page_size = 20, query = '' } = args || {}; const platform = platformOf(ctx); @@ -213,7 +213,7 @@ export function registerSkills() { required: ["name"] } } - }, false, '技能'); + }, false, SKILL_GROUP); tool.solve = async (ctx, _msg, session, args) => { const name = typeof args?.name === 'string' ? args.name.trim() : ''; if (!name) return 'use_skill 缺少技能名称 name'; diff --git a/src/tool/tool.ts b/src/tool/tool.ts index a31f920..a9636b5 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -23,7 +23,7 @@ export type ToolState = { [key: string]: boolean }; // 核心常驻工具:始终注入函数 schema,保证基本对话能力与“发现/执行”入口; // 其余工具按需加载:AI 先用 search_tools 查找工具,再用 call_tool 执行,避免全量工具定义浪费 token export const CORE_TOOL_NAMES: string[] = [ - 'list_tools', // 工具列表(名称+描述,按来源分组) + 'list_tools', // 工具列表(名称+描述,按工具组分组) 'search_tools', // 按需发现工具(返回完整参数说明) 'list_mcps', // 列出所有 MCP 服务器及其工具 'call_tool', // 统一执行入口(调用任意已开启工具) @@ -36,6 +36,38 @@ export const CORE_TOOL_NAMES: string[] = [ /** 原生 function calling 模式下只向 API 暴露的引导工具 */ export const NATIVE_TOOL_NAMES: string[] = ['list_tools', 'search_tools', 'call_tool']; +/** 不参与工具组维度的来源分组:技能/知识库的会话开关由 .ai skill / .ai kb 管理(单工具开关不受影响) */ +export const SKILL_GROUP = '技能'; +export const KNOWLEDGE_GROUP = '知识库'; +export const NON_GROUPABLE_GROUPS: string[] = [SKILL_GROUP, KNOWLEDGE_GROUP]; + +/** 内置工具分类缺省兜底 / 外部插件(globalThis.aiplugin4.registerTool)注册工具的固定分类 */ +export const DEFAULT_CATEGORY = '其他'; +export const EXTERNAL_CATEGORY = '外部插件'; + +/** 内置分类的固定展示顺序(未列入的分类按名称追加在末尾,避免依赖中文 localeCompare 排序) */ +export const BUILTIN_CATEGORY_ORDER: string[] = [ + '基础调度', '指令', '定时', '触发', '记忆', '图片', 'OB11', '资源', + '属性', '原文检索', '黑名单', '网页', '公开会话', '子代理', + DEFAULT_CATEGORY, EXTERNAL_CATEGORY +]; + +/** 工具组:内置分类组(kind=builtin,key=分类名)或 MCP 服务器组(kind=mcp,key=服务器名) */ +export interface ToolGroupInfo { + /** 解析键:分类名 / MCP 服务器名 */ + key: string; + /** 显示名:内置·记忆 / mcp-files-exec */ + label: string; + kind: 'builtin' | 'mcp'; + /** 组内工具名(按名称排序、已按当前平台过滤、不含禁止名单中的工具) */ + names: string[]; + on: number; + off: number; +} + +/** 注册批次内的当前分类(withCategory 维护,仅对未显式指定 group 的内置工具生效) */ +let currentCategory: string | null = null; + /** 提示词工程模式下需要完整参数说明的元工具 */ export const META_TOOL_NAMES: string[] = [ 'list_tools', @@ -85,6 +117,8 @@ export default class Tool { sensitive: boolean; // 敏感工具(发送消息/封禁/改名等),调用会显著记录 /** 来源分组:内置工具为空;技能工具='技能';知识库工具='知识库';MCP 工具=所属服务器名 */ group?: string; + /** 内置工具的细分分类(仅 group 为空时赋值):用于 .ai tool 组概览与批量开关、AI 侧组头显示 */ + category?: string; /** 平台白名单(继承自源单元:MCP 服务器/技能/知识库的 platform),缺省 = 所有平台 */ platforms?: string[]; solve: (ctx: seal.MsgContext, msg: seal.Message, session: Session, args: { [key: string]: any }) => Promise; @@ -95,12 +129,31 @@ export default class Tool { this.sessionType = "any"; this.callBack = true; this.group = group; + // 分类只对内置工具(group 为空)生效:技能/知识库/MCP 工具的组维度就是其来源分组 + this.category = group ? undefined : (currentCategory || undefined); this.platforms = platforms; this.solve = async (_, __, ___, ____) => "函数未实现"; toolMap[info.function.name] = this; } + /** + * 在指定分类下注册一批内置工具(注册批次包装,避免逐个 new Tool 传分类)。 + * 只对批次内未显式指定 group 的工具赋值 category;仅用于同步注册批次。 + */ + static withCategory(category: string, fn: () => T): T { + if (currentCategory !== null) { + log.warning(`withCategory 嵌套调用(${currentCategory} → ${category}),内层分类生效`); + } + const prev = currentCategory; + currentCategory = category; + try { + return fn(); + } finally { + currentCategory = prev; + } + } + /** 清空工具注册表(用于测试/热重载) */ static reset() { for (const key of Object.keys(toolMap)) delete toolMap[key]; @@ -262,11 +315,95 @@ export default class Tool { return matchesPlatform(tool.platforms, platform); } - /** 来源分组显示名:无 group 的内置工具显示「内置」 */ + /** 来源分组显示名:无 group 的内置工具显示「内置」(平台限制等错误文案沿用该口径) */ static groupLabel(group?: string): string { return group || '内置'; } + /** 工具的分类名:仅内置工具(group 为空)有分类,缺省兜底「其他」;非内置工具返回空串 */ + static categoryOf(tool?: Tool): string { + if (!tool || tool.group) return ''; + return tool.category || DEFAULT_CATEGORY; + } + + /** 工具是否属于可切换的工具组(技能/知识库由 .ai skill / .ai kb 管理,不参与组维度) */ + static isGroupable(tool?: Tool): boolean { + if (!tool) return false; + return !(!!tool.group && NON_GROUPABLE_GROUPS.includes(tool.group)); + } + + /** 工具所属组的显示名:内置工具=「内置·分类」,其余=来源分组(技能/知识库/MCP 服务器名) */ + static groupDisplay(tool?: Tool): string { + if (!tool) return DEFAULT_CATEGORY; + if (tool.group) return tool.group; + return `内置·${Tool.categoryOf(tool)}`; + } + + /** 分组过滤匹配:来源分组(原 mcp= 语义)/ 分类名 / 组显示名 / 「内置」任一命中 */ + static matchesGroupFilter(tool: Tool | undefined, filter: string): boolean { + const f = String(filter || '').trim(); + if (!f) return true; + if (!tool) return false; + if (tool.group === f || Tool.groupDisplay(tool) === f) return true; + if (!tool.group && (Tool.categoryOf(tool) === f || f === '内置')) return true; + return false; + } + + /** 按组聚合工具名:组键=分类名或来源分组,显示名取 groupDisplay,组内按工具名排序 */ + private static groupEntries(entries: { name: string; tool: Tool }[]): { key: string; label: string; kind: 'builtin' | 'mcp'; names: string[] }[] { + const map: { [key: string]: { key: string; label: string; kind: 'builtin' | 'mcp'; names: string[] } } = {}; + for (const { name, tool } of entries) { + const kind: 'builtin' | 'mcp' = tool.group ? 'mcp' : 'builtin'; + const key = tool.group || Tool.categoryOf(tool); + if (!map[key]) map[key] = { key, label: Tool.groupDisplay(tool), kind, names: [] }; + map[key].names.push(name); + } + const groups = Object.keys(map).map(k => map[k]); + for (const g of groups) g.names.sort((a, b) => a.localeCompare(b)); + // 内置分类按固定顺序在前,MCP 服务器组按名称在后;未列入顺序表的分类排在已知分类之后 + const rank = (g: { kind: string; key: string }) => { + if (g.kind === 'mcp') return 10000; + const i = BUILTIN_CATEGORY_ORDER.indexOf(g.key); + return i === -1 ? 9000 : i; + }; + groups.sort((a, b) => rank(a) - rank(b) || a.key.localeCompare(b.key)); + return groups; + } + + /** + * 当前会话的全部已注册工具按组列出(含已关闭的工具,排除技能/知识库组),供 .ai tool 组概览与批量开关用。 + * 计数直接读 session.toolState:自动继承平台过滤、禁止名单剔除、默认关闭与子代理工具面收窄。 + */ + static getToolGroups(session: Session, platform?: string): ToolGroupInfo[] { + const state = session.toolState; + const entries: { name: string; tool: Tool }[] = []; + for (const name of Object.keys(state)) { + const tool = toolMap[name]; + if (!tool) continue; + if (!Tool.isGroupable(tool)) continue; + if (!Tool.isAllowedPlatform(tool, platform)) continue; + entries.push({ name, tool }); + } + return Tool.groupEntries(entries).map(g => { + const on = g.names.filter(n => !!state[n]).length; + return { key: g.key, label: g.label, kind: g.kind, names: g.names, on, off: g.names.length - on }; + }); + } + + /** 按显示名/分类名/服务器名解析工具组:精确优先,其次唯一包含匹配;返回字符串即错误说明 */ + static resolveToolGroup(arg: string, groups: ToolGroupInfo[]): ToolGroupInfo | string { + const raw = String(arg || '').trim(); + if (raw === '') return '未指定工具组名;可用 .ai tool 查看全部组'; + const lower = raw.toLowerCase(); + const exact = groups.filter(g => g.key === raw || g.label === raw || g.key.toLowerCase() === lower || g.label.toLowerCase() === lower); + if (exact.length === 1) return exact[0]; + if (exact.length > 1) return `「${raw}」匹配多个工具组:${exact.map(g => g.key).join('、')};请写全名(可用 --group=)`; + const fuzzy = groups.filter(g => g.key.toLowerCase().includes(lower) || g.label.toLowerCase().includes(lower)); + if (fuzzy.length === 1) return fuzzy[0]; + if (fuzzy.length === 0) return `未找到工具组「${raw}」;可用 .ai tool 查看全部组`; + return `「${raw}」匹配多个工具组:${fuzzy.map(g => g.key).join('、')};请写全名(可用 --group=)`; + } + static getToolsInfo(session: Session, platform?: string): ToolInfo[] | null { const toolState = session.toolState; const sessionType = session.sessionType; @@ -316,20 +453,21 @@ export default class Tool { return core.concat(this.getOnDemandTools(session, platform)); } - /** 按来源分组返回可用工具(过滤平台),组内按工具名排序,组按显示名排序 */ - static getGroupedAvailableTools(session: Session, platform?: string): { group: string; tools: ToolInfo[] }[] { + /** + * 按组返回当前会话可用(已开启、平台匹配)的工具,组头用统一显示名(内置·记忆 / MCP 服务器名 / 技能 / 知识库)。 + * 与 .ai tool 共用同一分组实现;此处不排除技能/知识库(AI 侧发现需要看到它们)。 + */ + static getGroupedAvailableTools(session: Session, platform?: string): { group: string; key: string; kind: 'builtin' | 'mcp'; tools: ToolInfo[] }[] { const infos = this.getAvailableTools(session, platform); - const byGroup: { [key: string]: ToolInfo[] } = {}; - for (const info of infos) { - const tool = toolMap[info.function.name]; - const label = Tool.groupLabel(tool?.group); - (byGroup[label] = byGroup[label] || []).push(info); - } - const keys = Object.keys(byGroup).sort(); - for (const k of keys) { - byGroup[k].sort((a, b) => a.function.name.localeCompare(b.function.name)); - } - return keys.map(g => ({ group: g, tools: byGroup[g] })); + const entries = infos + .map(info => ({ name: info.function.name, tool: toolMap[info.function.name] })) + .filter(e => !!e.tool); + return Tool.groupEntries(entries).map(g => ({ + group: g.label, + key: g.key, + kind: g.kind, + tools: g.names.map(n => toolMap[n].toolInfo) + })); } static getToolsInfoPrompt(session: Session, platform?: string): string { @@ -372,7 +510,7 @@ export default class Tool { .sort((a, b) => a.function.name.localeCompare(b.function.name)); const summaries = tools.map(t => { const tool = toolMap[t.function.name]; - const group = Tool.groupLabel(tool?.group); + const group = Tool.groupDisplay(tool); const desc = flattenText(t.function.description, 120); return `- ${t.function.name}:${desc}(来源:${group})`; }); @@ -410,7 +548,7 @@ export default class Tool { return [ '## 工具获取', '当前不直接列出全部工具。需要发现工具时:', - '- list_tools:分页查看当前可用工具的名称与描述(按来源分组)', + '- list_tools:分页查看当前可用工具的名称与描述(按工具组分组)', '- search_tools:按名称/关键词/MCP 服务器获取工具的完整参数说明', '- list_mcps:列出当前平台可用的全部 MCP 服务器及其工具', '- call_tool:执行指定工具' @@ -433,7 +571,7 @@ export default class Tool { return [formatPart, guidePart].filter(Boolean).join('\n\n'); } - /** 原生模式工具块:仅名称 + 描述(按来源分组) */ + /** 原生模式工具块:仅名称 + 描述(按工具组分组) */ static getToolSummaryBlock(session: Session, platform?: string): string { const r = this.getToolSummaries(session, 100, platform); if (r.summaries.length === 0) return ''; diff --git a/src/tool/tool_group.ts b/src/tool/tool_group.ts new file mode 100644 index 0000000..636f736 --- /dev/null +++ b/src/tool/tool_group.ts @@ -0,0 +1,67 @@ +// 工具组批量开关:.ai tool on/off <组名> 与 .ai mcp on/off <服务器> 共用同一实现, +// 保证「跳过禁用名单 / 跳过核心常驻工具 / 计数口径」两条命令完全一致。 +import Config from "../config/config"; +import { Session } from "../session/session"; + +import { CORE_TOOL_NAMES } from "./tool"; + +export interface GroupToggleOptions { + /** 跳过「禁止调用的函数」(on 用,与 .ai mcp on 语义一致) */ + skipBlocked?: boolean; + /** 跳过核心常驻工具(off 用;避免一键关掉 AI 的工具发现/执行入口) */ + skipCore?: boolean; +} + +export interface GroupToggleResult { + /** 实际发生变化的工具数 */ + changed: number; + /** 已经是目标状态的工具数 */ + unchanged: number; + /** 因「禁止调用的函数」跳过的工具数 */ + skippedBlocked: number; + /** 因核心常驻工具跳过的工具数 */ + skippedCore: number; +} + +/** + * 批量设置一组工具的会话开关。写入 session.tool.state(稀疏持久化),有实际变更时落盘。 + * 计数只反映真实状态变化;被跳过的工具单独计数,供命令侧如实回报。 + */ +export function setToolGroupState(session: Session, names: string[], enable: boolean, options: GroupToggleOptions = {}): GroupToggleResult { + const { skipBlocked = false, skipCore = false } = options; + const blocked = Config.tool.BLOCKED; + const result: GroupToggleResult = { changed: 0, unchanged: 0, skippedBlocked: 0, skippedCore: 0 }; + + for (const name of names) { + if (skipBlocked && blocked.includes(name)) { + result.skippedBlocked++; + continue; + } + if (skipCore && CORE_TOOL_NAMES.includes(name)) { + result.skippedCore++; + continue; + } + if (!!session.tool.state[name] === enable) { + result.unchanged++; + continue; + } + session.tool.state[name] = enable; + result.changed++; + } + + if (result.changed > 0) session.save(); + return result; +} + +/** 批量开关的回复文案(组级与全量共用;0 变更且因核心工具跳过时给出 --force 提示) */ +export function formatGroupToggleReply(scope: string, enable: boolean, result: GroupToggleResult): string { + const parts = [`实际变更 ${result.changed} 个`]; + if (result.unchanged > 0) parts.push(`已是该状态 ${result.unchanged} 个`); + if (result.skippedBlocked > 0) parts.push(`跳过禁用名单 ${result.skippedBlocked} 个`); + if (result.skippedCore > 0) parts.push(`跳过核心常驻工具 ${result.skippedCore} 个`); + let text = `已${enable ? '开启' : '关闭'}${scope}:${parts.join(',')}。`; + if (!enable && result.changed === 0 && result.skippedCore > 0) { + text += `\n(命中项全部是核心常驻工具,关掉后 AI 将无法发现/执行其余工具;如确需关闭请加 --force)`; + } + return text; +} diff --git a/src/tool/tools/core/init.ts b/src/tool/tools/core/init.ts index c3332e8..61bd261 100644 --- a/src/tool/tools/core/init.ts +++ b/src/tool/tools/core/init.ts @@ -1,15 +1,17 @@ // core 子目录工具注册统一入口(调度/触发/时间/指令) +import Tool from "../../tool"; + import { registerCmdTool } from "./tool_cmd"; import { registerCoreCommandTool } from "./tool_core_command"; import { registerDispatchTools } from "./tool_dispatch"; import { registerTime } from "./tool_time"; import { registerSetTrigger } from "./tool_trigger"; -/** 注册 core 下全部核心基础工具 */ +/** 注册 core 下全部核心基础工具(按分类批次包装,供 .ai tool 组概览与组开关使用) */ export function registerCoreTools() { - registerDispatchTools(); - registerSetTrigger(); - registerTime(); - registerCmdTool(); - registerCoreCommandTool(); + Tool.withCategory('基础调度', registerDispatchTools); + Tool.withCategory('指令', registerCmdTool); + Tool.withCategory('指令', registerCoreCommandTool); + Tool.withCategory('定时', registerTime); + Tool.withCategory('触发', registerSetTrigger); } diff --git a/src/tool/tools/core/tool_dispatch.ts b/src/tool/tools/core/tool_dispatch.ts index 4e0a6c4..df594f2 100644 --- a/src/tool/tools/core/tool_dispatch.ts +++ b/src/tool/tools/core/tool_dispatch.ts @@ -1,6 +1,6 @@ // 按需加载调度工具:list_tools(分组列表)+ search_tools(发现工具,支持按 MCP 过滤)+ list_mcps(列出全部 MCP)+ call_tool(统一执行) // 非核心工具不再全量注入函数 schema;AI 先搜索工具获得参数说明,再通过 call_tool 执行, -// 大幅降低每轮请求中工具定义占用的 token。工具按来源分组(MCP 服务器/内置/技能/知识库), +// 大幅降低每轮请求中工具定义占用的 token。工具按工具组分组(内置分类/MCP 服务器/技能/知识库), // 且只对当前平台可用(platform 白名单)的工具可见/可调用。 import Config from "../../../config/config"; import Logger from "../../../logger"; @@ -16,12 +16,12 @@ const MCP_PAGE_SIZE_LIMIT = 100; export function registerDispatchTools() { - // 工具列表:按来源分组返回工具名称 + 一句话描述,不返回参数详情 + // 工具列表:按工具组返回工具名称 + 一句话描述,不返回参数详情 const listTool = new Tool({ type: "function", function: { name: "list_tools", - description: `分页列出当前会话可用(当前平台)工具的名称与一句话描述,按来源分组(MCP 服务器/内置/技能/知识库);不返回参数详情。需要获取某个工具的参数说明时使用 search_tools。`, + description: `分页列出当前会话可用(当前平台)工具的名称与一句话描述,按工具组分组(内置分类如 记忆/图片/子代理、MCP 服务器名、技能、知识库);不返回参数详情。需要获取某个工具的参数说明时使用 search_tools。`, parameters: { type: "object", properties: { @@ -39,7 +39,7 @@ export function registerDispatchTools() { }, mcp: { type: "string", - description: "可选,只列出某个来源分组(如 MCP 服务器名)下的工具" + description: "可选,只列出某个来源分组或内置分类下的工具(如 MCP 服务器名 mcp-browser、分类 记忆/图片/子代理)" } }, required: [] @@ -52,7 +52,7 @@ export function registerDispatchTools() { let tools = Tool.getAvailableTools(session, platform) .sort((a, b) => a.function.name.localeCompare(b.function.name)); const groupFilter = String(mcp || '').trim(); - if (groupFilter) tools = tools.filter(t => (toolMap[t.function.name]?.group || '') === groupFilter); + if (groupFilter) tools = tools.filter(t => Tool.matchesGroupFilter(toolMap[t.function.name], groupFilter)); const q = String(query || '').trim().toLowerCase(); const filtered = q ? tools.filter(t => @@ -61,7 +61,7 @@ export function registerDispatchTools() { ) : tools; if (filtered.length === 0) { - return groupFilter ? `未找到来源分组「${groupFilter}」下的工具;可用 list_mcps 查看全部 MCP 服务器` : '当前没有可用工具'; + return groupFilter ? `未找到来源分组/分类「${groupFilter}」下的工具;可用 list_mcps 查看 MCP 服务器,.ai tool 查看工具组` : '当前没有可用工具'; } const size = Math.min(Math.max(parseInt(page_size, 10) || 20, 1), 100); const current = Math.max(parseInt(page, 10) || 1, 1); @@ -69,10 +69,10 @@ export function registerDispatchTools() { const start = (current - 1) * size; const pageItems = filtered.slice(start, start + size); const lines = [`可用工具(共 ${filtered.length} 个)`]; - // 按来源分组渲染(组头 + 组内工具,编号全局连续) + // 按组渲染(组头 + 组内工具,编号全局连续);组头用「内置·分类 / MCP 服务器名 / 技能 / 知识库」 const groups: { [group: string]: ToolInfo[] } = {}; for (const t of pageItems) { - const g = Tool.groupLabel(toolMap[t.function.name]?.group); + const g = Tool.groupDisplay(toolMap[t.function.name]); (groups[g] = groups[g] || []).push(t); } let n = start; @@ -94,7 +94,7 @@ export function registerDispatchTools() { type: "function", function: { name: "search_tools", - description: `查看/搜索当前会话可用(当前平台)的工具。不传参数:返回全部可用工具的名字列表;指定 name:返回该工具的完整参数说明;指定 query:按关键词搜索匹配工具并返回完整参数说明;指定 mcp:只在该 MCP 服务器(或来源分组)内搜索。需要调用未直接在函数列表中提供的工具时,先通过本工具获取参数格式,再通过 call_tool 执行。`, + description: `查看/搜索当前会话可用(当前平台)的工具。不传参数:返回全部可用工具的名字列表;指定 name:返回该工具的完整参数说明;指定 query:按关键词搜索匹配工具并返回完整参数说明;指定 mcp:只在该 MCP 服务器或内置分类(如 记忆/图片/子代理)内搜索。需要调用未直接在函数列表中提供的工具时,先通过本工具获取参数格式,再通过 call_tool 执行。`, parameters: { type: "object", properties: { @@ -108,7 +108,7 @@ export function registerDispatchTools() { }, mcp: { type: "string", - description: "可选,限定在某个来源分组(MCP 服务器名等)内搜索工具;可用 list_mcps 查看全部 MCP 服务器名" + description: "可选,限定在某个来源分组或内置分类内搜索工具(MCP 服务器名如 mcp-browser、分类如 记忆/图片/子代理);可用 list_mcps 查看 MCP 服务器名" }, limit: { type: "integer", @@ -125,9 +125,9 @@ export function registerDispatchTools() { let tools = Tool.getAvailableTools(session, platform); const groupFilter = String(mcp || '').trim(); if (groupFilter) { - tools = tools.filter(t => (toolMap[t.function.name]?.group || '') === groupFilter); + tools = tools.filter(t => Tool.matchesGroupFilter(toolMap[t.function.name], groupFilter)); } - const notFoundInGroup = () => `未找到来源分组「${groupFilter}」下的工具;可用 list_mcps 查看全部 MCP 服务器`; + const notFoundInGroup = () => `未找到来源分组/分类「${groupFilter}」下的工具;可用 list_mcps 查看 MCP 服务器,.ai tool 查看工具组`; if (groupFilter && tools.length === 0) { return notFoundInGroup(); } @@ -137,16 +137,16 @@ export function registerDispatchTools() { if (toolName) { const target = tools.find(t => t.function.name === toolName); if (!target) { - if (groupFilter) return `来源分组「${groupFilter}」中没有工具 ${toolName};可用 list_mcps 查看 MCP 服务器,search_tools(不传参数)查看全部工具名`; + if (groupFilter) return `来源分组/分类「${groupFilter}」中没有工具 ${toolName};可用 list_mcps 查看 MCP 服务器,search_tools(不传参数)查看全部工具名`; return `工具 ${toolName} 不存在、未开启或当前平台不可用;可调用 search_tools(不传参数)查看全部工具名`; } - return formatToolDetail(target, 1, toolMap[target.function.name]?.group); + return formatToolDetail(target, 1); } // 不传参数:返回全部工具名字列表(紧凑),详情按需查询 if (!String(query || '').trim()) { if (tools.length === 0) { - return groupFilter ? `未找到来源分组「${groupFilter}」下的工具;可用 list_mcps 查看全部 MCP 服务器` : '当前没有可用工具'; + return groupFilter ? `未找到来源分组/分类「${groupFilter}」下的工具;可用 list_mcps 查看 MCP 服务器,.ai tool 查看工具组` : '当前没有可用工具'; } return `可用工具(共 ${tools.length} 个):\n${tools.map((t, i) => `${i + 1}. ${t.function.name}`).join('\n')}\n查看工具名称与描述:调用 list_tools;查看某个工具的详情:调用 search_tools 并指定 name=工具名;查看 MCP 服务器:调用 list_mcps`; } @@ -165,7 +165,7 @@ export function registerDispatchTools() { if (list.length === 0) { return `没有找到与「${query}」匹配的工具`; } - return list.map((t, i) => formatToolDetail(t, i + 1, toolMap[t.function.name]?.group)).join('\n\n') + `\n\n共匹配 ${matched.length} 个,已返回 ${list.length} 个。`; + return list.map((t, i) => formatToolDetail(t, i + 1)).join('\n\n') + `\n\n共匹配 ${matched.length} 个,已返回 ${list.length} 个。`; }; // 列出全部 MCP 服务器及其工具(按 MCP 分组的能力入口) @@ -308,9 +308,9 @@ export function registerDispatchTools() { }; } -/** 输出单个工具的完整详情(名称/描述/来源/参数 schema/调用方式) */ -function formatToolDetail(tool: ToolInfo, index: number, group?: string): string { - const source = Tool.groupLabel(group); +/** 输出单个工具的完整详情(名称/描述/来源/参数 schema/调用方式);来源统一用 groupDisplay 口径 */ +function formatToolDetail(tool: ToolInfo, index: number): string { + const source = Tool.groupDisplay(toolMap[tool.function.name]); return `${index}. ${tool.function.name}(来源:${source})\n描述:${tool.function.description}\n参数(JSON Schema):\n${JSON.stringify(tool.function.parameters, null, 2)}\n调用方式:使用 call_tool,参数为 {"name": "${tool.function.name}", "arguments": {…}}`; } diff --git a/src/tool/tools/image/init.ts b/src/tool/tools/image/init.ts index d5bcbba..fe8affe 100644 --- a/src/tool/tools/image/init.ts +++ b/src/tool/tools/image/init.ts @@ -1,9 +1,13 @@ // image.ts 子目录工具注册统一入口 +import Tool from "../../tool"; + import { registerImage } from "./tool_image"; import { registerMeme } from "./tool_meme"; -/** 注册 image.ts 下全部图片工具 */ +/** 注册 image.ts 下全部图片工具(分类:图片) */ export function registerImageTools() { - registerImage(); - registerMeme(); + Tool.withCategory('图片', () => { + registerImage(); + registerMeme(); + }); } diff --git a/src/tool/tools/init.ts b/src/tool/tools/init.ts index eb3baaa..88d1082 100644 --- a/src/tool/tools/init.ts +++ b/src/tool/tools/init.ts @@ -1,8 +1,9 @@ // 工具注册统一入口:OB11 消息发送由 call_ob11_api 统一处理。 +// 各 register* 函数内部用 Tool.withCategory 标注内置分类(.ai tool 组概览与组开关用)。 import { registerCoreTools } from "./core/init"; import { registerImageTools } from "./image/init"; import { registerManageTools } from "./manage/init"; -import { registerMemoryTools } from "./memory/init"; +import { registerKnowledgeTools, registerMemoryTools } from "./memory/init"; import { registerOb11Tools } from "./ob11/init"; import { registerPubToolSet } from "./pub/init"; import { registerRawToolSet } from "./raw/init"; @@ -17,6 +18,7 @@ export function registerTools() { registerSealTools(); registerRawToolSet(); registerMemoryTools(); + registerKnowledgeTools(); registerResourceTools(); registerCoreTools(); registerWebTools(); diff --git a/src/tool/tools/manage/init.ts b/src/tool/tools/manage/init.ts index ebb125c..d8bba20 100644 --- a/src/tool/tools/manage/init.ts +++ b/src/tool/tools/manage/init.ts @@ -1,7 +1,9 @@ // manage 子目录工具注册统一入口(黑名单管理) +import Tool from "../../tool"; + import { registerBlockTool } from "./tool_block"; -/** 注册 manage 下全部管理工具 */ +/** 注册 manage 下全部管理工具(分类:黑名单) */ export function registerManageTools() { - registerBlockTool(); + Tool.withCategory('黑名单', registerBlockTool); } diff --git a/src/tool/tools/memory/init.ts b/src/tool/tools/memory/init.ts index a38601f..1dd0697 100644 --- a/src/tool/tools/memory/init.ts +++ b/src/tool/tools/memory/init.ts @@ -1,9 +1,15 @@ // memory 子目录工具注册统一入口(记忆 + 知识库) -import { registerKnowledgeTools } from "./tool_knowledge"; +import Tool from "../../tool"; + +import { registerKnowledgeTools as registerKnowledgeToolSet } from "./tool_knowledge"; import { registerMemory } from "./tool_memory"; -/** 注册 memory 下全部记忆工具 */ +/** 注册 memory 下全部记忆工具(分类:记忆) */ export function registerMemoryTools() { - registerMemory(); - registerKnowledgeTools(); + Tool.withCategory('记忆', registerMemory); +} + +/** 注册知识库工具(来源分组=知识库,不参与工具组维度,由 .ai kb 管理会话开关) */ +export function registerKnowledgeTools() { + registerKnowledgeToolSet(); } diff --git a/src/tool/tools/memory/tool_knowledge.ts b/src/tool/tools/memory/tool_knowledge.ts index afd9e1b..38049b1 100644 --- a/src/tool/tools/memory/tool_knowledge.ts +++ b/src/tool/tools/memory/tool_knowledge.ts @@ -2,7 +2,7 @@ // 按库过滤:frontmatter platform(平台白名单)+ 会话级 kbState 开关。 import { knowledgeService } from "../../../memory/knowledge"; import { matchesPlatform, platformOf } from "../../../utils/target_id"; -import Tool from "../../tool"; +import Tool, { KNOWLEDGE_GROUP } from "../../tool"; // 单次检索返回条数上限,防止模型请求超大 topK 造成上下文/API 浪费 const KB_SEARCH_TOPK_MAX = 50; @@ -45,7 +45,7 @@ export function registerKnowledgeTools() { required: ['query'] } } - }, false, '知识库'); + }, false, KNOWLEDGE_GROUP); toolSearch.solve = async (ctx, _msg, session, args) => { const query = typeof args.query === 'string' ? args.query : ''; const libraryId = typeof args.library_id === 'string' ? args.library_id : ''; @@ -74,7 +74,7 @@ export function registerKnowledgeTools() { required: ['id'] } } - }, false, '知识库'); + }, false, KNOWLEDGE_GROUP); toolRead.solve = async (ctx, _msg, session, args) => { const id = typeof args.id === 'string' ? args.id : ''; await knowledgeService.init(); @@ -109,7 +109,7 @@ export function registerKnowledgeTools() { required: [] } } - }, false, '知识库'); + }, false, KNOWLEDGE_GROUP); toolList.solve = async (ctx, _msg, session, args) => { await knowledgeService.init(); const { page = 1, page_size = 20, query = '' } = args || {}; @@ -160,7 +160,7 @@ export function registerKnowledgeTools() { required: ['library_id'] } } - }, false, '知识库'); + }, false, KNOWLEDGE_GROUP); toolDocs.solve = async (ctx, _msg, session, args) => { await knowledgeService.init(); const libraryId = typeof args.library_id === 'string' ? args.library_id : ''; diff --git a/src/tool/tools/ob11/init.ts b/src/tool/tools/ob11/init.ts index 9090247..e3a951a 100644 --- a/src/tool/tools/ob11/init.ts +++ b/src/tool/tools/ob11/init.ts @@ -1,8 +1,12 @@ +import Tool from "../../tool"; + import { registerCallOb11Api } from "./tool_call_api"; import { registerResolveSpecialId } from "./tool_resolve_id"; -/** 注册统一的 call_ob11_api 与特殊 ID/句柄解析工具;旧的按 action 工具已经删除。 */ +/** 注册统一的 call_ob11_api 与特殊 ID/句柄解析工具(分类:OB11);旧的按 action 工具已经删除。 */ export function registerOb11Tools() { - registerCallOb11Api(); - registerResolveSpecialId(); + Tool.withCategory('OB11', () => { + registerCallOb11Api(); + registerResolveSpecialId(); + }); } diff --git a/src/tool/tools/pub/init.ts b/src/tool/tools/pub/init.ts index 4d65554..e465d88 100644 --- a/src/tool/tools/pub/init.ts +++ b/src/tool/tools/pub/init.ts @@ -1,7 +1,11 @@ -// pub(公开会话)工具注册统一入口:pub_read / pub_send +// pub(公开会话)工具注册统一入口:pub_read / pub_send(分类:公开会话) +import Tool from "../../tool"; + import { registerPubRead, registerPubSend } from "./tool_pub"; export function registerPubToolSet() { - registerPubRead(); - registerPubSend(); + Tool.withCategory('公开会话', () => { + registerPubRead(); + registerPubSend(); + }); } diff --git a/src/tool/tools/raw/init.ts b/src/tool/tools/raw/init.ts index 7935a72..61d5be0 100644 --- a/src/tool/tools/raw/init.ts +++ b/src/tool/tools/raw/init.ts @@ -1,7 +1,9 @@ // raw 子目录工具注册统一入口(工具原文检索) +import Tool from "../../tool"; + import { registerRawTools } from "./tool_raw"; -/** 注册 raw 下全部工具原文读取工具 */ +/** 注册 raw 下全部工具原文读取工具(分类:原文检索) */ export function registerRawToolSet() { - registerRawTools(); + Tool.withCategory('原文检索', registerRawTools); } diff --git a/src/tool/tools/resource/init.ts b/src/tool/tools/resource/init.ts index d4e91c4..6650f79 100644 --- a/src/tool/tools/resource/init.ts +++ b/src/tool/tools/resource/init.ts @@ -1,12 +1,17 @@ // resource 子目录工具注册统一入口:资源查询与资源生产,不直接注册旧的发送工具。 +import Tool from "../../tool"; + import { registerMusicPlay } from "./tool_music"; import { registerResourceTools as registerResourceList } from "./tool_resource"; import { registerResourcePathTool } from "./tool_resource_path"; import { registerAudioTools } from "./tool_voice"; +/** 注册 resource 下全部资源工具(分类:资源) */ export function registerResourceTools() { - registerResourceList(); - registerResourcePathTool(); - registerAudioTools(); - registerMusicPlay(); + Tool.withCategory('资源', () => { + registerResourceList(); + registerResourcePathTool(); + registerAudioTools(); + registerMusicPlay(); + }); } diff --git a/src/tool/tools/seal/init.ts b/src/tool/tools/seal/init.ts index 53d808e..7ecd245 100644 --- a/src/tool/tools/seal/init.ts +++ b/src/tool/tools/seal/init.ts @@ -1,7 +1,9 @@ // Seal API 工具注册统一入口 +import Tool from "../../tool"; + import { registerAttrSeal } from "./tool_attr"; -/** 注册 Seal API 工具 */ +/** 注册 Seal API 工具(分类:属性) */ export function registerSealTools() { - registerAttrSeal(); + Tool.withCategory('属性', registerAttrSeal); } diff --git a/src/tool/tools/subagent/init.ts b/src/tool/tools/subagent/init.ts index ace1d4a..73923a7 100644 --- a/src/tool/tools/subagent/init.ts +++ b/src/tool/tools/subagent/init.ts @@ -246,8 +246,10 @@ function registerControlTools(): void { } export function registerSubagentTools(): void { - registerDelegate(false); - registerDelegate(true); - registerJobTools(); - registerControlTools(); + Tool.withCategory('子代理', () => { + registerDelegate(false); + registerDelegate(true); + registerJobTools(); + registerControlTools(); + }); } diff --git a/src/tool/tools/web/init.ts b/src/tool/tools/web/init.ts index b8e6f3b..03ae189 100644 --- a/src/tool/tools/web/init.ts +++ b/src/tool/tools/web/init.ts @@ -1,9 +1,13 @@ // web 子目录工具注册统一入口(联网搜索/阅读 + 论坛) +import Tool from "../../tool"; + import { registerForum } from "./tool_forum"; import { registerWeb } from "./tool_web"; -/** 注册 web 下全部联网工具 */ +/** 注册 web 下全部联网工具(分类:网页) */ export function registerWebTools() { - registerWeb(); - registerForum(); + Tool.withCategory('网页', () => { + registerWeb(); + registerForum(); + }); }