diff --git a/package.json b/package.json index 79e3a7ac..b55600e8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.9", + "version": "2026.10.10", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.10.md b/release-notes/2026.10.10.md new file mode 100644 index 00000000..c6a36e2a --- /dev/null +++ b/release-notes/2026.10.10.md @@ -0,0 +1,14 @@ +**Upgrade notes:** +- The bundled core is now 0.66.0, with control-plane protocol 42. A remote server must be upgraded to core 0.66.0 together with the app: this version does not connect to a server on 0.65.0, and 2026.10.9 and earlier do not connect to 0.66.0. +- Request history is kept. +- Models added by hand are now checked when the configuration loads. An entry with `*` or `?`, spaces around it, a duplicate, or more than 256 characters is refused and the gateway starts in safe mode, naming the upstream and the model; the earlier manual model list accepted such entries. Plain model ids load unchanged. + +**Upstreams:** +- Models can be added by hand: **Add models…** in an upstream's model list. A model the upstream serves but leaves out of its list — newer models an account backend hides, a relay that lists only some — is then routed to it like any listed model: routing and failover, aliases, routing rules that name a model, a key's allowed models, the model list clients see, the dry run and the speed test. +- Models added by hand are marked **Manual** and can be removed from the list, with **Undo** in the confirmation. The summary counts them apart from the ones the upstream lists. For an upstream that lists no models, the same dialog replaces the manual model list. + +**Menu bar:** +- The **Menu bar** setting has a fourth choice, **Hidden**, which takes the item off the menu bar. Opening ThinkWatch Lite again from Finder or Spotlight shows the main window; the gateway keeps running in the background. + +**MCP:** +- Editing a client's configuration, such as adding a line to `~/.codex/config.toml`, no longer reports the remote MCP servers and other findings that were already there as new. Only findings that are actually new are reported. diff --git a/scripts/shots/mock/config.ts b/scripts/shots/mock/config.ts index 3f9468fe..b190a543 100644 --- a/scripts/shots/mock/config.ts +++ b/scripts/shots/mock/config.ts @@ -157,10 +157,14 @@ function inScope(p: ProviderView, model: string) { return !p.models_only || p.models_only.some((g) => glob(g, model)); } -/** 这个上游能服务的模型:手动清单,或者列出来的里面在启用范围内的 */ +/** 这个上游的清单:列出来的,后面接上手动添加的(列了的不重复),和 core 一样 */ +function idsOf(p: ProviderView): string[] { + return [...new Set([...(LISTED[p.name] ?? []), ...p.models])]; +} + +/** 这个上游能服务的模型:清单里在启用范围内的 */ export function catalogOf(p: ProviderView): string[] { - if (p.model_source === "manual" || p.models.length > 0) return p.models; - return (LISTED[p.name] ?? []).filter((m) => inScope(p, m)); + return idsOf(p).filter((m) => inScope(p, m)); } /** 上一次去问模型清单是什么时候 */ @@ -168,7 +172,8 @@ const CHECKED = NOW - 2 * HOUR - 17 * MIN; export function providers(): ProviderView[] { return FX.overview.providers.map((p) => { - const manual = p.models.length > 0; + // 上游不给清单时,手动添加的就是全部 + const manual = !LISTED[p.name] && p.models.length > 0; return { ...clone(p), model_source: manual ? "manual" : "discovered", @@ -183,17 +188,19 @@ export function providers(): ProviderView[] { export function providerModels(name: string): ProviderModelsView | null { const p = providers().find((x) => x.name === name); if (!p) return null; - const ids = p.model_source === "manual" ? p.models : (LISTED[name] ?? []); - const rows: ModelRow[] = ids.map((id) => { + const listed = LISTED[name] ?? []; + const rows: ModelRow[] = idsOf(p).map((id) => { const price = priceFor(name, id); return { id, - enabled: p.model_source === "manual" || inScope(p, id), + enabled: inScope(p, id), context_window: FX.prices.find((x) => x.model === id)?.max_input_tokens ?? null, price, price_source: price ? priceSource(name) : null, estimated: false, aliases: [], + manual: p.models.includes(id), + listed: listed.includes(id), }; }); return { diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 8143e94b..a1d78f25 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -122,6 +122,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { DeleteProvider: refuse, ProviderModels: (_req, [name]) => providerModels(name!) ?? notFound(`Upstream ${name}`), SetModelSpec: refuse, + SetManualModels: refuse, RefreshProviderModels: (_req, [name]) => providerModels(name!) ?? notFound(`Upstream ${name}`), RefreshStaleModels: () => ({ providers: [] }), diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index feb2ee4e..bc03c7da 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4766,7 +4766,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.9" +version = "2026.10.10" dependencies = [ "anyhow", "block2", @@ -5268,8 +5268,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "serde", "serde_json", @@ -5280,8 +5280,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "memchr", "serde", @@ -5290,8 +5290,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "base64 0.22.1", "bytes", @@ -5307,8 +5307,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "serde", "serde_json", @@ -5334,8 +5334,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "serde", "ts-rs", @@ -5343,8 +5343,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "libc", "notify", @@ -5354,8 +5354,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.65.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.65.0#2f1abbe9af75fd8caf3971b9bd954284f14d182a" +version = "0.66.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.66.0#476cfafce2c8c519c5230ee8f4c1b3c5b938650c" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8611162c..6e86d09d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.9" +version = "2026.10.10" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.65.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.66.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 7befdab4..dc1062a4 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -99,6 +99,8 @@ webview_endpoints![ ProviderModels, // 手写一个模型的上下文窗口、输出上限 SetModelSpec, + // 手动添加的模型:上游能服务、却没列进清单的 + SetManualModels, RefreshProviderModels, RefreshStaleModels, CreateProxy, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 09ecbc2a..0e65da91 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.9", + "version": "2026.10.10", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/control.ts b/src/control.ts index 935cb219..db7af63b 100644 --- a/src/control.ts +++ b/src/control.ts @@ -60,6 +60,7 @@ export const WEBVIEW_ENDPOINTS = [ "DeleteProvider", "ProviderModels", "SetModelSpec", + "SetManualModels", "RefreshProviderModels", "RefreshStaleModels", "CreateProxy", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 4aaa1045..da78ce8e 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 41; +export const CONTROL_API_VERSION = 42; /** * 一个账号上游登的是哪个账号。 @@ -2225,6 +2225,19 @@ on_error: OnError, reply_mode: ReplyMode, */ settings_schema: Array, hooks: PluginHooks, }; +/** + * 设一家上游手动添加的模型(`PUT /provider-manual-models`):上游能服务、却没列进 + * 清单的模型。**交的是整份清单**,按给的顺序换掉原来的;空的就是一个都不留。 + * + * 每一项去掉首尾空白之后是一个确切的模型 ID:不空、不带 `*` `?`、不超过 256 个字符, + * 而且不重复(`config.manual_model_*`)。上游也列了的模型可以加,不算重复。 + */ +export type ManualModelsSave = { provider: string, models: Array, +/** + * 你基于哪一版。**对不上就是 409** + */ +base_version?: string | null, }; + /** * 一条规则按什么认。**给界面说明用**,界面按类型写成自己的话。 */ @@ -2365,7 +2378,17 @@ estimated: boolean, * 列出了这个模型名的别名,按别名表的顺序。**不论这家发不发它**:别名在这家按列表 * 顺序取它有的第一个,排在后面的名字也算列进了这个别名 */ -aliases: Array, }; +aliases: Array, +/** + * 手动添加的(配置里这一家的 `models`)。和上游列出的一样算这家提供;删得掉的只有 + * 这些(`PUT /provider-manual-models`) + */ +manual: boolean, +/** + * 上游自己的清单里有它。手动添加的模型上游也列了时两项都是 true;上游没给出清单时 + * 一律是 false + */ +listed: boolean, }; /** * 一个上游的模型清单从哪儿来。 @@ -3070,7 +3093,9 @@ proxy: string, */ on_proxy_fail: OnProxyFail, /** - * 服务不提供模型列表时的手动清单 + * 手动添加的模型:和上游列出的一起算这家提供(上游不提供清单时就是全部)。 + * **编辑对话框保存时原样交回**,不然手动添加的模型会被清掉;单改这份清单用 + * `PUT /provider-manual-models`([`ManualModelsSave`]) */ models?: Array, /** @@ -3099,7 +3124,8 @@ disabled: boolean, }; */ export type ProviderModelsView = { provider: string, /** - * `discovered`(上游列出的)/ `manual`(手动清单)/ `none` + * `discovered`(上游列出的,手动添加的接在后面)/ `manual`(上游没给出清单,只有 + * 手动添加的)/ `none` */ source: ModelSource, /** @@ -3239,7 +3265,8 @@ proxy: string, */ on_proxy_fail: OnProxyFail, /** - * 服务不提供模型列表时用的手动清单 + * 手动添加的模型,按写的顺序:和上游列出的一起算这家提供(上游不提供清单时就是 + * 全部) */ models: Array, /** @@ -3247,8 +3274,8 @@ models: Array, */ models_only?: Array | null, /** - * 模型清单从哪儿来:`discovered`(上游列出的)/ `manual`(手动清单)/ - * `none`(不知道它有什么) + * 模型清单从哪儿来:`discovered`(上游列出的,手动添加的接在后面)/ `manual`(上游 + * 没给出清单,只有手动添加的)/ `none`(不知道它有什么) */ model_source: ModelSource, /** @@ -4932,6 +4959,7 @@ export const ENDPOINTS = { DeleteProvider: { method: "DELETE", path: "/providers/{name}", params: ["name"], format: "json" }, ProviderModels: { method: "GET", path: "/providers/{name}/models", params: ["name"], format: "json" }, SetModelSpec: { method: "PUT", path: "/provider-model-spec", params: [], format: "json" }, + SetManualModels: { method: "PUT", path: "/provider-manual-models", params: [], format: "json" }, RefreshProviderModels: { method: "POST", path: "/providers/{name}/models/refresh", params: ["name"], format: "json" }, RefreshStaleModels: { method: "POST", path: "/models/refresh", params: [], format: "json" }, CreateProxy: { method: "POST", path: "/proxies", params: [], format: "json" }, @@ -5054,6 +5082,7 @@ export type Endpoints = { DeleteProvider: { req: BaseVersion; res: ConfigWritten }; ProviderModels: { req: null; res: ProviderModelsView }; SetModelSpec: { req: ModelSpecSave; res: ConfigWritten }; + SetManualModels: { req: ManualModelsSave; res: ConfigWritten }; RefreshProviderModels: { req: null; res: ProviderModelsView }; RefreshStaleModels: { req: null; res: ModelsRefreshing }; CreateProxy: { req: ProxySave; res: ConfigWritten }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 0d4f2696..a372f0a3 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -652,6 +652,11 @@ "config.alias_blank_model": "别名「{alias}」列出的模型中有空白项。", "config.alias_chained": "别名「{alias}」列出的 {model} 本身也是别名。别名列出的应是上游使用的模型名称,不能是其他别名。", "config.alias_only_itself": "别名「{alias}」只列出了它自己,不起任何作用。请列出各上游使用的名称,或删除该别名。", + "config.manual_model_blank": "上游「{upstream}」手动添加的模型(models)中有空白项。", + "config.manual_model_padded": "上游「{upstream}」手动添加的模型「{model}」首尾有空白,请去掉。", + "config.manual_model_wildcard": "上游「{upstream}」手动添加的模型「{model}」含有 * 或 ?。手动添加的模型须为确切的模型 ID;只使用上游的部分模型,请设置启用范围(models_only)。", + "config.manual_model_duplicate": "模型「{model}」在上游「{upstream}」手动添加的模型中重复。", + "config.manual_model_too_long": "上游「{upstream}」手动添加的模型中有一项超过 {max} 个字符。", "config.model_spec_blank_model": "上游「{upstream}」的模型规格(model_specs)中有一项的模型 ID 为空。", "config.model_spec_wildcard": "上游「{upstream}」的模型规格「{model}」含有 * 或 ?。模型规格只能对应一个确切的模型 ID。", "config.model_spec_nothing_set": "上游「{upstream}」的模型规格「{model}」中 context_window、max_output_tokens、reasoning、image_input 均未设置。请至少设置一项,或删除该规格。", diff --git a/src/import/ImportDialog.i18n.ts b/src/import/ImportDialog.i18n.ts index 6cfbaa1f..e27b8237 100644 --- a/src/import/ImportDialog.i18n.ts +++ b/src/import/ImportDialog.i18n.ts @@ -12,7 +12,7 @@ export const importDialogText = messages( auto: "自动识别", key: "API 密钥", noKey: "未提供", - models: "手动模型清单", + models: "手动添加的模型", create: "创建", created: (name: string) => `已创建上游「${name}」`, }, @@ -27,7 +27,7 @@ export const importDialogText = messages( auto: "Auto-detect", key: "API key", noKey: "Not provided", - models: "Manual model list", + models: "Models added by hand", create: "Create", created: (name: string) => `Upstream “${name}” created`, }, diff --git a/src/upstreams/ManualModelsDialog.i18n.ts b/src/upstreams/ManualModelsDialog.i18n.ts new file mode 100644 index 00000000..bd833c11 --- /dev/null +++ b/src/upstreams/ManualModelsDialog.i18n.ts @@ -0,0 +1,39 @@ +import { messages } from "@/i18n"; + +export const manualModelsDialogText = messages( + { + title: "添加模型", + desc: "添加上游能服务、但未列在其模型列表中的模型。", + models: "模型 ID", + placeholder: "例如 gpt-6-luna", + enterToAdd: "回车添加", + hint: "可添加多个,每项为一个确切的模型 ID。", + remove: (id: string) => `移除 ${id}`, + removeShort: "移除", + outOfScope: "不在启用范围内", + blank: "模型 ID 不能为空。", + wildcard: (id: string) => `「${id}」含有 * 或 ?。手动添加的模型须为确切的模型 ID,不支持通配。`, + tooLong: (max: number) => `模型 ID 最多 ${max} 个字符。`, + duplicate: (id: string) => `「${id}」已在待添加的模型中。`, + added: (id: string) => `「${id}」已经手动添加过。`, + listed: (id: string) => `上游的模型列表中已有「${id}」,无需添加。`, + }, + { + title: "Add models", + desc: "Add models the upstream serves but leaves out of its model list.", + models: "Model IDs", + placeholder: "e.g. gpt-6-luna", + enterToAdd: "Enter to add", + hint: "Add as many as needed, each an exact model ID.", + remove: (id: string) => `Remove ${id}`, + removeShort: "Remove", + outOfScope: "Not enabled", + blank: "A model ID cannot be empty.", + wildcard: (id: string) => + `“${id}” contains * or ?. A model added by hand is an exact model ID; wildcards are not supported.`, + tooLong: (max: number) => `A model ID is at most ${max} characters.`, + duplicate: (id: string) => `“${id}” is already in the list to add.`, + added: (id: string) => `“${id}” has already been added by hand.`, + listed: (id: string) => `The upstream's model list already has “${id}”; there is no need to add it.`, + }, +); diff --git a/src/upstreams/ManualModelsDialog.tsx b/src/upstreams/ManualModelsDialog.tsx new file mode 100644 index 00000000..872698cb --- /dev/null +++ b/src/upstreams/ManualModelsDialog.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { invalidate } from "@/lib/resource"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import { Input } from "@/ui/input"; +import { rowMotion, usePresentList } from "@/ui/motion"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import type { ProviderView } from "@/types"; +import { api } from "./api"; +import { modelsKey } from "./data"; +import { globMatch } from "./glob"; +import { errorText } from "./labels"; +import { MANUAL_MODEL_MAX, addIds, type ManualProblem } from "./manualModels"; +import { manualModelsDialogText } from "./ManualModelsDialog.i18n"; +import { Boxed, DialogError } from "./parts"; + +/** + * 给一家上游手动添加模型(`PUT /provider-manual-models`):上游能服务、却没列进模型列表的 + * 模型。添加之后它们和列出的模型一样出现在客户端的模型列表里、路由到这家。 + * + * 名单的写法和放行网段、插件范围一样:一条一行,最后一行输入,右边写「回车添加」,一次 + * 粘贴几个按空白和逗号拆开;写错的、重复的、已经加过的、上游已经列出的当场说,留在输入框里。 + * 失焦也算添加。**交给 core 的是整份手动清单**:原来的接上这次添加的。 + * + * 挂载方:上游表「模型」一格的弹窗(「添加模型…」)。对话框挂在弹窗外面:弹窗一收起, + * 里面的东西就卸掉了。 + */ +export function ManualModelsDialog({ + open, + onOpenChange, + p, + listed, + configVersion, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + p: ProviderView; + /** 上游自己列出的模型。加它们不改变什么,当场说 */ + listed: readonly string[]; + /** 概览里的配置版本。**只取打开那一刻的**,见 `Body` 的 `base` */ + configVersion: string; + onSaved?: () => void; +}) { + return ( + + {/* 点到对话框外面不关:填了一半的名单不该因为一次误点丢掉。Esc、×、取消照常 */} + e.preventDefault()}> + {/* 内容只在开着时挂上:每次打开都从空的名单起 */} + {open && ( + onOpenChange(false)} + onSaved={onSaved} + /> + )} + + + ); +} + +function Body({ + p, + listed, + configVersion, + onClose, + onSaved, +}: { + p: ProviderView; + listed: readonly string[]; + configVersion: string; + onClose: () => void; + onSaved?: () => void; +}) { + const t = useText(manualModelsDialogText); + const c = useText(commonText); + const [drafts, setDrafts] = useState([]); + const [input, setInput] = useState(""); + const [problem, setProblem] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const rows = usePresentList(drafts, (x) => x); + /* + **打开时的版本号,保存时带它。**交的是原来的手动清单接上这次的:开着的时候别处改了这份 + 清单,带保存那一刻的版本号,core 的冲突检查永远通过,别人的改动就被盖掉了 + */ + const [base] = useState(configVersion); + + /** 把输入框里的加进名单,交回加完的名单;有毛病时为 null(毛病留在输入框里说) */ + function commit(raw: string, quiet = false): string[] | null { + if (raw.trim() === "") { + if (!quiet) setProblem({ kind: "blank" }); + return quiet ? drafts : null; + } + const r = addIds(raw, { drafts, manual: p.models, listed }); + setDrafts(r.drafts); + setInput(r.rest); + setProblem(r.problem); + return r.problem ? null : r.drafts; + } + + async function save() { + // 输入框里还有没按回车的:先加上,写错了就不存 + const next = input.trim() === "" ? drafts : commit(input); + if (!next || next.length === 0) return; + setSaving(true); + setError(null); + try { + await api.setManualModels({ provider: p.name, models: [...p.models, ...next], base_version: base }); + // 用到这家清单的几处:模型弹窗、路由里指定的模型、测速、价目表,别名和模型目录 + invalidate(modelsKey(p.name)); + invalidate("aliases"); + invalidate("known-models"); + onSaved?.(); + onClose(); + } catch (e) { + setError(errorText(e)); + setSaving(false); + } + } + + const inScope = (id: string) => !p.models_only || p.models_only.some((g) => globMatch(g, id)); + const message = problem ? problemText(problem, t) : null; + + return ( +
{ + e.preventDefault(); + if (!saving) void save(); + }} + > + + {t.title} + {p.name} + + +

