Files
ai-video-fullstack/frontend/src/components/pages/ComponentsTaskBackendsPage.tsx
T

172 lines
10 KiB
TypeScript
Raw Normal View History

"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.enabled && <p className="text-xs leading-5 text-muted-foreground">停用后,已绑定助手需解绑或重新启用此后端才能开启新会话。已创建的任务继续使用提交时的配置。</p>}
{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>
</>;
}