From 7bff6ffd3184f1535374390eaa2cefeb7ad62d9e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:21:14 +0800 Subject: [PATCH] Upstream dialog: add models by hand in the Models section, for any upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit / New upstream dialog only offered a free-text box for models added by hand when the upstream returned no list. The Models section now always ends with the same list input as the model panel's "Add models…" dialog (Enter to add, several at once, checked on the spot), for an upstream with a list, without one, and while a new upstream's first fetch runs. - The section's table is the union of what the upstream lists and the form's manual list (pending edits are the source of truth), deduplicated, listed first. Manual rows are tagged Manual and have a Remove action; a removed model the upstream also lists stays, untagged. - With "Selected models" active, a model added by hand is checked right away (its exact ID is appended to the scope; a wildcard that already covers it is left alone). Removing one the upstream does not list also drops its exact entry from the scope. "All models" needs nothing. - The header counts read like the panel: "4 listed by the upstream, 1 added by hand"; the badge follows the pending manual list. - Saving writes the manual list with the rest of the upstream. Text still in the input is added first; if it has a problem the save stops on the Models section. Core's config.manual_model_* rejections show in the dialog like any other save error. - ModelCatalog carries only the IDs the upstream lists (ModelRow.listed) instead of flattening manual ones in. - The list input, its checks' wording and the Manual tag are shared (ManualModelInput.tsx); ManualModelsDialog and ModelsPanel use them. Co-Authored-By: Claude Opus 5.5 --- src/upstreams/ManualModelInput.i18n.ts | 37 +++ src/upstreams/ManualModelInput.tsx | 161 ++++++++++ src/upstreams/ManualModelsDialog.i18n.ts | 24 +- src/upstreams/ManualModelsDialog.tsx | 135 +++----- src/upstreams/ModelsPanel.i18n.ts | 6 - src/upstreams/ModelsPanel.tsx | 11 +- src/upstreams/ModelsSection.i18n.ts | 29 +- src/upstreams/ModelsSection.test.ts | 58 ++++ src/upstreams/ModelsSection.tsx | 391 ++++++++++++++--------- src/upstreams/UpstreamDialog.tsx | 49 ++- src/upstreams/labels.test.ts | 13 + src/upstreams/manualModels.test.ts | 72 ++++- src/upstreams/manualModels.ts | 47 ++- src/upstreams/upstreamForm.test.ts | 55 ++++ src/upstreams/upstreamForm.ts | 36 ++- 15 files changed, 815 insertions(+), 309 deletions(-) create mode 100644 src/upstreams/ManualModelInput.i18n.ts create mode 100644 src/upstreams/ManualModelInput.tsx create mode 100644 src/upstreams/ModelsSection.test.ts diff --git a/src/upstreams/ManualModelInput.i18n.ts b/src/upstreams/ManualModelInput.i18n.ts new file mode 100644 index 00000000..baa41adf --- /dev/null +++ b/src/upstreams/ManualModelInput.i18n.ts @@ -0,0 +1,37 @@ +import { messages } from "@/i18n"; + +export const manualModelInputText = messages( + { + about: "添加上游能服务、但未列在其模型列表中的模型。", + models: "模型 ID", + placeholder: "例如 gpt-6-luna", + enterToAdd: "回车添加", + hint: "可添加多个,每项为一个确切的模型 ID。", + blank: "模型 ID 不能为空。", + wildcard: (id: string) => `「${id}」含有 * 或 ?。手动添加的模型须为确切的模型 ID,不支持通配。`, + tooLong: (max: number) => `模型 ID 最多 ${max} 个字符。`, + duplicate: (id: string) => `「${id}」已在待添加的模型中。`, + added: (id: string) => `「${id}」已经手动添加过。`, + listed: (id: string) => `上游的模型列表中已有「${id}」,无需添加。`, + manualTag: "手动", + manualOnlyTitle: "手动添加的模型,上游的模型列表中没有", + manualListedTitle: "手动添加的模型,上游的模型列表中也有", + }, + { + about: "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.", + 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.`, + manualTag: "Manual", + manualOnlyTitle: "Added by hand; not in the upstream's model list", + manualListedTitle: "Added by hand; also in the upstream's model list", + }, +); diff --git a/src/upstreams/ManualModelInput.tsx b/src/upstreams/ManualModelInput.tsx new file mode 100644 index 00000000..c8f43be8 --- /dev/null +++ b/src/upstreams/ManualModelInput.tsx @@ -0,0 +1,161 @@ +/** + * 手动添加模型的名单式输入,两处共用:模型弹窗的「添加模型…」对话框(`ManualModelsDialog`), + * 和编辑对话框的「模型」一节(`ModelsSection`)。 + * + * 写法和放行网段、插件范围一样:一条一行,最后一行输入,右边写「回车添加」,一次粘贴几个按 + * 空白和逗号拆开;写错的、重复的、已经加过的、上游已经列出的当场说,留在输入框里。失焦也算 + * 添加。框里输入行上面放什么(待添加的几行、整张模型表)由调用方给。 + * + * 加进哪儿、拿什么查重也由调用方定(`useManualEntry` 的 `add`):对话框加进「待添加」, + * 编辑对话框直接加进表单里的手动清单。 + */ +import { type ReactNode, type Ref, useId, useState } from "react"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/ui/badge"; +import { Input } from "@/ui/input"; +import { useText } from "@/i18n"; +import { MANUAL_MODEL_MAX, type ManualProblem } from "./manualModels"; +import { manualModelInputText } from "./ManualModelInput.i18n"; +import { Boxed } from "./parts"; + +/** 加一次的结果:输入框里留下的(有毛病的那几个)和第一个毛病 */ +export interface ManualAdd { + rest: string; + problem: ManualProblem | null; +} + +export interface ManualEntry { + input: string; + problem: ManualProblem | null; + change: (value: string) => void; + /** + * 把输入框里的加进去,交回 `add` 的结果。输入框是空的时为 null —— `quiet`(失焦、保存前 + * 顺手加一下)时不说「不能为空」,空着就是没东西要加 + */ + commit: (quiet?: boolean) => R | null; +} + +/** 输入框的字和毛病。`add` 把一段输入加进调用方的名单,交回加的结果 */ +export function useManualEntry(add: (raw: string) => R): ManualEntry { + const [input, setInput] = useState(""); + const [problem, setProblem] = useState(null); + return { + input, + problem, + change: (value) => { + setInput(value); + setProblem(null); + }, + commit: (quiet = false) => { + if (input.trim() === "") { + // 空着离开不算写错:按回车时说过的「不能为空」也收掉 + setProblem(quiet ? (p) => (p?.kind === "blank" ? null : p) : { kind: "blank" }); + return null; + } + const r = add(input); + setInput(r.rest); + setProblem(r.problem); + return r; + }, + }; +} + +/** + * 一个框:`children`(已有的几行)在上,最后一行是输入框;框下面一行是提示,有毛病时换成 + * 毛病(整个框描红)。 + */ +export function ManualModelList({ + entry, + children, + label, + placeholder, + hint, + inputRowClassName, + inputRef, +}: { + entry: ManualEntry; + children?: ReactNode; + /** 输入框的无障碍名称。不给是「模型 ID」 */ + label?: string; + /** 不给是「例如 gpt-6-luna」 */ + placeholder?: string; + /** 框下面那一行。不给是「可添加多个……」 */ + hint?: string; + /** 输入那一行:比如左边让出表格勾选的那一列,和上面的模型 ID 对齐 */ + inputRowClassName?: string; + inputRef?: Ref; +}) { + const t = useText(manualModelInputText); + const noteId = useId(); + const message = entry.problem ? problemText(entry.problem, t) : null; + return ( +
+ + {children} +
+ entry.change(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter" || e.nativeEvent.isComposing) return; + // 回车是添加这一条,不是提交对话框 + e.preventDefault(); + entry.commit(); + }} + // 输了没按回车就去点别处:这一条也算上,不让它悄悄丢掉 + onBlur={() => entry.commit(true)} + /> + {t.enterToAdd} +
+
+

+ {message ?? hint ?? t.hint} +

+
+ ); +} + +/** + * 模型名后面的「手动」标记。上游也列了的照样标:手动清单里有什么,一样不藏,悬停说上游 + * 列没列它 + */ +export function ManualTag({ listed }: { listed: boolean }) { + const t = useText(manualModelInputText); + return ( + + {t.manualTag} + + ); +} + +function problemText(problem: ManualProblem, t: (typeof manualModelInputText)["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/ManualModelsDialog.i18n.ts b/src/upstreams/ManualModelsDialog.i18n.ts index bd833c11..52022ab2 100644 --- a/src/upstreams/ManualModelsDialog.i18n.ts +++ b/src/upstreams/ManualModelsDialog.i18n.ts @@ -1,39 +1,17 @@ import { messages } from "@/i18n"; +/** 名单式输入本身的那些字(提示、回车添加、毛病)在 `ManualModelInput.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 index 872698cb..a5990b9d 100644 --- a/src/upstreams/ManualModelsDialog.tsx +++ b/src/upstreams/ManualModelsDialog.tsx @@ -10,7 +10,6 @@ import { 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"; @@ -19,17 +18,18 @@ 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 { ManualModelList, useManualEntry } from "./ManualModelInput"; +import { manualModelInputText } from "./ManualModelInput.i18n"; +import { addIds } from "./manualModels"; import { manualModelsDialogText } from "./ManualModelsDialog.i18n"; -import { Boxed, DialogError } from "./parts"; +import { DialogError } from "./parts"; /** * 给一家上游手动添加模型(`PUT /provider-manual-models`):上游能服务、却没列进模型列表的 * 模型。添加之后它们和列出的模型一样出现在客户端的模型列表里、路由到这家。 * - * 名单的写法和放行网段、插件范围一样:一条一行,最后一行输入,右边写「回车添加」,一次 - * 粘贴几个按空白和逗号拆开;写错的、重复的、已经加过的、上游已经列出的当场说,留在输入框里。 - * 失焦也算添加。**交给 core 的是整份手动清单**:原来的接上这次添加的。 + * 名单式输入和编辑对话框的「模型」一节共用(`ManualModelInput`)。**交给 core 的是整份手动 + * 清单**:原来的接上这次添加的。 * * 挂载方:上游表「模型」一格的弹窗(「添加模型…」)。对话框挂在弹窗外面:弹窗一收起, * 里面的东西就卸掉了。 @@ -84,10 +84,14 @@ function Body({ onSaved?: () => void; }) { const t = useText(manualModelsDialogText); + const shared = useText(manualModelInputText); const c = useText(commonText); const [drafts, setDrafts] = useState([]); - const [input, setInput] = useState(""); - const [problem, setProblem] = useState(null); + const entry = useManualEntry((raw) => { + const r = addIds(raw, { drafts, manual: p.models, listed }); + setDrafts(r.drafts); + return r; + }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const rows = usePresentList(drafts, (x) => x); @@ -97,23 +101,12 @@ function Body({ */ 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; + // 输入框里还有没按回车的:先加上,写错了就不存(毛病留在输入框里说) + const r = entry.commit(true); + if (r?.problem) return; + const next = r ? r.drafts : drafts; + if (next.length === 0) return; setSaving(true); setError(null); try { @@ -131,7 +124,6 @@ function Body({ } const inScope = (id: string) => !p.models_only || p.models_only.some((g) => globMatch(g, id)); - const message = problem ? problemText(problem, t) : null; return (
{p.name} -

{t.desc}

+

{shared.about}

-
- - {rows.map(({ item: id, key, presence }) => ( -
+ {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} + {t.removeShort} +
- -

{message ?? t.hint}

-
+ ))} + @@ -208,27 +170,10 @@ function Body({ - ); } - -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 f61e623e..7b9a8354 100644 --- a/src/upstreams/ModelsPanel.i18n.ts +++ b/src/upstreams/ModelsPanel.i18n.ts @@ -33,9 +33,6 @@ export const modelsPanelText = messages( makeAlias: "起别名…", specs: "规格…", manualSpecs: "手动规格", - manualTag: "手动", - manualOnlyTitle: "手动添加的模型,上游的模型列表中没有", - manualListedTitle: "手动添加的模型,上游的模型列表中也有", remove: "移除", removeLabel: (id: string) => `移除手动添加的模型 ${id}`, removed: (id: string) => `已移除手动添加的模型 ${id}`, @@ -80,9 +77,6 @@ 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`, diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index 6f81a22f..4a2f04b6 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -17,6 +17,7 @@ import type { ModelRow, ProviderModelsView, ProviderView } from "@/types"; import { api } from "./api"; import { modelsKey, patch } from "./data"; import { coreText, errorText, perMillion } from "./labels"; +import { ManualTag } from "./ManualModelInput"; import { hasManual } from "./modelSpec"; import { modelsPanelText } from "./ModelsPanel.i18n"; @@ -362,15 +363,7 @@ function Row({ ))} {/* 放在名字这一边,不放在数旁边:悬停时右边让给按钮,这些标记和它们的说明还看得见 */} - {m.manual && ( - - {t.manualTag} - - )} + {m.manual && } {hasManual(m) && ( `${n} 个`, + listedAndAdded: (listed: number, added: number) => `上游列出 ${listed} 个,手动添加 ${added} 个`, fetchedAt: (time: string) => `获取于 ${time}`, refresh: "刷新模型列表", fetching: "正在获取模型列表", notFetched: "尚未获取模型列表。", - manual: "手动添加的模型", - manualDesc: (error?: string | null) => - `${error ? `${error.replace(/[。.]$/, "")}。` : "未获取到模型列表。"}每行填写一个模型 ID,这些模型会出现在客户端的模型列表中。`, + noList: (reason: string) => `${reason.replace(/[。.]$/, "")}。可手动添加此上游提供的模型。`, + noListReason: "上游未提供模型列表", + unreachable: "未能连接上游", + addLabel: "手动添加的模型 ID", + addPlaceholder: "添加模型,例如 gpt-6-luna", + // 「添加上游能服务……」和「可添加多个……」两句之间 + sep: "", scope: "启用范围", all: "全部模型", some: "指定模型", @@ -22,6 +27,10 @@ export const modelsSectionText = messages( modelId: "模型 ID", context: "上下文窗口", pricing: "定价", + actions: "操作", + remove: "移除", + removeLabel: (id: string) => `移除手动添加的模型 ${id}`, + noMatch: "无匹配的模型", priced: "已定价", pricedEstimated: "已定价(估算)", unpriced: "未定价", @@ -31,13 +40,17 @@ export const modelsSectionText = messages( { title: "Model list", count: (n: number) => (n === 1 ? "1 model" : `${n} models`), + listedAndAdded: (listed: number, added: number) => `${listed} listed by the upstream, ${added} added by hand`, fetchedAt: (time: string) => `fetched at ${time}`, refresh: "Refresh model list", fetching: "Fetching the model list", notFetched: "The model list has not been fetched.", - 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.`, + noList: (reason: string) => `${reason.replace(/[。.]$/, "")}. The models this upstream serves can be added by hand.`, + noListReason: "The upstream does not provide a model list", + unreachable: "Could not reach the upstream", + addLabel: "Model IDs to add by hand", + addPlaceholder: "Add a model, e.g. gpt-6-luna", + sep: " ", scope: "Enabled models", all: "All models", some: "Selected models", @@ -49,6 +62,10 @@ export const modelsSectionText = messages( modelId: "Model ID", context: "Context window", pricing: "Price", + actions: "Actions", + remove: "Remove", + removeLabel: (id: string) => `Remove ${id}, added by hand`, + noMatch: "No matching models", priced: "Priced", pricedEstimated: "Priced (estimated)", unpriced: "Unpriced", diff --git a/src/upstreams/ModelsSection.test.ts b/src/upstreams/ModelsSection.test.ts new file mode 100644 index 00000000..043f4e39 --- /dev/null +++ b/src/upstreams/ModelsSection.test.ts @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { ModelRow } from "@/types"; +import { catalogOf, modelCount } from "./ModelsSection"; + +// 断言按中文写:不随跑测试那台机器的系统语言变 +beforeAll(() => setLang("zh")); + +function row(patch: Partial & { id: string }): ModelRow { + return { enabled: true, estimated: false, aliases: [], manual: false, listed: true, ...patch }; +} + +describe("编辑对话框的模型一节", () => { + it("只记上游自己列出的:手动添加的以表单为准,不混进来", () => { + const c = catalogOf({ + provider: "chatgpt", + source: "discovered", + status: "listed", + fetching: false, + models: [ + row({ id: "gpt-5.5" }), + row({ id: "gpt-5.4", manual: true }), + row({ id: "gpt-6-luna", manual: true, listed: false }), + ], + }); + expect(c.listed).toEqual(["gpt-5.5", "gpt-5.4"]); + }); + + it("上游不提供清单:一个也不算上游列出的", () => { + const c = catalogOf({ + provider: "ollama", + source: "manual", + status: "no_list", + fetching: false, + models: [row({ id: "qwen3-coder:30b", manual: true, listed: false })], + }); + expect(c.listed).toEqual([]); + }); + + it("标题旁的数:上游列出的和手动添加的分开说", () => { + expect(modelCount(true, 4, 0)).toBe("4 个"); + expect(modelCount(true, 4, 1)).toBe("上游列出 4 个,手动添加 1 个"); + // 没有清单:标签已经说了「手动添加」,这里只说数 + expect(modelCount(false, 0, 2)).toBe("2 个"); + expect(modelCount(false, 0, 0)).toBeNull(); + }); + + it("英文界面同样分开说", () => { + setLang("en"); + try { + expect(modelCount(true, 1, 0)).toBe("1 model"); + expect(modelCount(true, 4, 1)).toBe("4 listed by the upstream, 1 added by hand"); + expect(modelCount(false, 0, 2)).toBe("2 models"); + } finally { + setLang("zh"); + } + }); +}); diff --git a/src/upstreams/ModelsSection.tsx b/src/upstreams/ModelsSection.tsx index 6e8d3f63..b94f8e23 100644 --- a/src/upstreams/ModelsSection.tsx +++ b/src/upstreams/ModelsSection.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { CircleAlertIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { compact, whenMinute } from "@/format"; @@ -6,6 +6,7 @@ import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; +import { rowMotion, usePresentList } from "@/ui/motion"; import { Segmented } from "@/ui/segmented"; import { Spinner } from "@/ui/spinner"; import { TableSkeleton } from "@/ui/states"; @@ -18,20 +19,25 @@ import { TableHeader, TableRow, } from "@/ui/table"; -import { Textarea } from "@/ui/textarea"; -import { useText } from "@/i18n"; +import { textOf, useText } from "@/i18n"; import type { ModelListStatus, ModelSource, Msg, ProviderModelsView, ResolvedPrice } from "@/types"; import { coreText } from "@/i18n/core.i18n"; -import { globMatch } from "./glob"; import { modelSourceLabel } from "./labels"; +import { type ManualAdd, type ManualEntry, ManualModelList, ManualTag } from "./ManualModelInput"; +import { manualModelInputText } from "./ManualModelInput.i18n"; +import { unionModels } from "./manualModels"; import { modelsSectionText } from "./ModelsSection.i18n"; -import { Boxed, FormItem, Note } from "./parts"; -import type { UpstreamForm } from "./upstreamForm"; +import { FormItem, Note } from "./parts"; +import { inScope, withManualRemoved, type UpstreamForm } from "./upstreamForm"; /** 这家上游有哪些模型,以及是怎么知道的 */ export interface ModelCatalog { source: ModelSource; - models: string[]; + /** + * **上游自己列出的**模型(`ModelRow.listed`)。手动添加的不在这里:那一份以表单里的 + * `manualModels` 为准 —— 对话框里加了、移除了还没保存的,这一节照样要列对 + */ + listed: string[]; checkedAtMs?: number | null; /** 没拿到清单的原因 */ error?: Msg | null; @@ -50,7 +56,7 @@ export interface ModelCatalog { export function catalogOf(v: ProviderModelsView): ModelCatalog { return { source: v.source, - models: v.models.map((m) => m.id), + listed: v.models.filter((m) => m.listed).map((m) => m.id), checkedAtMs: v.checked_at_ms, error: v.error, status: v.status, @@ -63,15 +69,32 @@ export function catalogOf(v: ProviderModelsView): ModelCatalog { }; } -/** 这个模型在不在启用范围里。和 core 同一套通配规则 */ -export function inScope(form: UpstreamForm, model: string): boolean { - return form.scope === "all" || form.scopeList.some((p) => globMatch(p, model)); +/** + * 标题旁那一行数。上游列出的清单里接了手动添加的,两个数分开说(和模型弹窗一样):「上游 + * 列出 5 个」里有两个其实是手动添加的,就不对了。手动添加、上游也列了的算在上游列出的里。 + * 没有清单时全是手动添加的,前面的标签已经说了「手动添加」,只说数;一个也没有就不说。 + */ +export function modelCount(hasList: boolean, listed: number, added: number): string | null { + const t = textOf(modelsSectionText); + if (hasList) return added > 0 ? t.listedAndAdded(listed, added) : t.count(listed); + return added > 0 ? t.count(added) : null; } +/** + * 编辑对话框的「模型」一节:上游列出的和手动添加的合成一张表,勾选启用范围,最后一行手动 + * 添加模型(名单式输入,和模型弹窗的「添加模型…」共用,见 `ManualModelInput`)。 + * + * 手动添加的那几行标「手动」、行尾「移除」。加和移除都只改表单,和别的设置一起保存。 + * 指定了启用范围时,新加的一并勾上(`withManualAdded`)。 + * + * 输入框的状态(`entry`)放在对话框里:切到别的分节再保存,输入框里没按回车的也要先加上、 + * 写错了不存(见 `UpstreamDialog` 的 `save`)。 + */ export function ModelsSection({ form, set, catalog, + entry, prices, perToken, sheetLabel, @@ -82,6 +105,8 @@ export function ModelsSection({ form: UpstreamForm; set: (patch: Partial) => void; catalog: ModelCatalog | null; + /** 手动添加的输入框。加进去由对话框做(查重、勾上启用范围) */ + entry: ManualEntry; /** 按所选价目表查到的价格,键是模型 ID */ prices: Record; /** 按量计费。其余几种计费方式不按单价算费用,定价一列没有意义 */ @@ -93,25 +118,59 @@ export function ModelsSection({ onRefresh: () => void; }) { const t = useText(modelsSectionText); + const shared = useText(manualModelInputText); const [filter, setFilter] = useState(""); - /** - * 手动清单的原文。**不能直接用 `manualModels.join("\n")` 当值** —— 按行拆 - * 的时候空行被去掉,回车敲出来的那个空行会被立刻吞掉,换不了行。 - */ - const [manualText, setManualText] = useState(() => form.manualModels.join("\n")); - const listed = catalog?.source === "discovered"; - // 后台正在问、手里还什么都没有:等它,别先让人去填手动清单 + const hasList = catalog?.source === "discovered"; + // 后台正在问、手里还什么都没有:等它 const waiting = !!catalog?.fetching && catalog.source === "none"; const busy = refreshing || !!catalog?.fetching; - const models = listed ? catalog.models : form.manualModels; - const shown = useMemo( - () => models.filter((m) => m.toLowerCase().includes(filter.trim().toLowerCase())), - [models, filter], - ); + const pending = waiting || loading; + /** + * 知道上游列了哪些。不知道的时候(还在读、没读到)不给勾选:勾一下就把范围换成手里这几个, + * 上游列出的那些就悄悄掉出去了 + */ + const known = catalog != null && !pending; + const listed = catalog?.listed; + const union = useMemo(() => unionModels(listed ?? [], form.manualModels), [listed, form.manualModels]); + const models = useMemo(() => union.map((m) => m.id), [union]); + // 第一次知道上游列了哪些时整张表一起出来,之后新加的那几行才带进场的动效 + const present = usePresentList(known ? union : [], (m) => m.id); + const rows = known ? present : union.map((item) => ({ item, key: item.id, presence: "idle" as const })); + const q = known ? filter.trim().toLowerCase() : ""; + const shown = rows.filter(({ item }) => item.id.toLowerCase().includes(q)); + /** 看得见、还在的(刚移除、正在淡出的那一行不算):全选管的是这些 */ + const live = shown.flatMap(({ item, presence }) => (presence === "exit" ? [] : [item.id])); // 切回「全部模型」时清单还留着(再切回来不丢),但那时通配规则不生效 const patterns = form.scope === "some" ? form.scopeList.filter((p) => p.includes("*")) : []; const enabled = models.filter((m) => inScope(form, m)); const unpriced = perToken ? enabled.filter((m) => prices[m] && !prices[m].price) : []; + const anyManual = union.some((m) => m.manual); + const added = union.filter((m) => m.manual && !m.listed).length; + const count = pending ? null : modelCount(hasList, union.length - added, added); + const source: ModelSource = hasList ? "discovered" : form.manualModels.length > 0 ? "manual" : "none"; + + /* + 新加了模型:让那一行露出来。手动添加的接在表的最后,滚到底就是它;筛选框里写着别的、 + 把它筛掉了的话,清掉筛选。输入框跟着往下挪,也让它留在视野里 + */ + const scroller = useRef(null); + const input = useRef(null); + const before = useRef(form.manualModels); + const [reveal, setReveal] = useState(0); + useLayoutEffect(() => { + const fresh = form.manualModels.filter((m) => !before.current.includes(m)); + before.current = form.manualModels; + if (fresh.length === 0) return; + if (fresh.some((m) => !m.toLowerCase().includes(filter.trim().toLowerCase()))) setFilter(""); + setReveal((n) => n + 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.manualModels]); + useLayoutEffect(() => { + if (reveal === 0) return; + const el = scroller.current; + if (el) el.scrollTop = el.scrollHeight; + input.current?.scrollIntoView({ block: "nearest" }); + }, [reveal]); function toggle(model: string, on: boolean) { // 通配规则一旦被逐个勾选改动,就换成明确的模型清单 —— 两种写法混在 @@ -123,29 +182,30 @@ export function ModelsSection({ function toggleAll(on: boolean) { const current = new Set(models.filter((m) => inScope(form, m))); - for (const m of shown) { - if (on) current.add(m); - else current.delete(m); + for (const id of live) { + if (on) current.add(id); + else current.delete(id); } set({ scope: "some", scopeList: models.filter((m) => current.has(m)) }); } - const allShownOn = shown.length > 0 && shown.every((m) => inScope(form, m)); - const someShownOn = shown.some((m) => inScope(form, m)); + const allShownOn = live.length > 0 && live.every((id) => inScope(form, id)); + const someShownOn = live.some((id) => inScope(form, id)); + const note = known && !hasList ? noListNote(catalog) : null; return (
{t.title} {catalog && !waiting && ( - - {modelSourceLabel(catalog.source, catalog.status)} + + {modelSourceLabel(source, catalog.status)} )} - {catalog && (listed || models.length > 0) && ( + {count && ( - {t.count(models.length)} - {listed && catalog.checkedAtMs ? ` · ${t.fetchedAt(whenMinute(catalog.checkedAtMs))}` : ""} + {count} + {hasList && catalog.checkedAtMs ? ` · ${t.fetchedAt(whenMinute(catalog.checkedAtMs))}` : ""} )}
@@ -155,79 +215,66 @@ export function ModelsSection({
- {waiting ? ( - - {t.fetching} - - ) : loading ? ( - - ) : !catalog ? ( - {t.notFetched} - ) : ( - <> - {!listed && ( - -