{t.desc}

+ +
+ + {rows.map(({ item: id, key, presence }) => ( +
+ + {id} + + {!inScope(id) && {t.outOfScope}} + +
+ ))} +
+ { + setInput(e.target.value); + setProblem(null); + }} + onKeyDown={(e) => { + if (e.key !== "Enter" || e.nativeEvent.isComposing) return; + // 回车是添加这一条,不是提交对话框 + e.preventDefault(); + commit(input); + }} + // 输了没按回车就去点别处:这一条也算上,不让它悄悄丢掉 + onBlur={() => commit(input, true)} + /> + {t.enterToAdd} +
+
+

{message ?? t.hint}

+
+ + + + + + + + + ); +} + +function problemText(problem: ManualProblem, t: (typeof manualModelsDialogText)["zh"]): string { + switch (problem.kind) { + case "blank": + return t.blank; + case "wildcard": + return t.wildcard(problem.id); + case "tooLong": + return t.tooLong(MANUAL_MODEL_MAX); + case "duplicate": + return t.duplicate(problem.id); + case "added": + return t.added(problem.id); + case "listed": + return t.listed(problem.id); + } +} diff --git a/src/upstreams/ModelsPanel.i18n.ts b/src/upstreams/ModelsPanel.i18n.ts index 7bc7c13f..f61e623e 100644 --- a/src/upstreams/ModelsPanel.i18n.ts +++ b/src/upstreams/ModelsPanel.i18n.ts @@ -8,20 +8,21 @@ export const modelsPanelText = messages( failed: "获取失败", unreachable: "未能连接上游。", editUpstream: "编辑上游…", - usingManual: (n: number) => `暂用手动清单中的 ${n} 个模型。`, - noList: (reason: string) => `${reason.replace(/[。.]$/, "")}。可填写手动清单,列出此上游提供的模型。`, + usingManual: (n: number) => `暂用手动添加的 ${n} 个模型。`, + noList: (reason: string) => `${reason.replace(/[。.]$/, "")}。可手动添加此上游提供的模型。`, noListReason: "上游未提供模型列表", - fillManual: "填写手动清单…", + addModels: "添加模型…", filter: "筛选模型", notEnabled: (n: number) => `不在启用范围内 ${n} 个`, noMatch: "无匹配的模型", - editManual: "编辑手动清单与启用范围…", editScope: "调整启用范围…", countAll: (total: number) => `${total} 个`, countSome: (total: number, enabled: number) => `${total} 个,已启用 ${enabled} 个`, listed: (count: string) => `上游列出 ${count}`, listedAt: (count: string, time: string) => `上游列出 ${count} · ${time} 获取`, - manual: (count: string) => `手动清单 ${count}`, + listedAndAdded: (listed: number, added: number) => `上游列出 ${listed} 个,手动添加 ${added} 个`, + fetchedAt: (what: string, time: string) => `${what} · ${time} 获取`, + manual: (count: string) => `手动添加 ${count}`, lastTry: (time: string) => `最近一次尝试 ${time}`, notFetched: "尚未获取", estimated: "(估)", @@ -32,6 +33,12 @@ export const modelsPanelText = messages( makeAlias: "起别名…", specs: "规格…", manualSpecs: "手动规格", + manualTag: "手动", + manualOnlyTitle: "手动添加的模型,上游的模型列表中没有", + manualListedTitle: "手动添加的模型,上游的模型列表中也有", + remove: "移除", + removeLabel: (id: string) => `移除手动添加的模型 ${id}`, + removed: (id: string) => `已移除手动添加的模型 ${id}`, manualContext: (n: string) => `上下文窗口 ${n}`, manualOutput: (n: string) => `输出上限 ${n}`, manualReasoning: (on: boolean): string => (on ? "支持推理" : "不支持推理"), @@ -47,23 +54,22 @@ export const modelsPanelText = messages( unreachable: "Could not reach the upstream.", editUpstream: "Edit upstream…", usingManual: (n: number) => - n === 1 - ? "Using the 1 model in the manual list for now." - : `Using the ${n} models in the manual list for now.`, - noList: (reason: string) => `${reason.replace(/[。.]$/, "")}. A manual list can name the models this upstream serves.`, + n === 1 ? "Using the 1 model added by hand for now." : `Using the ${n} models added by hand for now.`, + noList: (reason: string) => `${reason.replace(/[。.]$/, "")}. The models this upstream serves can be added by hand.`, noListReason: "The upstream does not provide a model list", - fillManual: "Enter a manual list…", + addModels: "Add models…", filter: "Filter models", notEnabled: (n: number) => `${n} not enabled`, noMatch: "No matching models", - editManual: "Edit manual list and enabled models…", editScope: "Adjust enabled models…", countAll: (total: number) => (total === 1 ? "1 model" : `${total} models`), countSome: (total: number, enabled: number) => `${total === 1 ? "1 model" : `${total} models`}, ${enabled} enabled`, listed: (count: string) => `Listed by the upstream: ${count}`, listedAt: (count: string, time: string) => `Listed by the upstream: ${count} · fetched at ${time}`, - manual: (count: string) => `Manual list: ${count}`, + listedAndAdded: (listed: number, added: number) => `${listed} listed by the upstream, ${added} added by hand`, + fetchedAt: (what: string, time: string) => `${what} · fetched at ${time}`, + manual: (count: string) => `Added by hand: ${count}`, lastTry: (time: string) => `Last attempt at ${time}`, notFetched: "Not fetched yet", estimated: " (est.)", @@ -74,6 +80,12 @@ export const modelsPanelText = messages( makeAlias: "Add alias…", specs: "Specs…", manualSpecs: "Manual specs", + manualTag: "Manual", + manualOnlyTitle: "Added by hand; not in the upstream's model list", + manualListedTitle: "Added by hand; also in the upstream's model list", + remove: "Remove", + removeLabel: (id: string) => `Remove ${id}, added by hand`, + removed: (id: string) => `Removed ${id}, added by hand`, manualContext: (n: string) => `context window ${n}`, manualOutput: (n: string) => `max output ${n}`, manualReasoning: (on: boolean): string => (on ? "reasoning" : "no reasoning"), diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index fd363e18..6f81a22f 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -3,18 +3,19 @@ import { ChevronRightIcon, CircleAlertIcon, RefreshCwIcon, SearchIcon } from "lu import { AliasMark } from "@/aliases/AliasMark"; import { cn } from "@/lib/utils"; import { compact, whenMinute } from "@/format"; -import { useResource } from "@/lib/resource"; +import { invalidate, useResource } from "@/lib/resource"; import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { Skeleton } from "@/ui/skeleton"; import { Spinner } from "@/ui/spinner"; import { StatusLabel } from "@/ui/status-dot"; +import { undoable } from "@/ui/notify"; import { textOf, useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import type { ModelRow, ProviderModelsView, ProviderView } from "@/types"; import { api } from "./api"; -import { modelsKey } from "./data"; +import { modelsKey, patch } from "./data"; import { coreText, errorText, perMillion } from "./labels"; import { hasManual } from "./modelSpec"; import { modelsPanelText } from "./ModelsPanel.i18n"; @@ -26,8 +27,10 @@ const FILTER_FROM = 10; * 点开「模型」一格看到的东西:这一家有哪些模型,是怎么知道的,没拿到的话 * 原因和下一步。 * - * **只读,改动在编辑对话框里做** —— 启用范围、手动清单都要一次保存一个 - * 配置版本,和上游其余设置走同一条路。这里给的是去那儿的入口。 + * **启用范围在编辑对话框里改**,和上游其余设置走同一条路,这里给的是去那儿的入口。 + * 手动添加的模型(上游能服务、却没列进清单的)在这里加、在这里移除:「添加模型…」 + * 打开添加的对话框(`onAdd`),手动添加的那几行标「手动」,悬停给「移除」—— 可撤销, + * 不弹确认。上游也列了的手动模型照样标着、照样能移除:手动清单里有什么,这里一样不藏。 * * 清单按上游缓存(`upstream-models:<名字>`):再点开一次先画上一次的,后台再读。 * 概览里这一家的获取状态一变(开始问了、问完了)就重读 —— 开着面板等它问完, @@ -42,19 +45,28 @@ const FILTER_FROM = 10; export function ModelsPanel({ p, perToken, + configVersion, onEdit, + onAdd, onAlias, onSpec, + onChanged, }: { p: ProviderView; /** 按量计费:列出单价。别的计费方式不按单价算费用,列了也没意义 */ perToken: boolean; + /** 概览里的配置版本:移除手动添加的模型时带它 */ + configVersion: string; /** 打开编辑对话框的「模型」一节 */ onEdit: () => void; + /** 打开添加模型的对话框。`listed` 是上游自己列出的那些 */ + onAdd: (listed: string[]) => void; /** 给这个模型起别名。不给就不出「起别名…」 */ onAlias?: (model: string) => void; /** 手写这个模型的规格。不给就不出「规格…」 */ onSpec?: (model: ModelRow) => void; + /** 改了配置之后(移除了手动添加的模型):让概览重读 */ + onChanged?: () => void; }) { const t = useText(modelsPanelText); const c = useText(commonText); @@ -93,6 +105,42 @@ export function ModelsPanel({ const error = view?.error ?? p.model_error; const why = error ? coreText(error) : null; const readError = refreshError ?? (r.error !== undefined && !view ? errorText(r.error) : null); + const add = () => onAdd(models.filter((m) => m.listed).map((m) => m.id)); + + /** + * 从手动添加的模型里移除一个。**先改界面**:上游没列它的,这一行马上没了;列了的,只是 + * 「手动」标记没了。撤销就是照移除之前的整份清单写回去,带移除写出的那一版 —— 这期间 + * 别处又改了配置,撤销被拒,而不是把别人的改动盖掉。 + */ + async function remove(m: ModelRow) { + const before = p.models; + let written: string | undefined; + await undoable({ + message: t.removed(m.id), + apply: () => + r.mutate( + patch((v) => ({ + ...v, + models: v.models.flatMap((x) => (x.id !== m.id ? [x] : x.listed ? [{ ...x, manual: false }] : [])), + })), + ), + do: async () => { + const w = await api.setManualModels({ + provider: p.name, + models: before.filter((x) => x !== m.id), + base_version: configVersion, + }); + written = w.version; + }, + undo: () => api.setManualModels({ provider: p.name, models: before, base_version: written }), + after: () => { + invalidate(modelsKey(p.name)); + invalidate("aliases"); + invalidate("known-models"); + onChanged?.(); + }, + }); + } return (
@@ -158,8 +206,8 @@ export function ModelsPanel({ text={t.noList(why ?? t.noListReason)} muted action={ - } /> @@ -183,7 +231,14 @@ export function ModelsPanel({ )}
{shownOn.map((m) => ( - + void remove(row)} + /> ))} {off.length > 0 && ( <> @@ -203,16 +258,28 @@ export function ModelsPanel({ {t.notEnabled(off.length)} {(showOff || q !== "") && - shownOff.map((m) => )} + shownOff.map((m) => ( + void remove(row)} + /> + ))} )} {q !== "" && shownOn.length + shownOff.length === 0 && (

{t.noMatch}

)}
-
- +
@@ -223,11 +290,19 @@ export function ModelsPanel({ ); } -/** 标题下那一行:多少个、从哪儿来、什么时候问的 */ +/** + * 标题下那一行:多少个、从哪儿来、什么时候问的。上游列出的清单里接了手动添加的,两个数 + * 分开说 —— 「上游列出 5 个」里有两个其实是手动添加的,就不对了。 + */ function summary(source: string, total: number, enabled: number, view: ProviderModelsView): string { const t = textOf(modelsPanelText); const count = enabled === total ? t.countAll(total) : t.countSome(total, enabled); if (source === "discovered") { + const added = view.models.filter((m) => m.manual && !m.listed).length; + if (added > 0) { + const both = t.listedAndAdded(total - added, added); + return view.checked_at_ms ? t.fetchedAt(both, whenMinute(view.checked_at_ms)) : both; + } return view.checked_at_ms ? t.listedAt(count, whenMinute(view.checked_at_ms)) : t.listed(count); } if (source === "manual") return t.manual(count); @@ -240,18 +315,22 @@ function Row({ perToken, onAlias, onSpec, + onRemove, }: { m: ModelRow; perToken: boolean; onAlias?: (model: string) => void; onSpec?: (model: ModelRow) => void; + /** 从手动添加的模型里移除。只给手动添加的那几行 */ + onRemove?: (model: ModelRow) => void; }) { const t = useText(modelsPanelText); const price = perToken && m.price ? `$${perMillion(m.price.input)} / $${perMillion(m.price.output)}${m.estimated ? t.estimated : ""}` : null; - const actions = onAlias || onSpec; + const removable = m.manual && onRemove; + const actions = onAlias || onSpec || removable; /** 手写了哪几项:「上下文窗口 128k」「输出上限 16k」「支持推理」「不支持图片输入」 */ const manual = [ m.context_window_source === "manual" && m.context_window ? t.manualContext(compact(m.context_window)) : null, @@ -282,7 +361,16 @@ function Row({ {t.aliasMark(a)} ))} - {/* 放在名字这一边,不放在数旁边:悬停时右边让给按钮,这个标记和它的说明还看得见 */} + {/* 放在名字这一边,不放在数旁边:悬停时右边让给按钮,这些标记和它们的说明还看得见 */} + {m.manual && ( + + {t.manualTag} + + )} {hasManual(m) && ( )} + {removable && ( + + )} )}
diff --git a/src/upstreams/ModelsSection.i18n.ts b/src/upstreams/ModelsSection.i18n.ts index 039c31dc..e92d1a80 100644 --- a/src/upstreams/ModelsSection.i18n.ts +++ b/src/upstreams/ModelsSection.i18n.ts @@ -8,7 +8,7 @@ export const modelsSectionText = messages( refresh: "刷新模型列表", fetching: "正在获取模型列表", notFetched: "尚未获取模型列表。", - manual: "手动清单", + manual: "手动添加的模型", manualDesc: (error?: string | null) => `${error ? `${error.replace(/[。.]$/, "")}。` : "未获取到模型列表。"}每行填写一个模型 ID,这些模型会出现在客户端的模型列表中。`, scope: "启用范围", @@ -35,7 +35,7 @@ export const modelsSectionText = messages( refresh: "Refresh model list", fetching: "Fetching the model list", notFetched: "The model list has not been fetched.", - manual: "Manual list", + manual: "Models added by hand", manualDesc: (error?: string | null) => `${error ? `${error.replace(/[。.]$/, "")}.` : "No model list was fetched."} One model ID per line; these models appear in the model lists clients see.`, scope: "Enabled models", diff --git a/src/upstreams/TestDialogs.i18n.ts b/src/upstreams/TestDialogs.i18n.ts index b198ebec..d071daa2 100644 --- a/src/upstreams/TestDialogs.i18n.ts +++ b/src/upstreams/TestDialogs.i18n.ts @@ -24,7 +24,7 @@ export const testDialogsText = messages( desc: "向所选上游各发送一次推理请求,测量首 token 时间。此操作按量计费。", model: "模型", loadingModels: "正在读取模型列表", - noModels: "尚无可用模型。先为上游获取模型列表或填写手动清单。", + noModels: "尚无可用模型。先为上游获取模型列表或手动添加模型。", upstreams: "上游", estimate: "费用预估", quoting: "计算中", @@ -68,7 +68,7 @@ export const testDialogsText = messages( desc: "Sends one inference request to each selected upstream and measures the time to first token. This test is billed per token.", model: "Model", loadingModels: "Loading model list", - noModels: "No models available yet. Fetch an upstream's model list or enter a manual list first.", + noModels: "No models available yet. Fetch an upstream's model list or add models by hand first.", upstreams: "Upstreams", estimate: "Cost estimate", quoting: "Calculating", diff --git a/src/upstreams/UpstreamTable.tsx b/src/upstreams/UpstreamTable.tsx index bf3e2ff7..6b7b2662 100644 --- a/src/upstreams/UpstreamTable.tsx +++ b/src/upstreams/UpstreamTable.tsx @@ -33,6 +33,7 @@ import { import { labelsText } from "./labels.i18n"; import { ModelsPanel } from "./ModelsPanel"; import { ModelSpecDialog } from "./ModelSpecDialog"; +import { ManualModelsDialog } from "./ManualModelsDialog"; import { AliasDialog } from "@/aliases/AliasDialog"; import { ProviderTile, keepInRow, openRow } from "./parts"; import { QUOTA_FULL, QuotaBar } from "./QuotaBar"; @@ -46,7 +47,7 @@ export interface UpstreamActions { linkTest: (name: string) => void; speedTest: (name: string) => void; refreshModels: (name: string) => void; - /** 打开编辑对话框的「模型」一节:启用范围、手动清单 */ + /** 打开编辑对话框的「模型」一节:启用范围、手动添加的模型 */ editModels: (name: string) => void; /** ChatGPT 账号上游:编辑对话框的「账号」一节(额度与重置卡) */ account: (name: string) => void; @@ -417,6 +418,8 @@ function ModelsCell({ const [aliasFor, setAliasFor] = useState(null); /** 「规格…」点的那一行,同上 */ const [specFor, setSpecFor] = useState(null); + /** 「添加模型…」:上游自己列出的那些(加它们不改变什么),同上。没打开是 null */ + const [addFor, setAddFor] = useState(null); if (p.disabled) { return —; } @@ -447,10 +450,16 @@ function ModelsCell({ { setOpen(false); onEdit(); }} + onAdd={(listed) => { + setOpen(false); + setAddFor(listed); + }} + onChanged={onChanged} onAlias={(model) => { setOpen(false); setAliasFor(model); @@ -462,6 +471,14 @@ function ModelsCell({ /> + !o && setAddFor(null)} + p={p} + listed={addFor ?? []} + configVersion={configVersion} + onSaved={onChanged} + /> !o && setSpecFor(null)} diff --git a/src/upstreams/api.ts b/src/upstreams/api.ts index 4948b61b..23bef4e6 100644 --- a/src/upstreams/api.ts +++ b/src/upstreams/api.ts @@ -14,6 +14,7 @@ import type { CostBucketGroup, CostGroup, LatencyView, + ManualModelsSave, ModelSpecSave, TokenRateView, PriceQuery, @@ -58,6 +59,8 @@ export const api = { refreshProviderModels: (name: string) => call("RefreshProviderModels", null, name), /** 手写一个模型的上下文窗口、输出上限、推理、图片输入。四项都空 = 删掉手写的,回到价目表 */ setModelSpec: (save: ModelSpecSave) => call("SetModelSpec", save), + /** 换掉一家上游手动添加的模型:交整份清单,空的就是一个都不留 */ + setManualModels: (save: ManualModelsSave) => call("SetManualModels", save), /** 补问缺失、失败、过期的清单。**立刻回**,答案随 `models_changed` 到 */ refreshStaleModels: () => call("RefreshStaleModels", null), /** 起点和格宽都由界面给:格子对齐到本地整点(见 `bucketStart`) */ diff --git a/src/upstreams/labels.i18n.ts b/src/upstreams/labels.i18n.ts index ff0df44b..7c9e2ae3 100644 --- a/src/upstreams/labels.i18n.ts +++ b/src/upstreams/labels.i18n.ts @@ -20,7 +20,7 @@ export const labelsText = messages( accessKeys: "访问密钥", models: { discovered: "自动发现", - manual: "手动清单", + manual: "手动添加", failed: "获取失败", noList: "未提供清单", notFetched: "未获取", @@ -102,7 +102,7 @@ export const labelsText = messages( accessKeys: "Access keys", models: { discovered: "Discovered", - manual: "Manual list", + manual: "Added by hand", failed: "Fetch failed", noList: "No list provided", notFetched: "Not fetched", diff --git a/src/upstreams/labels.test.ts b/src/upstreams/labels.test.ts index 5594224a..0cfb51a6 100644 --- a/src/upstreams/labels.test.ts +++ b/src/upstreams/labels.test.ts @@ -63,7 +63,7 @@ describe("modelFace", () => { it("keeps showing a manual list that stands in for a failed or missing one", () => { const face = modelFace(p({ model_source: "manual", model_status: "failed", model_count: 2 })); - expect(face).toEqual({ count: 2, note: "手动清单", warn: false }); + expect(face).toEqual({ count: 2, note: "手动添加", warn: false }); }); }); diff --git a/src/upstreams/manualModels.test.ts b/src/upstreams/manualModels.test.ts new file mode 100644 index 00000000..12f3fa85 --- /dev/null +++ b/src/upstreams/manualModels.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { MANUAL_MODEL_MAX, addIds, splitIds } from "./manualModels"; + +const none = { drafts: [], manual: [], listed: [] }; + +describe("手动添加的模型", () => { + it("一次粘贴几个:按空白和逗号拆开", () => { + expect(splitIds(" gpt-6-luna, gpt-5\nclaude-fable-5,o5 ")).toEqual(["gpt-6-luna", "gpt-5", "claude-fable-5", "o5"]); + expect(splitIds(" ")).toEqual([]); + }); + + it("写对的加进名单,有毛病的留在输入框里、说第一个毛病", () => { + const r = addIds("gpt-6-luna gpt-* gpt-5", { drafts: ["o5"], manual: [], listed: [] }); + expect(r.drafts).toEqual(["o5", "gpt-6-luna", "gpt-5"]); + expect(r.rest).toBe("gpt-*"); + expect(r.problem).toEqual({ kind: "wildcard", id: "gpt-*" }); + }); + + it("空的、重复的、加过的、上游已列出的、太长的都当场说", () => { + expect(addIds(" ", none).problem).toEqual({ kind: "blank" }); + expect(addIds("a", { ...none, drafts: ["a"] }).problem).toEqual({ kind: "duplicate", id: "a" }); + expect(addIds("a a", none)).toEqual({ drafts: ["a"], rest: "a", problem: { kind: "duplicate", id: "a" } }); + expect(addIds("a", { ...none, manual: ["a"] }).problem).toEqual({ kind: "added", id: "a" }); + expect(addIds("a", { ...none, listed: ["a"] }).problem).toEqual({ kind: "listed", id: "a" }); + const long = "m".repeat(MANUAL_MODEL_MAX + 1); + expect(addIds(long, none).problem).toEqual({ kind: "tooLong", id: long }); + expect(addIds("m".repeat(MANUAL_MODEL_MAX), none).problem).toBeNull(); + }); +}); diff --git a/src/upstreams/manualModels.ts b/src/upstreams/manualModels.ts new file mode 100644 index 00000000..ebfd4014 --- /dev/null +++ b/src/upstreams/manualModels.ts @@ -0,0 +1,73 @@ +/** + * 手动添加的模型:上游能服务、却没有列进模型列表的模型(配置里这一家的 `models`)。 + * core 把它们和上游列出的合在一起,一样出现在客户端的模型列表里、一样路由到这家。 + * + * 这里是添加对话框当场校验的那几条,和 core 的 `check_manual_models` 是同一套: + * 不空、不带通配、不超过 `MANUAL_MODEL_MAX` 个字符、不重复。另外两条是界面自己的: + * 已经手动添加过的、上游已经列出的,加了也不改变什么,当场说。 + */ + +/** 一个模型 ID 最多多少个字符,和 core 的 `MANUAL_MODEL_MAX` 一致 */ +export const MANUAL_MODEL_MAX = 256; + +/** 一条输入的毛病。`id` 是出毛病的那一个 */ +export type ManualProblem = + | { kind: "blank" } + | { kind: "wildcard"; id: string } + | { kind: "tooLong"; id: string } + | { kind: "duplicate"; id: string } + | { kind: "added"; id: string } + | { kind: "listed"; id: string }; + +/** + * 输入框里的一段字拆成模型 ID:按空白和逗号分开。**一次粘贴几个**是常事(从文档里抄一串), + * 而模型 ID 里没有空白和逗号。 + */ +export function splitIds(raw: string): string[] { + return raw + .split(/[\s,,]+/) + .map((x) => x.trim()) + .filter(Boolean); +} + +/** + * 把输入里的模型加进待添加的名单。写得对的照样加进去;有毛病的留在输入框里,说第一个 + * 毛病。全是空白(按了回车却什么都没写)也说一声。 + * + * - `drafts`:这次已经要添加的; + * - `manual`:之前手动添加过的; + * - `listed`:上游自己列出的。 + */ +export function addIds( + raw: string, + ctx: { drafts: readonly string[]; manual: readonly string[]; listed: readonly string[] }, +): { drafts: string[]; rest: string; problem: ManualProblem | null } { + const ids = splitIds(raw); + if (ids.length === 0) return { drafts: [...ctx.drafts], rest: "", problem: { kind: "blank" } }; + const drafts = [...ctx.drafts]; + const rest: string[] = []; + let problem: ManualProblem | null = null; + for (const id of ids) { + const p = problemOf(id, drafts, ctx); + if (p) { + rest.push(id); + problem ??= p; + } else { + drafts.push(id); + } + } + return { drafts, rest: rest.join(" "), problem }; +} + +function problemOf( + id: string, + drafts: readonly string[], + ctx: { manual: readonly string[]; listed: readonly string[] }, +): ManualProblem | null { + if ([...id].length > MANUAL_MODEL_MAX) return { kind: "tooLong", id }; + if (/[*?]/.test(id)) return { kind: "wildcard", id }; + if (drafts.includes(id)) return { kind: "duplicate", id }; + if (ctx.manual.includes(id)) return { kind: "added", id }; + if (ctx.listed.includes(id)) return { kind: "listed", id }; + return null; +} diff --git a/src/upstreams/modelSpec.test.ts b/src/upstreams/modelSpec.test.ts index f46b35ef..673bdb46 100644 --- a/src/upstreams/modelSpec.test.ts +++ b/src/upstreams/modelSpec.test.ts @@ -4,7 +4,7 @@ import { catalogOf } from "./ModelsSection"; import { MAX_SPEC_TOKENS, hasManual, isEmptySpec, manualOf, sameSpec, specOf, tokensOf } from "./modelSpec"; function row(patch: Partial = {}): ModelRow { - return { id: "glm-5-air", enabled: true, estimated: false, aliases: [], ...patch }; + return { id: "glm-5-air", enabled: true, estimated: false, aliases: [], manual: false, listed: true, ...patch }; } /** 手写的模型规格:格子里的数怎么认,哪几项是手写的 */