171 lines
10 KiB
TypeScript
171 lines
10 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
|
import { Copy, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||
|
|
import { ListPageLayout, ListPageSection } from "@/components/layout/list-page-layout";
|
||
|
|
import { BackendField, OpenCodeFields, ReasonerFields } from "@/components/task-backends/backend-fields";
|
||
|
|
import { backendPayload, draftFromBackend, emptyBackendDraft } from "@/components/task-backends/backend-form";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { DataList } from "@/components/ui/data-list";
|
||
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
|
|
import { FilterPills } from "@/components/ui/filter-pills";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||
|
|
import { SearchInput } from "@/components/ui/search-input";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Switch } from "@/components/ui/switch";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import { modelResourcesApi, taskBackendsApi, toolsApi, type ModelResource, type TaskBackendResource, type TaskBackendType, type Tool } from "@/lib/api";
|
||
|
|
|
||
|
|
const typeLabels: Record<TaskBackendType, string> = { reasoner: "内置 Reasoner", opencode: "外部 OpenCode" };
|
||
|
|
const filters = ["全部", "内置 Reasoner", "外部 OpenCode"] as const;
|
||
|
|
type Filter = (typeof filters)[number];
|
||
|
|
|
||
|
|
export function ComponentsTaskBackendsPage() {
|
||
|
|
const [resources, setResources] = useState<TaskBackendResource[]>([]);
|
||
|
|
const [models, setModels] = useState<ModelResource[]>([]);
|
||
|
|
const [tools, setTools] = useState<Tool[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState("");
|
||
|
|
const [search, setSearch] = useState("");
|
||
|
|
const [filter, setFilter] = useState<Filter>("全部");
|
||
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||
|
|
const [editing, setEditing] = useState<TaskBackendResource | null>(null);
|
||
|
|
const [draft, setDraft] = useState(emptyBackendDraft);
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
const [saveError, setSaveError] = useState("");
|
||
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
const [nextResources, nextModels, nextTools] = await Promise.all([
|
||
|
|
taskBackendsApi.list(), modelResourcesApi.list(), toolsApi.list(),
|
||
|
|
]);
|
||
|
|
setResources(nextResources);
|
||
|
|
setModels(nextModels);
|
||
|
|
setTools(nextTools);
|
||
|
|
} catch (error) {
|
||
|
|
setError(error instanceof Error ? error.message : "任务后端加载失败");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
useEffect(() => {
|
||
|
|
// Load external resources once; retry and CRUD actions use the same loader.
|
||
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
|
|
void load();
|
||
|
|
}, [load]);
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
const query = search.trim().toLowerCase();
|
||
|
|
return resources.filter(resource => (filter === "全部" || typeLabels[resource.type] === filter) &&
|
||
|
|
(!query || `${resource.name} ${resource.description}`.toLowerCase().includes(query)));
|
||
|
|
}, [resources, search, filter]);
|
||
|
|
|
||
|
|
function openEditor(resource?: TaskBackendResource) {
|
||
|
|
setEditing(resource ?? null);
|
||
|
|
setDraft(resource ? draftFromBackend(resource) : emptyBackendDraft());
|
||
|
|
setSaveError("");
|
||
|
|
setDialogOpen(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function save() {
|
||
|
|
setSaving(true);
|
||
|
|
setSaveError("");
|
||
|
|
try {
|
||
|
|
const payload = backendPayload(draft);
|
||
|
|
if (payload.type === "reasoner" && payload.config.reasoningEffort !== "provider_default" &&
|
||
|
|
models.find(model => model.id === payload.config.modelResourceId)?.values.supportsReasoningEffort !== true) {
|
||
|
|
throw new Error("该模型未声明支持推理强度,请选择模型默认");
|
||
|
|
}
|
||
|
|
if (editing) await taskBackendsApi.update(editing.id, payload);
|
||
|
|
else await taskBackendsApi.create(payload);
|
||
|
|
setDialogOpen(false);
|
||
|
|
await load();
|
||
|
|
} catch (error) {
|
||
|
|
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function act(resource: TaskBackendResource, action: "duplicate" | "remove") {
|
||
|
|
if (action === "remove" && !window.confirm(`确认删除任务后端“${resource.name}”?`)) return;
|
||
|
|
setBusyId(resource.id);
|
||
|
|
try {
|
||
|
|
await taskBackendsApi[action](resource.id);
|
||
|
|
await load();
|
||
|
|
} catch (error) {
|
||
|
|
setError(error instanceof Error ? error.message : "操作失败");
|
||
|
|
} finally {
|
||
|
|
setBusyId(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return <>
|
||
|
|
<ListPageLayout title="任务后端" description="为提示词助手配置独立的推理与执行环境。Speaker 负责对话,任务后端负责处理委派目标。"
|
||
|
|
action={<Button className="gap-2" disabled={loading || Boolean(error)} onClick={() => openEditor()}><Plus size={16} />添加任务后端</Button>}>
|
||
|
|
<ListPageSection>
|
||
|
|
<ListToolbar filters={<FilterPills options={filters} value={filter} onChange={setFilter} />}
|
||
|
|
search={<SearchInput value={search} onChange={setSearch} placeholder="搜索名称或说明" />} />
|
||
|
|
<DataList rows={filtered} rowKey={resource => resource.id} loading={loading} error={error} onRetry={() => void load()}
|
||
|
|
onRowClick={resource => openEditor(resource)} empty={{ title: "暂无任务后端", description: "创建内置 Reasoner 或外部 OpenCode,再到提示词助手中绑定。" }}
|
||
|
|
columns={[
|
||
|
|
{ key: "name", header: "名称", cell: resource => <div><div className="font-medium">{resource.name}</div><div className="mt-1 text-xs text-muted-foreground">{resource.description || "未填写说明"}</div></div> },
|
||
|
|
{ key: "type", header: "后端", width: "md:w-[164px]", cell: resource => <Badge variant="secondary">{typeLabels[resource.type]}</Badge> },
|
||
|
|
{ key: "status", header: "状态", width: "md:w-[84px]", cell: resource => <span className="text-muted-foreground">{resource.enabled ? "已启用" : "已停用"}</span> },
|
||
|
|
{ key: "action", header: "操作", width: "md:w-[144px]", align: "right", cell: resource => <div className="flex justify-end gap-1" onClick={event => event.stopPropagation()}>
|
||
|
|
<Button variant="ghost" size="icon" aria-label={`编辑 ${resource.name}`} onClick={() => openEditor(resource)}><Pencil size={15} /></Button>
|
||
|
|
<Button variant="ghost" size="icon" aria-label={`复制 ${resource.name}`} disabled={busyId === resource.id} onClick={() => void act(resource, "duplicate")}><Copy size={15} /></Button>
|
||
|
|
<Button variant="ghost" size="icon" aria-label={`删除 ${resource.name}`} disabled={busyId === resource.id} onClick={() => void act(resource, "remove")}><Trash2 size={15} /></Button>
|
||
|
|
</div> },
|
||
|
|
]} />
|
||
|
|
</ListPageSection>
|
||
|
|
</ListPageLayout>
|
||
|
|
|
||
|
|
<Dialog open={dialogOpen} onOpenChange={open => { if (!saving) setDialogOpen(open); }}>
|
||
|
|
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-3xl">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{editing ? "编辑任务后端" : "添加任务后端"}</DialogTitle>
|
||
|
|
<DialogDescription>连接、模型、执行策略与技能在此复用。助手侧配置任务目标与委派要求。</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="min-h-0 space-y-5 overflow-y-auto pr-2">
|
||
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
||
|
|
<BackendField label="名称"><Input value={draft.name} onChange={event => setDraft(previous => ({ ...previous, name: event.target.value }))} /></BackendField>
|
||
|
|
<BackendField label="任务后端类型">
|
||
|
|
<Select value={draft.type} disabled={Boolean(editing)} onValueChange={value => setDraft(previous => ({ ...previous, type: value as TaskBackendType }))}>
|
||
|
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
|
|
<SelectContent><SelectItem value="reasoner">内置 Reasoner</SelectItem><SelectItem value="opencode">外部 OpenCode</SelectItem></SelectContent>
|
||
|
|
</Select>
|
||
|
|
</BackendField>
|
||
|
|
</div>
|
||
|
|
<BackendField label="说明"><Textarea rows={2} value={draft.description} onChange={event => setDraft(previous => ({ ...previous, description: event.target.value }))} /></BackendField>
|
||
|
|
<div className="flex items-center justify-between rounded-xl border border-hairline p-3">
|
||
|
|
<label htmlFor="backend-enabled" className="text-sm">启用此任务后端</label>
|
||
|
|
<Switch id="backend-enabled" checked={draft.enabled} onCheckedChange={enabled => setDraft(previous => ({ ...previous, enabled }))} />
|
||
|
|
</div>
|
||
|
|
{draft.type === "reasoner"
|
||
|
|
? <ReasonerFields draft={draft} setDraft={setDraft} models={models} tools={tools} />
|
||
|
|
: <OpenCodeFields draft={draft} setDraft={setDraft} hasPassword={Boolean(editing?.secrets.password)} />}
|
||
|
|
<BackendField label="后端任务指令(可选)" hint="所有使用此后端的任务共享的要求。每个助手还可以添加独立的任务指令。">
|
||
|
|
<Textarea rows={4} value={draft.instructions} onChange={event => setDraft(previous => ({ ...previous, instructions: event.target.value }))} />
|
||
|
|
</BackendField>
|
||
|
|
<div className="rounded-2xl border border-hairline p-4">
|
||
|
|
<BackendField label="任务时限(秒)" hint="覆盖排队与执行;当前并发固定为 1。语音插话和断线不会取消任务,明确停止时会核查执行状态。">
|
||
|
|
<Input type="number" min={30} max={7200} value={draft.timeoutSeconds} onChange={event => setDraft(previous => ({ ...previous, timeoutSeconds: event.target.value }))} />
|
||
|
|
</BackendField>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" disabled={saving} onClick={() => setDialogOpen(false)}>取消</Button>
|
||
|
|
<Button disabled={saving} onClick={() => void save()}>{saving && <Loader2 size={15} className="animate-spin" />}保存</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</>;
|
||
|
|
}
|