- 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>
|
|
);
|
|
}
|