Files
infinite-canvas/web/src/components/layout/channel-editor-drawer.tsx
T

135 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
import { ListPlus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { ModelScriptEditor } from "./model-script-editor";
import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
{ label: "生图", value: "image" },
{ label: "视频", value: "video" },
{ label: "文本", value: "text" },
{ label: "音频", value: "audio" },
];
type ScriptTarget = { name: string; capability: ModelCapability; value: string };
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
const [draft, setDraft] = useState<ModelChannel | null>(channel);
const [selectOpen, setSelectOpen] = useState(false);
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
useEffect(() => {
if (open && channel) setDraft(channel);
}, [open, channel]);
if (!draft) return null;
const patch = (value: Partial<ModelChannel>) => setDraft((current) => (current ? { ...current, ...value } : current));
const setModels = (models: ChannelModel[]) => patch({ models });
const changeApiFormat = (apiFormat: ApiCallFormat) => {
const baseUrl = !draft.baseUrl.trim() || draft.baseUrl.trim() === defaultBaseUrlForApiFormat(draft.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : draft.baseUrl;
patch({ apiFormat, baseUrl });
};
const applySelection = (names: string[]) => {
const map = new Map(draft.models.map((model) => [model.name, model]));
setModels(names.map((name) => map.get(name) || { name, capability: guessCapability(name) }));
};
const setCapability = (name: string, capability: ModelCapability) => setModels(draft.models.map((model) => (model.name === name ? { ...model, capability } : model)));
const setScript = (name: string, script: string) => setModels(draft.models.map((model) => (model.name === name ? { ...model, script: script || undefined } : model)));
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
const save = () => {
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) });
onClose();
};
return (
<Drawer
open={open}
width={640}
title="编辑渠道"
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={save}>
</Button>
</Space>
}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium"></span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium"></span>
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
</label>
<label className="block md:col-span-2">
<span className="mb-1 block text-sm font-medium"></span>
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
</label>
<label className="block md:col-span-2">
<span className="mb-1 block text-sm font-medium">API Key</span>
<Input.Password value={draft.apiKey} onChange={(event) => patch({ apiKey: event.target.value })} placeholder="sk-..." />
</label>
</div>
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold"></div>
<div className="mt-0.5 text-xs text-stone-500"> {draft.models.length} </div>
</div>
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
</Button>
</div>
<div className="space-y-2 rounded-lg border border-stone-200 p-2 dark:border-stone-800">
{draft.models.length ? (
draft.models.map((model) => (
<div key={model.name} className="flex flex-wrap items-center gap-3 rounded-md px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-stone-900/40">
<span className="min-w-0 flex-1 truncate text-sm" title={model.name}>
{model.name}
</span>
<div className="flex shrink-0 items-center gap-2">
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
{model.script ? "脚本已设" : "调用脚本"}
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
</div>
</div>
))
) : (
<div className="px-2 py-8 text-center text-sm text-stone-500"></div>
)}
</div>
<ModelSelectModal open={selectOpen} channel={draft} selectedNames={draft.models.map((model) => model.name)} onConfirm={applySelection} onClose={() => setSelectOpen(false)} />
<ModelScriptEditor
open={Boolean(scriptTarget)}
capability={scriptTarget?.capability || "text"}
modelName={scriptTarget?.name || ""}
value={scriptTarget?.value || ""}
onSave={(script) => scriptTarget && setScript(scriptTarget.name, script)}
onClose={() => setScriptTarget(null)}
/>
</Drawer>
);
}