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 && ( - -