Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/upstreams/ManualModelInput.i18n.ts
Original file line number Diff line number Diff line change
@@ -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",
},
);
161 changes: 161 additions & 0 deletions src/upstreams/ManualModelInput.tsx
Original file line number Diff line number Diff line change
@@ -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<R extends ManualAdd> {
input: string;
problem: ManualProblem | null;
change: (value: string) => void;
/**
* 把输入框里的加进去,交回 `add` 的结果。输入框是空的时为 null —— `quiet`(失焦、保存前
* 顺手加一下)时不说「不能为空」,空着就是没东西要加
*/
commit: (quiet?: boolean) => R | null;
}

/** 输入框的字和毛病。`add` 把一段输入加进调用方的名单,交回加的结果 */
export function useManualEntry<R extends ManualAdd>(add: (raw: string) => R): ManualEntry<R> {
const [input, setInput] = useState("");
const [problem, setProblem] = useState<ManualProblem | null>(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<R extends ManualAdd>({
entry,
children,
label,
placeholder,
hint,
inputRowClassName,
inputRef,
}: {
entry: ManualEntry<R>;
children?: ReactNode;
/** 输入框的无障碍名称。不给是「模型 ID」 */
label?: string;
/** 不给是「例如 gpt-6-luna」 */
placeholder?: string;
/** 框下面那一行。不给是「可添加多个……」 */
hint?: string;
/** 输入那一行:比如左边让出表格勾选的那一列,和上面的模型 ID 对齐 */
inputRowClassName?: string;
inputRef?: Ref<HTMLInputElement>;
}) {
const t = useText(manualModelInputText);
const noteId = useId();
const message = entry.problem ? problemText(entry.problem, t) : null;
return (
<div className="flex flex-col gap-1">
<Boxed className={cn(entry.problem && "border-destructive")}>
{children}
<div className={cn("flex h-8 items-center gap-2.5 bg-background px-3", inputRowClassName)}>
<Input
ref={inputRef}
aria-label={label ?? t.models}
aria-describedby={noteId}
// 写错时整个框变红就够了,框里的输入框不再套一圈
className="h-7 flex-1 border-0 bg-transparent px-0 font-mono shadow-none focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent"
value={entry.input}
placeholder={placeholder ?? t.placeholder}
aria-invalid={entry.problem ? true : undefined}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
onChange={(e) => entry.change(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
// 回车是添加这一条,不是提交对话框
e.preventDefault();
entry.commit();
}}
// 输了没按回车就去点别处:这一条也算上,不让它悄悄丢掉
onBlur={() => entry.commit(true)}
/>
<span className="shrink-0 tw-label text-muted-foreground">{t.enterToAdd}</span>
</div>
</Boxed>
<p id={noteId} className={cn("tw-label", message ? "text-destructive" : "text-muted-foreground")}>
{message ?? hint ?? t.hint}
</p>
</div>
);
}

/**
* 模型名后面的「手动」标记。上游也列了的照样标:手动清单里有什么,一样不藏,悬停说上游
* 列没列它
*/
export function ManualTag({ listed }: { listed: boolean }) {
const t = useText(manualModelInputText);
return (
<Badge
variant="secondary"
title={listed ? t.manualListedTitle : t.manualOnlyTitle}
className="h-4 shrink-0 rounded-[4px] px-1 font-sans font-normal"
>
{t.manualTag}
</Badge>
);
}

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);
}
}
24 changes: 1 addition & 23 deletions src/upstreams/ManualModelsDialog.i18n.ts
Original file line number Diff line number Diff line change
@@ -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.`,
},
);
Loading
Loading