- Extend the AssistantConfig model to include agent_interface_type, agent_values, and agent_secrets for enhanced agent management. - Update ModelType to include "Agent" for broader capability support. - Seed new agent models and configurations in the database for Dify, FastGPT, and OpenCode applications. - Modify the Assistant routes to validate and handle agent capabilities. - Implement agent resource resolution in the config resolver for runtime configuration. - Enhance the interface catalog with agent-specific fields and definitions. - Update frontend components to manage agent configurations, including form handling and state management for agent-related inputs.
1043 lines
33 KiB
TypeScript
1043 lines
33 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Copy,
|
||
Eye,
|
||
EyeOff,
|
||
Loader2,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plug,
|
||
Plus,
|
||
Trash2,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { DataList } from "@/components/ui/data-list";
|
||
import { PageHeader } from "@/components/ui/page-header";
|
||
import { FilterPills } from "@/components/ui/filter-pills";
|
||
import { SearchInput } from "@/components/ui/search-input";
|
||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
interfaceDefinitionsApi,
|
||
modelResourcesApi,
|
||
type InterfaceDefinition,
|
||
type InterfaceField,
|
||
type ModelResource,
|
||
type ModelType,
|
||
} from "@/lib/api";
|
||
|
||
const capabilities: ModelType[] = [
|
||
"LLM",
|
||
"ASR",
|
||
"TTS",
|
||
"Realtime",
|
||
"Embedding",
|
||
"Agent",
|
||
];
|
||
|
||
const capabilityFilters = ["全部", ...capabilities] as const;
|
||
|
||
type ResourceDraft = {
|
||
name: string;
|
||
capability: ModelType;
|
||
interfaceType: string;
|
||
values: Record<string, unknown>;
|
||
secrets: Record<string, unknown>;
|
||
supportImageInput: boolean;
|
||
};
|
||
|
||
const emptyDraft: ResourceDraft = {
|
||
name: "",
|
||
capability: "LLM",
|
||
interfaceType: "",
|
||
values: {},
|
||
secrets: {},
|
||
supportImageInput: false,
|
||
};
|
||
|
||
function defaults(definition: InterfaceDefinition): Record<string, unknown> {
|
||
return Object.fromEntries(
|
||
definition.fieldSchema.fields
|
||
.filter((field) => field.group === "values" && field.default !== undefined)
|
||
.map((field) => [field.key, field.default]),
|
||
);
|
||
}
|
||
|
||
function formatUpdatedAt(value?: string | null): string {
|
||
if (!value) return "—";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "—";
|
||
return date.toLocaleString("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
// 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前
|
||
type SortOrder = "newest" | "oldest";
|
||
|
||
// 缺失/非法时间戳视为最早(0),用于排序
|
||
function updatedAtValue(value?: string | null): number {
|
||
const time = value ? new Date(value).getTime() : NaN;
|
||
return Number.isNaN(time) ? 0 : time;
|
||
}
|
||
|
||
function fieldValue(draft: ResourceDraft, field: InterfaceField): unknown {
|
||
return field.group === "secrets"
|
||
? draft.secrets[field.key]
|
||
: draft.values[field.key];
|
||
}
|
||
|
||
function secretMask(
|
||
masks: Record<string, unknown>,
|
||
field: InterfaceField,
|
||
): string {
|
||
if (field.group !== "secrets") return "";
|
||
const value = masks[field.key];
|
||
return typeof value === "string" && value.includes("****") ? value : "";
|
||
}
|
||
|
||
function extraBodyText(values: Record<string, unknown>): string {
|
||
const value = values.extraBody;
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||
return JSON.stringify(value, null, 2);
|
||
}
|
||
|
||
function parseExtraBody(text: string):
|
||
| { ok: true; value: Record<string, unknown> | null }
|
||
| { ok: false; error: string } {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return { ok: true, value: null };
|
||
try {
|
||
const parsed = JSON.parse(trimmed) as unknown;
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
return { ok: false, error: "Extra Body 必须是 JSON 对象" };
|
||
}
|
||
return { ok: true, value: parsed as Record<string, unknown> };
|
||
} catch {
|
||
return { ok: false, error: "Extra Body 不是合法 JSON" };
|
||
}
|
||
}
|
||
|
||
function valuesWithExtraBody(
|
||
values: Record<string, unknown>,
|
||
extraBody: Record<string, unknown> | null,
|
||
): Record<string, unknown> {
|
||
const next = { ...values };
|
||
delete next.extraBody;
|
||
if (extraBody) next.extraBody = extraBody;
|
||
return next;
|
||
}
|
||
|
||
function isSelectableDefinition(definition: InterfaceDefinition): boolean {
|
||
return (
|
||
definition.capability !== "LLM" || definition.interfaceType === "openai-llm"
|
||
);
|
||
}
|
||
|
||
export function ComponentsModelsPage() {
|
||
const [resources, setResources] = useState<ModelResource[]>([]);
|
||
const [definitions, setDefinitions] = useState<InterfaceDefinition[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadError, setLoadError] = useState("");
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState<ModelType | "全部">("全部");
|
||
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [draft, setDraft] = useState<ResourceDraft>(emptyDraft);
|
||
const [storedSecretMasks, setStoredSecretMasks] = useState<Record<string, unknown>>({});
|
||
const [extraBodyDraft, setExtraBodyDraft] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState("");
|
||
const [testing, setTesting] = useState(false);
|
||
const [testResult, setTestResult] = useState<{
|
||
ok: boolean;
|
||
message: string;
|
||
} | null>(null);
|
||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setLoadError("");
|
||
try {
|
||
const [nextResources, nextDefinitions] = await Promise.all([
|
||
modelResourcesApi.list(),
|
||
interfaceDefinitionsApi.list(),
|
||
]);
|
||
setResources(nextResources);
|
||
setDefinitions(nextDefinitions);
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : "加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Initial external API synchronization.
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
void load();
|
||
}, [load]);
|
||
|
||
const selectableDefinitions = definitions.filter(isSelectableDefinition);
|
||
const availableDefinitions = selectableDefinitions.filter(
|
||
(definition) => definition.capability === draft.capability,
|
||
);
|
||
const selectedDefinition = definitions.find(
|
||
(definition) => definition.interfaceType === draft.interfaceType,
|
||
);
|
||
const connectionFields =
|
||
selectedDefinition?.fieldSchema.fields.filter(
|
||
(field) => field.group === "secrets" || field.required,
|
||
) ?? [];
|
||
const optionalFields =
|
||
selectedDefinition?.fieldSchema.fields.filter(
|
||
(field) => field.group === "values" && !field.required,
|
||
) ?? [];
|
||
|
||
const filtered = useMemo(() => {
|
||
const keyword = search.trim().toLowerCase();
|
||
return resources.filter((resource) => {
|
||
if (filter !== "全部" && resource.capability !== filter) return false;
|
||
return (
|
||
!keyword ||
|
||
[
|
||
resource.name,
|
||
resource.interfaceType,
|
||
String(resource.values.modelId ?? ""),
|
||
]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(keyword)
|
||
);
|
||
});
|
||
}, [resources, search, filter]);
|
||
|
||
// 按更新时间排序(以原始时间戳为准);id 作为稳定的次级排序键
|
||
const sorted = useMemo(() => {
|
||
return [...filtered].sort((a, b) => {
|
||
const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt);
|
||
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
|
||
return a.id.localeCompare(b.id);
|
||
});
|
||
}, [filtered, sortOrder]);
|
||
|
||
const pageSize = 5;
|
||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||
const pageEnd = pageStart + pageSize;
|
||
const paginatedResources = sorted.slice(pageStart, pageEnd);
|
||
|
||
function changeFilter(nextFilter: ModelType | "全部") {
|
||
setFilter(nextFilter);
|
||
setCurrentPage(1);
|
||
}
|
||
|
||
function changeSearch(value: string) {
|
||
setSearch(value);
|
||
setCurrentPage(1);
|
||
}
|
||
|
||
function changeSort(order: SortOrder) {
|
||
setSortOrder(order);
|
||
setCurrentPage(1);
|
||
}
|
||
|
||
function toggleSortOrder() {
|
||
changeSort(sortOrder === "newest" ? "oldest" : "newest");
|
||
}
|
||
|
||
function selectDefinition(interfaceType: string) {
|
||
const definition = definitions.find(
|
||
(item) => item.interfaceType === interfaceType,
|
||
);
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
interfaceType,
|
||
values: definition ? defaults(definition) : {},
|
||
secrets: {},
|
||
}));
|
||
setStoredSecretMasks({});
|
||
setExtraBodyDraft("");
|
||
setTestResult(null);
|
||
}
|
||
|
||
function selectCapability(capability: ModelType) {
|
||
const first = selectableDefinitions.find(
|
||
(definition) => definition.capability === capability,
|
||
);
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
capability,
|
||
interfaceType: first?.interfaceType ?? "",
|
||
values: first ? defaults(first) : {},
|
||
secrets: {},
|
||
supportImageInput:
|
||
capability === "LLM" ? previous.supportImageInput : false,
|
||
}));
|
||
setStoredSecretMasks({});
|
||
setExtraBodyDraft("");
|
||
setTestResult(null);
|
||
}
|
||
|
||
function openCreate() {
|
||
const first = selectableDefinitions.find(
|
||
(definition) => definition.capability === "LLM",
|
||
);
|
||
setEditingId(null);
|
||
setDraft({
|
||
...emptyDraft,
|
||
interfaceType: first?.interfaceType ?? "",
|
||
values: first ? defaults(first) : {},
|
||
secrets: {},
|
||
supportImageInput: false,
|
||
});
|
||
setStoredSecretMasks({});
|
||
setExtraBodyDraft("");
|
||
setSaveError("");
|
||
setTestResult(null);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function openEdit(resource: ModelResource) {
|
||
setEditingId(resource.id);
|
||
setDraft({
|
||
name: resource.name,
|
||
capability: resource.capability,
|
||
interfaceType: resource.interfaceType,
|
||
values: resource.values,
|
||
secrets: {},
|
||
supportImageInput: resource.supportImageInput,
|
||
});
|
||
setStoredSecretMasks(resource.secrets);
|
||
setExtraBodyDraft(extraBodyText(resource.values));
|
||
setSaveError("");
|
||
setTestResult(null);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function updateField(field: InterfaceField, value: unknown) {
|
||
const target = field.group === "secrets" ? "secrets" : "values";
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
[target]: { ...previous[target], [field.key]: value },
|
||
}));
|
||
setTestResult(null);
|
||
}
|
||
|
||
const parsedExtraBody = parseExtraBody(extraBodyDraft);
|
||
const extraBodyError = parsedExtraBody.ok ? "" : parsedExtraBody.error;
|
||
const canSave = Boolean(
|
||
draft.name.trim() &&
|
||
selectedDefinition &&
|
||
!extraBodyError &&
|
||
selectedDefinition.fieldSchema.fields.every((field) => {
|
||
if (!field.required) return true;
|
||
const value =
|
||
field.group === "secrets" && secretMask(storedSecretMasks, field)
|
||
? secretMask(storedSecretMasks, field)
|
||
: fieldValue(draft, field);
|
||
return value !== undefined && value !== null && value !== "";
|
||
}),
|
||
);
|
||
|
||
function payload() {
|
||
const extraBody = parsedExtraBody.ok ? parsedExtraBody.value : null;
|
||
return {
|
||
name: draft.name.trim(),
|
||
interfaceType: draft.interfaceType,
|
||
values:
|
||
draft.capability === "LLM"
|
||
? valuesWithExtraBody(draft.values, extraBody)
|
||
: draft.values,
|
||
secrets: editingId
|
||
? { ...storedSecretMasks, ...draft.secrets }
|
||
: draft.secrets,
|
||
supportImageInput:
|
||
draft.capability === "LLM" ? draft.supportImageInput : false,
|
||
enabled: editingId
|
||
? resources.find((resource) => resource.id === editingId)?.enabled ?? true
|
||
: true,
|
||
isDefault: editingId
|
||
? resources.find((resource) => resource.id === editingId)?.isDefault ?? false
|
||
: false,
|
||
};
|
||
}
|
||
|
||
async function testConnection() {
|
||
if (!canSave) return;
|
||
setTesting(true);
|
||
setTestResult(null);
|
||
try {
|
||
const result = await modelResourcesApi.test(payload(), editingId ?? undefined);
|
||
setTestResult({
|
||
ok: result.ok,
|
||
message: [
|
||
result.message,
|
||
result.latencyMs !== null ? `${result.latencyMs}ms` : "",
|
||
result.detail,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · "),
|
||
});
|
||
} catch (error) {
|
||
setTestResult({
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : "测试连接失败",
|
||
});
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (!canSave) return;
|
||
setSaving(true);
|
||
setSaveError("");
|
||
const body = payload();
|
||
try {
|
||
if (editingId) {
|
||
await modelResourcesApi.update(editingId, body);
|
||
} else {
|
||
await modelResourcesApi.create(body);
|
||
}
|
||
setDialogOpen(false);
|
||
await load();
|
||
} catch (error) {
|
||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function remove(id: string) {
|
||
setDeletingId(id);
|
||
try {
|
||
await modelResourcesApi.remove(id);
|
||
await load();
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : "删除失败");
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
}
|
||
|
||
async function duplicate(id: string) {
|
||
setDuplicatingId(id);
|
||
try {
|
||
await modelResourcesApi.duplicate(id);
|
||
await load();
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : "复制失败");
|
||
} finally {
|
||
setDuplicatingId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||
<PageHeader
|
||
title="模型资源"
|
||
description="接口定义决定能力、鉴权字段和调用参数,表单会随接口类型动态更新。"
|
||
action={
|
||
<Button
|
||
size="lg"
|
||
className="w-full gap-2 sm:w-auto"
|
||
onClick={openCreate}
|
||
>
|
||
<Plus size={16} />
|
||
添加模型
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||
<ListToolbar
|
||
filters={
|
||
<FilterPills
|
||
options={capabilityFilters}
|
||
value={filter}
|
||
onChange={changeFilter}
|
||
/>
|
||
}
|
||
search={
|
||
<SearchInput
|
||
value={search}
|
||
onChange={changeSearch}
|
||
placeholder="搜索名称、接口类型或模型 ID"
|
||
className="lg:w-[320px]"
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<DataList<ModelResource>
|
||
rows={paginatedResources}
|
||
rowKey={(resource) => resource.id}
|
||
loading={loading}
|
||
error={loadError || null}
|
||
onRetry={() => void load()}
|
||
empty={{
|
||
title:
|
||
resources.length === 0
|
||
? "暂无模型资源"
|
||
: "未找到匹配的模型资源",
|
||
description:
|
||
resources.length === 0
|
||
? "点击右上角「添加模型」开始。"
|
||
: "请调整关键词或筛选条件后再试。",
|
||
}}
|
||
pagination={{
|
||
page: safeCurrentPage,
|
||
totalPages,
|
||
onPageChange: setCurrentPage,
|
||
summary:
|
||
filtered.length === 0
|
||
? "没有数据"
|
||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个模型资源`,
|
||
}}
|
||
columns={[
|
||
{
|
||
key: "name",
|
||
header: "模型名称",
|
||
width: "md:w-[360px]",
|
||
cell: (resource) => (
|
||
<>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className="truncate font-medium text-foreground">
|
||
{resource.name}
|
||
</span>
|
||
{resource.supportImageInput && (
|
||
<span
|
||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-surface-strong text-muted-foreground"
|
||
title="支持图片输入"
|
||
aria-label="支持图片输入"
|
||
>
|
||
<Eye size={12} />
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-1 truncate text-xs text-muted-soft">
|
||
{String(
|
||
resource.values.modelId ?? resource.values.apiUrl ?? "",
|
||
)}
|
||
</div>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: "capability",
|
||
header: "能力",
|
||
width: "md:w-[128px]",
|
||
cell: (resource) => (
|
||
<Badge
|
||
variant="secondary"
|
||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||
>
|
||
{resource.capability}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "interfaceType",
|
||
header: "接口类型",
|
||
width: "md:w-[220px]",
|
||
cell: (resource) => (
|
||
<Badge variant="outline" className="h-6 px-3">
|
||
{resource.interfaceType}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "updatedAt",
|
||
width: "md:w-[176px]",
|
||
header: (
|
||
<button
|
||
type="button"
|
||
onClick={toggleSortOrder}
|
||
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||
aria-label={
|
||
sortOrder === "newest"
|
||
? "当前按最近更新排序,点击切换为最早更新"
|
||
: "当前按最早更新排序,点击切换为最近更新"
|
||
}
|
||
>
|
||
更新时间
|
||
{sortOrder === "newest" ? (
|
||
<ChevronDown size={13} />
|
||
) : (
|
||
<ChevronUp size={13} />
|
||
)}
|
||
</button>
|
||
),
|
||
cellClassName:
|
||
"whitespace-nowrap tabular-nums text-muted-foreground",
|
||
cell: (resource) => formatUpdatedAt(resource.updatedAt),
|
||
},
|
||
{
|
||
key: "actions",
|
||
header: "操作",
|
||
align: "right",
|
||
cellClassName: "flex justify-end gap-2",
|
||
cell: (resource) => (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||
onClick={() => openEdit(resource)}
|
||
>
|
||
<Pencil size={14} />
|
||
编辑
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon-sm"
|
||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
aria-label={`${resource.name} 更多操作`}
|
||
>
|
||
<MoreHorizontal size={15} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||
>
|
||
<DropdownMenuItem
|
||
className="rounded-lg"
|
||
disabled={duplicatingId === resource.id}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
void duplicate(resource.id);
|
||
}}
|
||
>
|
||
{duplicatingId === resource.id ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Copy size={14} />
|
||
)}
|
||
复制
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
className="rounded-lg"
|
||
disabled={deletingId === resource.id}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
void remove(resource.id);
|
||
}}
|
||
>
|
||
{deletingId === resource.id ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Trash2 size={14} />
|
||
)}
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</section>
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||
<DialogHeader>
|
||
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
|
||
<DialogDescription>
|
||
选择具体接口类型后,系统会从接口定义生成需要填写的字段。修改能力类型会自动取消不再兼容的助手绑定。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="grid gap-5 lg:grid-cols-2">
|
||
<div className="space-y-5 lg:flex lg:h-[38rem] lg:flex-col lg:space-y-0 lg:gap-5">
|
||
<FieldSection title="基本信息">
|
||
<Field label="资源名称" required>
|
||
<Input
|
||
value={draft.name}
|
||
onChange={(event) =>
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
name: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</Field>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<Field label="能力类型" required>
|
||
<Select
|
||
value={draft.capability}
|
||
onValueChange={(value) =>
|
||
selectCapability(value as ModelType)
|
||
}
|
||
>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{capabilities.map((capability) => (
|
||
<SelectItem key={capability} value={capability}>
|
||
{capability}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
<Field label="接口类型" required>
|
||
<Select
|
||
value={draft.interfaceType}
|
||
onValueChange={selectDefinition}
|
||
>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="请选择接口类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableDefinitions.map((definition) => (
|
||
<SelectItem
|
||
key={definition.interfaceType}
|
||
value={definition.interfaceType}
|
||
>
|
||
{definition.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
</div>
|
||
{draft.capability === "LLM" && (
|
||
<div className="flex items-center justify-between rounded-xl border border-hairline p-4">
|
||
<span className="text-sm font-medium">支持图片输入</span>
|
||
<Switch
|
||
checked={draft.supportImageInput}
|
||
onCheckedChange={(checked) =>
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
supportImageInput: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
</FieldSection>
|
||
|
||
<FieldSection title="连接与鉴权信息" scrollable fill>
|
||
{connectionFields.map((field) => (
|
||
<DynamicField
|
||
key={`${field.group}-${field.key}`}
|
||
field={field}
|
||
value={fieldValue(draft, field)}
|
||
storedValueMask={secretMask(storedSecretMasks, field)}
|
||
onChange={(value) => updateField(field, value)}
|
||
/>
|
||
))}
|
||
</FieldSection>
|
||
</div>
|
||
|
||
<FieldSection title="可选项" scrollable tall>
|
||
{draft.capability === "LLM" && (
|
||
<Field label="Extra Body">
|
||
<Textarea
|
||
rows={7}
|
||
value={extraBodyDraft}
|
||
onChange={(event) => {
|
||
setExtraBodyDraft(event.target.value);
|
||
setTestResult(null);
|
||
}}
|
||
placeholder={'例如:{\n "thinking": { "type": "disabled" }\n}'}
|
||
className="font-mono text-xs leading-5"
|
||
/>
|
||
{extraBodyError && (
|
||
<div className="text-xs text-destructive">
|
||
{extraBodyError}
|
||
</div>
|
||
)}
|
||
</Field>
|
||
)}
|
||
{optionalFields.length > 0 ? (
|
||
optionalFields.map((field) => (
|
||
<DynamicField
|
||
key={`${field.group}-${field.key}`}
|
||
field={field}
|
||
value={fieldValue(draft, field)}
|
||
storedValueMask={secretMask(storedSecretMasks, field)}
|
||
onChange={(value) => updateField(field, value)}
|
||
/>
|
||
))
|
||
) : draft.capability === "LLM" ? null : (
|
||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||
当前接口没有可选项
|
||
</div>
|
||
)}
|
||
</FieldSection>
|
||
</div>
|
||
|
||
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
|
||
|
||
<DialogFooter className="sm:items-center sm:justify-between">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<Button
|
||
variant="outline"
|
||
className="shrink-0 gap-2"
|
||
disabled={!canSave || testing}
|
||
onClick={() => void testConnection()}
|
||
>
|
||
{testing ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Plug size={14} />
|
||
)}
|
||
测试连接
|
||
</Button>
|
||
{testResult && (
|
||
<span
|
||
className={[
|
||
"flex min-w-0 items-center gap-1.5 text-xs",
|
||
testResult.ok ? "text-emerald-500" : "text-destructive",
|
||
].join(" ")}
|
||
title={testResult.message}
|
||
>
|
||
{testResult.ok ? (
|
||
<CheckCircle2 size={14} className="shrink-0" />
|
||
) : (
|
||
<XCircle size={14} className="shrink-0" />
|
||
)}
|
||
<span className="truncate">{testResult.message}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button disabled={!canSave || saving} onClick={() => void save()}>
|
||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
required,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
required?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<label className="block space-y-2">
|
||
<span className="text-sm font-medium">
|
||
{label}
|
||
{required && <span className="ml-1 text-destructive">*</span>}
|
||
</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function FieldSection({
|
||
title,
|
||
scrollable,
|
||
tall,
|
||
fill,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
scrollable?: boolean;
|
||
tall?: boolean;
|
||
fill?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<section
|
||
className={[
|
||
"rounded-xl border border-hairline bg-surface-strong/20",
|
||
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
|
||
fill ? "lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "",
|
||
].join(" ")}
|
||
>
|
||
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||
{title}
|
||
</div>
|
||
<div
|
||
className={[
|
||
"space-y-4 p-4",
|
||
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||
tall ? "lg:max-h-none lg:flex-1" : "",
|
||
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
|
||
].join(" ")}
|
||
>
|
||
{children}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DynamicField({
|
||
field,
|
||
value,
|
||
storedValueMask,
|
||
onChange,
|
||
}: {
|
||
field: InterfaceField;
|
||
value: unknown;
|
||
storedValueMask?: string;
|
||
onChange: (value: unknown) => void;
|
||
}) {
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
|
||
if (field.type === "boolean") {
|
||
return (
|
||
<div className="flex items-center justify-between rounded-xl border border-hairline p-4">
|
||
<span className="text-sm font-medium">{field.label}</span>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={Boolean(value) ? "default" : "outline"}
|
||
onClick={() => onChange(!Boolean(value))}
|
||
>
|
||
{Boolean(value) ? "开启" : "关闭"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (field.type === "select") {
|
||
return (
|
||
<Field label={field.label} required={field.required}>
|
||
<Select value={String(value ?? "")} onValueChange={onChange}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(field.options ?? []).map((option) => (
|
||
<SelectItem key={option} value={option}>
|
||
{option}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
if (field.type === "password") {
|
||
const inputValue = String(value ?? "");
|
||
const hasStoredValue = Boolean(storedValueMask);
|
||
|
||
return (
|
||
<Field label={field.label} required={field.required}>
|
||
{hasStoredValue && (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>当前密钥</span>
|
||
<code className="rounded-md bg-surface-strong px-2 py-1 font-mono text-foreground">
|
||
{storedValueMask}
|
||
</code>
|
||
</div>
|
||
)}
|
||
<div className="relative">
|
||
<Input
|
||
type={showPassword ? "text" : "password"}
|
||
value={inputValue}
|
||
onChange={(event) => {
|
||
const nextValue = event.target.value;
|
||
if (!nextValue) setShowPassword(false);
|
||
onChange(nextValue);
|
||
}}
|
||
placeholder={
|
||
hasStoredValue ? "已配置,留空则保持不变" : undefined
|
||
}
|
||
autoComplete="new-password"
|
||
className="pr-10"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={!inputValue}
|
||
onClick={() => setShowPassword((current) => !current)}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={showPassword ? "隐藏密钥" : "显示密钥"}
|
||
>
|
||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||
</button>
|
||
</div>
|
||
{hasStoredValue && (
|
||
<p className="text-xs leading-5 text-muted-foreground">
|
||
仅显示当前密钥首尾用于识别。留空可保持原密钥,输入新值将覆盖原密钥。
|
||
</p>
|
||
)}
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Field label={field.label} required={field.required}>
|
||
<Input
|
||
type={
|
||
field.type === "number"
|
||
? "number"
|
||
: field.type === "url"
|
||
? "url"
|
||
: "text"
|
||
}
|
||
value={String(value ?? "")}
|
||
onChange={(event) =>
|
||
onChange(
|
||
field.type === "number"
|
||
? Number(event.target.value)
|
||
: event.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
);
|
||
}
|