F3: configure Speaker and task backends in prompt mode

This commit is contained in:
Xin Wang
2026-10-23 23:46:40 +08:00
parent 16ebfe358c
commit fff76fe889
16 changed files with 598 additions and 92 deletions
@@ -0,0 +1,5 @@
import { ComponentsTaskBackendsPage } from "@/components/pages/ComponentsTaskBackendsPage";
export default function Page() {
return <ComponentsTaskBackendsPage />;
}
@@ -28,7 +28,7 @@ function TaskCard({ task, onChanged }: { task: BackgroundTask; onChanged: () =>
<span className="truncate font-medium" title={task.instruction}>{task.instruction}</span> <span className="truncate font-medium" title={task.instruction}>{task.instruction}</span>
<span className="shrink-0 rounded-full bg-surface-strong px-2 py-1">{labels[task.status]}</span> <span className="shrink-0 rounded-full bg-surface-strong px-2 py-1">{labels[task.status]}</span>
</div> </div>
<p className="text-muted-foreground">{task.summary}</p> <p className="text-muted-foreground">{task.backendType === "reasoner" ? "内置 Reasoner" : "OpenCode"} · {task.summary}</p>
{task.status === "waiting_input" && request.kind === "permission" && <div className="space-y-2"> {task.status === "waiting_input" && request.kind === "permission" && <div className="space-y-2">
<p>远端请求:{request.permission} {request.patterns?.join("、")}</p> <p>远端请求:{request.permission} {request.patterns?.join("、")}</p>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -45,7 +45,7 @@ function TaskCard({ task, onChanged }: { task: BackgroundTask; onChanged: () =>
<Button size="sm" disabled={busy || !request.questions?.every((_, index) => answers[index]?.trim())} onClick={() => void act(() => backgroundTasksApi.input(task.id, { answers: request.questions?.map((question, index) => question.multiple ? answers[index].split(/[,,]/).map(value => value.trim()).filter(Boolean) : [answers[index].trim()]) }))}>发送回复</Button> <Button size="sm" disabled={busy || !request.questions?.every((_, index) => answers[index]?.trim())} onClick={() => void act(() => backgroundTasksApi.input(task.id, { answers: request.questions?.map((question, index) => question.multiple ? answers[index].split(/[,,]/).map(value => value.trim()).filter(Boolean) : [answers[index].trim()]) }))}>发送回复</Button>
</div>} </div>}
{task.result && <details><summary className="cursor-pointer text-muted-foreground">查看结果</summary><pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-sans">{task.result}</pre></details>} {task.result && <details><summary className="cursor-pointer text-muted-foreground">查看结果</summary><pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-sans">{task.result}</pre></details>}
{task.status === "unknown" && <Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.refresh(task.id))}>核查远端状态</Button>} {task.status === "unknown" && <Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.refresh(task.id))}>核查任务状态</Button>}
{!terminal.has(task.status) && <Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.cancel(task.id))}>{task.status === "cancel_requested" ? "再次核查停止" : "停止任务"}</Button>} {!terminal.has(task.status) && <Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.cancel(task.id))}>{task.status === "cancel_requested" ? "再次核查停止" : "停止任务"}</Button>}
{error && <p role="alert" className="text-destructive">{error}</p>} {error && <p role="alert" className="text-destructive">{error}</p>}
</article>; </article>;
@@ -41,6 +41,7 @@ import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { SectionCard } from "@/components/editor/section-card"; import { SectionCard } from "@/components/editor/section-card";
import { VisionConfigSection } from "@/components/editor/vision-config-section"; import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { BuiltInToolsEditor } from "@/components/assistant-editor/built-in-tools"; import { BuiltInToolsEditor } from "@/components/assistant-editor/built-in-tools";
import { TaskBackendSection } from "@/components/assistant-editor/task-backend-section";
import { ToolBindingEditor } from "@/components/tools/tool-binding-editor"; import { ToolBindingEditor } from "@/components/tools/tool-binding-editor";
import { TurnConfigEditor } from "@/components/turn-config-editor"; import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -90,7 +91,6 @@ const EXTERNAL_TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
http: "HTTP", http: "HTTP",
mcp: "MCP", mcp: "MCP",
client: "Client", client: "Client",
task: "后台任务",
}; };
const OPENING_MODE_OPTIONS: Array<{ const OPENING_MODE_OPTIONS: Array<{
@@ -126,6 +126,7 @@ const ENTRY_MODE_OPTIONS: Array<{
const promptSections = [ const promptSections = [
{ id: "conversation", label: "对话内容" }, { id: "conversation", label: "对话内容" },
{ id: "models", label: "模型与语音" }, { id: "models", label: "模型与语音" },
{ id: "tasks", label: "任务后端" },
{ id: "capabilities", label: "知识与工具" }, { id: "capabilities", label: "知识与工具" },
{ id: "interaction", label: "交互策略" }, { id: "interaction", label: "交互策略" },
{ id: "variables", label: "动态变量" }, { id: "variables", label: "动态变量" },
@@ -191,6 +192,7 @@ export function PromptEditor({
const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({ const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({
conversation: null, conversation: null,
models: null, models: null,
tasks: null,
capabilities: null, capabilities: null,
interaction: null, interaction: null,
variables: null, variables: null,
@@ -640,6 +642,13 @@ export function PromptEditor({
<RuntimeModeSelector <RuntimeModeSelector
value={form.runtimeMode} value={form.runtimeMode}
onChange={(runtimeMode) => { onChange={(runtimeMode) => {
if (runtimeMode === "realtime" && form.taskBackend) {
const confirmed = window.confirm(
"Realtime 暂不支持任务后端,切换会解除当前助手的后端绑定,后端资源仍会保留。是否继续?",
);
if (!confirmed) return;
updateForm("taskBackend", null);
}
updateForm("runtimeMode", runtimeMode); updateForm("runtimeMode", runtimeMode);
if ( if (
runtimeMode === "realtime" && runtimeMode === "realtime" &&
@@ -662,7 +671,7 @@ export function PromptEditor({
{form.runtimeMode === "pipeline" ? ( {form.runtimeMode === "pipeline" ? (
<> <>
<ResourceSelectField <ResourceSelectField
label="大语言模型" label="对话模型 Speaker"
value={form.model} value={form.model}
onChange={handlePromptModelChange} onChange={handlePromptModelChange}
options={llmOptions} options={llmOptions}
@@ -695,6 +704,21 @@ export function PromptEditor({
</SectionCard> </SectionCard>
</section> </section>
<section
ref={(element) => {
sectionRefs.current.tasks = element;
}}
className="scroll-mt-3 space-y-3"
>
<TaskBackendSection
value={form.taskBackend}
onChange={(value) => updateForm("taskBackend", value)}
pipeline={form.runtimeMode === "pipeline"}
variables={promptVariableSuggestions}
modelOptions={llmOptions}
/>
</section>
<section <section
ref={(element) => { ref={(element) => {
sectionRefs.current.capabilities = element; sectionRefs.current.capabilities = element;
@@ -882,7 +906,6 @@ export function PromptEditor({
description="从资源库添加工具,并配置当前助手的参数来源与结果写入" description="从资源库添加工具,并配置当前助手的参数来源与结果写入"
> >
<ToolBindingEditor <ToolBindingEditor
allowTask={form.runtimeMode === "pipeline"}
tools={tools} tools={tools}
selectableTools={ selectableTools={
form.runtimeMode === "realtime" form.runtimeMode === "realtime"
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useId, useState } from "react";
import { Workflow } from "lucide-react";
import { SectionCard } from "@/components/editor/section-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { taskBackendsApi, type AssistantTaskBackend, type DynamicVariableDefinition, type TaskBackendResource, type ValueSource } from "@/lib/api";
const sourceLabels: Record<ValueSource["source"], string> = {
model: "Speaker 生成任务要求", literal: "固定任务要求", variable: "动态变量", template: "模板任务要求",
};
/** Task backends are configured independently from ordinary conversation tools. */
export function TaskBackendSection({ value, onChange, pipeline, variables, modelOptions }: {
value: AssistantTaskBackend | null;
onChange: (value: AssistantTaskBackend | null) => void;
pipeline: boolean;
variables: Record<string, DynamicVariableDefinition>;
modelOptions: Array<{ value: string; label: string }>;
}) {
const [resources, setResources] = useState<TaskBackendResource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const variableListId = useId();
const load = useCallback(async () => {
setLoading(true);
setError("");
try { setResources(await taskBackendsApi.list()); }
catch (error) { setError(error instanceof Error ? error.message : "任务后端加载失败"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
// Synchronize resource choices with the API.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const selected = resources.find(resource => resource.id === value?.backendId);
const source = value?.instructionSource ?? { source: "model" };
function changeSource(kind: ValueSource["source"]) {
if (!value) return;
const instructionSource: ValueSource = kind === "model" ? { source: "model" }
: kind === "variable" ? { source: "variable", variableName: "" } : { source: kind, value: "" };
onChange({ ...value, instructionSource });
}
return <SectionCard icon={<Workflow size={15} />} title="任务后端"
description="Speaker 负责交流与澄清,任务后端在独立上下文中执行;任务期间可继续对话。"
action={<Link href="/components/task-backends" className="text-xs text-muted-foreground underline-offset-4 hover:underline">管理资源</Link>}>
{!pipeline ? <p className="text-xs leading-6 text-muted-foreground">任务后端仅适用于提示词 Pipeline;Realtime 当前不可用。</p> : <>
<Select value={value?.backendId ?? "__none__"} disabled={loading || Boolean(error)} onValueChange={backendId => onChange(backendId === "__none__" ? null : {
backendId, instructions: value?.instructions ?? "", instructionSource: value?.instructionSource ?? { source: "model" },
})}>
<SelectTrigger className="w-full"><SelectValue placeholder={loading ? "加载任务后端…" : "选择任务后端"} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">关闭任务委派</SelectItem>
{value && !selected && <SelectItem value={value.backendId}>已失效后端(请重新选择)</SelectItem>}
{resources.map(resource => <SelectItem key={resource.id} value={resource.id} disabled={!resource.enabled}>
{resource.name} · {resource.type === "reasoner" ? "内置 Reasoner" : "外部 OpenCode"}{!resource.enabled ? "(已停用)" : ""}
</SelectItem>)}
</SelectContent>
</Select>
{error && <div className="flex items-center justify-between gap-3 text-xs"><span role="alert" className="text-destructive">{error}</span><Button size="sm" variant="outline" onClick={() => void load()}>重试</Button></div>}
{!loading && !error && !resources.length && <p className="text-xs leading-5 text-muted-foreground">先在组件库创建一个任务后端,然后回到此处选择。</p>}
{value && selected && <>
{!selected.enabled && <p role="alert" className="text-xs text-destructive">当前后端已停用,请启用资源或重新选择后端。</p>}
<div className="space-y-1 rounded-xl border border-hairline bg-canvas-soft p-3 text-xs leading-6 text-muted-foreground">
{selected.type === "reasoner" ? <>
<p>推理模型:{modelOptions.find(model => model.value === selected.config.modelResourceId)?.label ?? selected.config.modelResourceId}</p>
<p>推理强度:{selected.config.reasoningEffort === "provider_default" ? "模型默认" : selected.config.reasoningEffort} · 服务端工具 {selected.config.toolBindings.length} 项 · 技能 {selected.config.skills.length} 项</p>
</> : <>
<p className="break-all">OpenCode:{selected.config.url} · {selected.config.directory}</p>
<p>Agent:{selected.config.agent || "远端默认"} · 权限、沙箱与技能由远端管理</p>
{selected.config.skillsNote && <p className="whitespace-pre-wrap">技能说明:{selected.config.skillsNote}</p>}
</>}
<p>任务时限 {selected.config.timeoutSeconds} 秒 · 并发 1 · 断线后任务继续</p>
</div>
</>}
{value && <div className="space-y-3">
<label className="block space-y-2 text-sm">
<span className="font-medium">该助手的任务指令(可选)</span>
<Textarea rows={3} value={value.instructions} placeholder="例如:先分析影响范围,完成后说明修改与验证结果。支持 {{变量名}}。"
onChange={event => onChange({ ...value, instructions: event.target.value })} />
</label>
<label className="block space-y-2 text-sm">
<span className="font-medium">任务要求来源</span>
<Select value={source.source} onValueChange={kind => changeSource(kind as ValueSource["source"])}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(sourceLabels).map(([kind, label]) => <SelectItem key={kind} value={kind}>{label}</SelectItem>)}</SelectContent>
</Select>
</label>
{source.source === "model" && <p className="text-xs leading-5 text-muted-foreground">Speaker 根据对话生成本次目标,后端连接与权限由资源配置决定。</p>}
{source.source === "variable" && <label className="block space-y-2 text-sm">
<span className="font-medium">变量名</span>
<Input list={variableListId} value={source.variableName} placeholder="例如 project_task" onChange={event => onChange({ ...value, instructionSource: { source: "variable", variableName: event.target.value } })} />
<datalist id={variableListId}>{Object.keys(variables).map(name => <option key={name} value={name} />)}</datalist>
</label>}
{(source.source === "literal" || source.source === "template") && <label className="block space-y-2 text-sm">
<span className="font-medium">{source.source === "literal" ? "固定任务要求" : "任务模板"}</span>
<Textarea rows={3} value={typeof source.value === "string" ? source.value : JSON.stringify(source.value ?? "")} placeholder={source.source === "template" ? "处理 {{project}} 的任务" : "每次委派使用此任务要求"}
onChange={event => onChange({ ...value, instructionSource: { source: source.source, value: event.target.value } })} />
</label>}
</div>}
</>}
</SectionCard>;
}
@@ -1,5 +1,6 @@
import type { import type {
AnalysisConfig, AnalysisConfig,
AssistantTaskBackend,
DynamicVariableDefinition, DynamicVariableDefinition,
KnowledgeRetrievalConfig, KnowledgeRetrievalConfig,
StartupConfig, StartupConfig,
@@ -32,5 +33,6 @@ export type AssistantForm = {
visionPromptMode: VisionPromptMode; visionPromptMode: VisionPromptMode;
visionPrompt: string; visionPrompt: string;
toolBindings: ToolBinding[]; toolBindings: ToolBinding[];
taskBackend: AssistantTaskBackend | null;
builtInTools: BuiltInTools; builtInTools: BuiltInTools;
}; };
@@ -19,6 +19,7 @@ import {
PlayCircle, PlayCircle,
User, User,
Video, Video,
Workflow,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/components/auth/AuthProvider"; import { useAuth } from "@/components/auth/AuthProvider";
import { import {
@@ -45,6 +46,7 @@ const componentSubItems: NavItem[] = [
{ href: "/components/models", label: "模型资源", icon: Brain }, { href: "/components/models", label: "模型资源", icon: Brain },
{ href: "/components/knowledge", label: "知识库", icon: Database }, { href: "/components/knowledge", label: "知识库", icon: Database },
{ href: "/components/tools", label: "工具资源", icon: Wrench }, { href: "/components/tools", label: "工具资源", icon: Wrench },
{ href: "/components/task-backends", label: "任务后端", icon: Workflow },
]; ];
const monitorSubItems: NavItem[] = [ const monitorSubItems: NavItem[] = [
@@ -165,6 +165,7 @@ function blankPromptForm(name: string): AssistantForm {
visionPromptMode: "runtime", visionPromptMode: "runtime",
visionPrompt: "", visionPrompt: "",
toolBindings: [], toolBindings: [],
taskBackend: null,
builtInTools: {}, builtInTools: {},
}; };
} }
@@ -266,9 +267,16 @@ export function AssistantPage(props: AssistantPageProps) {
} = useWorkflowEditorState(); } = useWorkflowEditorState();
const effectiveDynamicVariableDefinitions = activeDynamicVariableDefinitions( const effectiveDynamicVariableDefinitions = activeDynamicVariableDefinitions(
[form.prompt, form.greeting], [
form.prompt,
form.greeting,
form.taskBackend?.instructions ?? "",
form.taskBackend?.instructionSource.source === "template"
? String(form.taskBackend.instructionSource.value ?? "")
: "",
],
form.dynamicVariableDefinitions, form.dynamicVariableDefinitions,
[form.toolBindings, form.builtInTools, form.startup], [form.toolBindings, form.builtInTools, form.startup, form.taskBackend],
); );
const effectiveWorkflowDynamicVariableDefinitions = const effectiveWorkflowDynamicVariableDefinitions =
activeWorkflowDynamicVariableDefinitions( activeWorkflowDynamicVariableDefinitions(
@@ -407,6 +415,7 @@ export function AssistantPage(props: AssistantPageProps) {
visionPromptMode: a.visionPromptMode ?? "runtime", visionPromptMode: a.visionPromptMode ?? "runtime",
visionPrompt: a.visionPrompt ?? "", visionPrompt: a.visionPrompt ?? "",
toolBindings: a.toolBindings ?? [], toolBindings: a.toolBindings ?? [],
taskBackend: a.taskBackend ?? null,
builtInTools: a.builtInTools ?? {}, builtInTools: a.builtInTools ?? {},
}; };
setForm(next); setForm(next);
@@ -463,6 +472,7 @@ export function AssistantPage(props: AssistantPageProps) {
knowledgeBaseId: null, knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
toolBindings: [], toolBindings: [],
taskBackend: null,
builtInTools: {}, builtInTools: {},
prompt: "", prompt: "",
dynamicVariableDefinitions: {}, dynamicVariableDefinitions: {},
@@ -531,6 +541,7 @@ export function AssistantPage(props: AssistantPageProps) {
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null, knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig, knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
toolBindings: form.toolBindings, toolBindings: form.toolBindings,
taskBackend: form.runtimeMode === "pipeline" ? form.taskBackend : null,
builtInTools: form.builtInTools, builtInTools: form.builtInTools,
prompt: form.prompt, prompt: form.prompt,
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions, dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
@@ -0,0 +1,170 @@
"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>
</>;
}
@@ -67,9 +67,9 @@ import {
type ToolUpsert, type ToolUpsert,
} from "@/lib/api"; } from "@/lib/api";
type ToolKind = "http" | "client" | "task"; type ToolKind = "http" | "client";
type HttpMethod = HttpToolDefinition["config"]["method"]; type HttpMethod = HttpToolDefinition["config"]["method"];
type ToolFilter = "全部" | "HTTP" | "Client" | "MCP" | "后台任务"; type ToolFilter = "全部" | "HTTP" | "Client" | "MCP";
type SortOrder = "newest" | "oldest"; type SortOrder = "newest" | "oldest";
type ToolResource = type ToolResource =
| { kind: "tool"; id: string; tool: Tool } | { kind: "tool"; id: string; tool: Tool }
@@ -80,7 +80,6 @@ const toolFilters: readonly ToolFilter[] = [
"HTTP", "HTTP",
"Client", "Client",
"MCP", "MCP",
"后台任务",
]; ];
type ToolForm = { type ToolForm = {
@@ -91,9 +90,6 @@ type ToolForm = {
status: ToolStatus; status: ToolStatus;
method: HttpMethod; method: HttpMethod;
url: string; url: string;
directory: string;
username: string;
password: string;
timeoutSeconds: string; timeoutSeconds: string;
allowInterruptions: boolean; allowInterruptions: boolean;
executionMode: ToolExecutionMode; executionMode: ToolExecutionMode;
@@ -118,9 +114,6 @@ function blankForm(): ToolForm {
status: "active", status: "active",
method: "GET", method: "GET",
url: "", url: "",
directory: "",
username: "opencode",
password: "",
timeoutSeconds: "15", timeoutSeconds: "15",
allowInterruptions: true, allowInterruptions: true,
executionMode: "immediate", executionMode: "immediate",
@@ -147,14 +140,6 @@ function formFromTool(tool: Tool): ToolForm {
base.description = tool.description; base.description = tool.description;
base.status = tool.status; base.status = tool.status;
if (tool.definition.type === "mcp") return base; if (tool.definition.type === "mcp") return base;
if (tool.definition.type === "task") {
base.url = tool.definition.config.url;
base.directory = tool.definition.config.directory;
base.username = tool.definition.config.username;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
base.password = String(tool.secrets.password ?? "");
return base;
}
if (tool.definition.type === "client") { if (tool.definition.type === "client") {
base.allowInterruptions = base.allowInterruptions =
tool.definition.config.allowInterruptions ?? true; tool.definition.config.allowInterruptions ?? true;
@@ -226,21 +211,6 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线"); throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线");
} }
if (form.type === "task") {
const timeoutSeconds = Number(form.timeoutSeconds);
if (!form.url.trim() || !form.directory.trim()) throw new Error("请输入远程服务地址和工作目录");
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 30 || timeoutSeconds > 7200) throw new Error("任务时限须为 30-7200 秒");
return {
name: form.name.trim(), functionName: form.functionName,
description: form.description.trim(), status: form.status,
definition: { schemaVersion: 2, type: "task", config: {
provider: "opencode", url: form.url.trim(), directory: form.directory.trim(),
username: form.username.trim() || "opencode", timeoutSeconds,
allowInterruptions: true, executionMode: "immediate",
} },
secrets: { password: form.password },
};
}
if (form.type === "client") { if (form.type === "client") {
const parsedTimeoutSeconds = Number(form.timeoutSeconds); const parsedTimeoutSeconds = Number(form.timeoutSeconds);
const hasValidTimeout = const hasValidTimeout =
@@ -398,8 +368,7 @@ export function ComponentsToolsPage() {
return ( return (
(filter === "全部" || (filter === "全部" ||
(filter === "HTTP" && tool.type === "http") || (filter === "HTTP" && tool.type === "http") ||
(filter === "Client" && tool.type === "client") || (filter === "Client" && tool.type === "client")) &&
(filter === "后台任务" && tool.type === "task")) &&
(!query || (!query ||
[tool.name, tool.functionName, tool.description].some((value) => [tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query), value.toLowerCase().includes(query),
@@ -548,7 +517,7 @@ export function ComponentsToolsPage() {
> >
{resource.kind === "mcp" {resource.kind === "mcp"
? "MCP" ? "MCP"
: resource.tool.type === "task" ? "后台任务" : resource.tool.type === "client" : resource.tool.type === "client"
? "Client" ? "Client"
: "HTTP"} : "HTTP"}
</Badge> </Badge>
@@ -755,9 +724,7 @@ export function ComponentsToolsPage() {
executionMode: "async" as const, executionMode: "async" as const,
responseWaitMode: "timeout" as const, responseWaitMode: "timeout" as const,
} }
: type === "http" : { timeoutSeconds: "15", executionMode: "immediate" as const }),
? { timeoutSeconds: "15", executionMode: "immediate" as const }
: { timeoutSeconds: "600" }),
})) }))
} }
> >
@@ -765,7 +732,6 @@ export function ComponentsToolsPage() {
<SelectContent> <SelectContent>
<SelectItem value="http">HTTP</SelectItem> <SelectItem value="http">HTTP</SelectItem>
<SelectItem value="client">Client Tool</SelectItem> <SelectItem value="client">Client Tool</SelectItem>
<SelectItem value="task">后台任务 · OpenCode</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
@@ -806,9 +772,7 @@ export function ComponentsToolsPage() {
</FieldSection> </FieldSection>
<FieldSection title="参数配置" scrollable tall> <FieldSection title="参数配置" scrollable tall>
{form.type === "task" ? ( {form.type === "client" ? (
<TaskFields form={form} setForm={setForm} />
) : form.type === "client" ? (
<ClientToolFields form={form} setForm={setForm} /> <ClientToolFields form={form} setForm={setForm} />
) : ( ) : (
<HttpFields form={form} setForm={setForm} /> <HttpFields form={form} setForm={setForm} />
@@ -1039,14 +1003,3 @@ function ToolRuntimeFields({
</div> </div>
); );
} }
function TaskFields({ form, setForm }: { form: ToolForm; setForm: React.Dispatch<React.SetStateAction<ToolForm>> }) {
return <div className="space-y-4">
<p className="text-sm leading-6 text-muted-foreground">助手把任务交给远程 OpenCode 执行,执行期间可继续对话。仅支持提示词 Pipeline,每个助手最多绑定一个。</p>
<Field label="服务地址" required><Input value={form.url} placeholder="https://coding.example.com" onChange={e => setForm(v => ({ ...v, url: e.target.value }))} /></Field>
<Field label="远端工作目录" required><Input value={form.directory} placeholder="/workspace/my-project" onChange={e => setForm(v => ({ ...v, directory: e.target.value }))} /></Field>
<Field label="用户名"><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} /></Field>
<Field label="服务密码"><Input type="password" autoComplete="new-password" value={form.password} onChange={e => setForm(v => ({ ...v, password: e.target.value }))} /><span className="text-xs text-muted-foreground">对应 OPENCODE_SERVER_PASSWORD;编辑时留空保留原密码。</span></Field>
<Field label="等待时限(秒)"><Input type="number" min={30} max={7200} value={form.timeoutSeconds} onChange={e => setForm(v => ({ ...v, timeoutSeconds: e.target.value }))} /><p className="text-xs text-muted-foreground">含排队时间;重启或重新观察任务后重新计时。</p></Field>
</div>;
}
@@ -99,6 +99,7 @@ function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
knowledgeBaseId: null, knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
toolBindings: [], toolBindings: [],
taskBackend: null,
builtInTools: {}, builtInTools: {},
prompt: "", prompt: "",
dynamicVariableDefinitions: {}, dynamicVariableDefinitions: {},
@@ -102,6 +102,7 @@ function baseUpsertFromTemplate(
knowledgeBaseId: null, knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
toolBindings: [], toolBindings: [],
taskBackend: null,
builtInTools: {}, builtInTools: {},
prompt: template.prompt, prompt: template.prompt,
dynamicVariableDefinitions: {}, dynamicVariableDefinitions: {},
@@ -0,0 +1,114 @@
"use client";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import Link from "next/link";
import { Plus, Trash2 } from "lucide-react";
import { ToolBindingEditor } from "@/components/tools/tool-binding-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ModelResource, ReasoningEffort, Tool } from "@/lib/api";
import type { TaskBackendDraft } from "./backend-form";
export function BackendField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return <label className="block space-y-2">
<span className="text-sm font-medium text-foreground">{label}</span>
{children}
{hint && <span className="block text-xs leading-5 text-muted-foreground">{hint}</span>}
</label>;
}
type FieldsProps = { draft: TaskBackendDraft; setDraft: Dispatch<SetStateAction<TaskBackendDraft>> };
const effortLabels: Record<ReasoningEffort, string> = {
provider_default: "模型默认", low: "低 · low", medium: "中 · medium", high: "高 · high",
};
export function ReasonerFields({ draft, setDraft, models, tools }: FieldsProps & { models: ModelResource[]; tools: Tool[] }) {
const availableModels = models.filter(model => model.enabled && model.capability === "LLM" &&
["openai-llm", "dashscope-llm"].includes(model.interfaceType));
const selectedModel = models.find(model => model.id === draft.modelResourceId);
const supportsEffort = selectedModel?.values.supportsReasoningEffort === true;
const serverTools = tools.filter(tool => tool.type === "http" || tool.type === "mcp");
return <div className="space-y-5">
<BackendField label="推理模型" hint="模型与 Speaker 分别运行,各自使用独立的上下文。支持 OpenAI-compatible LLM 接口。">
<Select value={draft.modelResourceId} onValueChange={modelResourceId => setDraft(previous => ({
...previous, modelResourceId,
reasoningEffort: models.find(model => model.id === modelResourceId)?.values.supportsReasoningEffort === true
? previous.reasoningEffort : "provider_default",
}))}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择模型资源" /></SelectTrigger>
<SelectContent>
{draft.modelResourceId && !availableModels.some(model => model.id === draft.modelResourceId) &&
<SelectItem value={draft.modelResourceId}>{selectedModel?.name ?? "已失效模型"}(不可用)</SelectItem>}
{availableModels.map(model => <SelectItem key={model.id} value={model.id}>{model.name}</SelectItem>)}
</SelectContent>
</Select>
{!availableModels.length && <Link href="/components/models" className="text-xs underline">先创建 LLM 模型资源</Link>}
</BackendField>
<BackendField label="推理强度" hint={supportsEffort
? "使用模型接口支持的 reasoning_effort 参数;需确认模型真实支持所选强度。"
: "当前模型未声明支持推理强度,使用模型默认配置。可在模型资源中声明接口能力。"}>
<Select value={draft.reasoningEffort} onValueChange={value => setDraft(previous => ({ ...previous, reasoningEffort: value as ReasoningEffort }))}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(effortLabels).map(([value, label]) =>
<SelectItem key={value} value={value} disabled={value !== "provider_default" && !supportsEffort}>{label}</SelectItem>)}</SelectContent>
</Select>
{!supportsEffort && draft.reasoningEffort !== "provider_default" &&
<span role="alert" className="text-xs text-destructive">保存前请改为模型默认,或选择已声明支持推理强度的模型。</span>}
</BackendField>
<div className="space-y-3 rounded-2xl border border-hairline p-4">
<div className="text-sm font-medium">可用工具</div>
<p className="text-xs leading-5 text-muted-foreground">仅允许配置的 HTTP / MCP 服务端工具。浏览器交互工具由 Speaker 使用;技能不能增加工具权限。</p>
<ToolBindingEditor tools={serverTools} selectableTools={serverTools} value={draft.toolBindings} variables={{}}
onChange={toolBindings => setDraft(previous => ({ ...previous, toolBindings }))} />
</div>
<div className="space-y-3 rounded-2xl border border-hairline p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">技能说明</span>
<Button type="button" variant="outline" size="sm" onClick={() => setDraft(previous => ({ ...previous, skills: [...previous.skills, { name: "", content: "" }] }))}>
<Plus size={14} />添加技能
</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">作为任务方法加载到 Reasoner;工具执行仍受上方许可约束。</p>
{draft.skills.map((skill, index) => <div key={index} className="space-y-2 border-t border-hairline pt-3">
<div className="flex gap-2">
<Input aria-label={`技能 ${index + 1} 名称`} value={skill.name} placeholder="技能名称"
onChange={event => setDraft(previous => ({ ...previous, skills: previous.skills.map((item, i) => i === index ? { ...item, name: event.target.value } : item) }))} />
<Button type="button" variant="ghost" size="icon" aria-label={`移除技能 ${index + 1}`}
onClick={() => setDraft(previous => ({ ...previous, skills: previous.skills.filter((_, i) => i !== index) }))}><Trash2 size={15} /></Button>
</div>
<Textarea aria-label={`技能 ${index + 1} 内容`} rows={4} value={skill.content} placeholder="任务说明与执行方法"
onChange={event => setDraft(previous => ({ ...previous, skills: previous.skills.map((item, i) => i === index ? { ...item, content: event.target.value } : item) }))} />
</div>)}
</div>
</div>;
}
export function OpenCodeFields({ draft, setDraft, hasPassword }: FieldsProps & { hasPassword: boolean }) {
function textField(key: "url" | "directory" | "username" | "password" | "agent" | "modelProviderId" | "modelId", label: string, hint?: string, placeholder?: string) {
return <BackendField label={label} hint={hint}>
<Input value={draft[key]} type={key === "password" ? "password" : "text"} autoComplete={key === "password" ? "new-password" : undefined}
placeholder={placeholder} onChange={event => setDraft(previous => ({ ...previous, [key]: event.target.value }))} />
</BackendField>;
}
return <div className="space-y-4">
{textField("url", "服务地址", "由平台后端访问;同机宿主运行可填写 http://127.0.0.1:4096。", "http://127.0.0.1:4096")}
{textField("directory", "远端工作目录", "使用 OpenCode 所在机器上的绝对路径。工作目录不等同于沙箱。", "/workspace/my-project")}
<div className="grid gap-4 sm:grid-cols-2">
{textField("username", "服务用户名")}
{textField("password", "服务密码", hasPassword ? "已设置密码;留空保留原凭据。" : "对应 OPENCODE_SERVER_PASSWORD。")}
</div>
{textField("agent", "远端 Agent(可选)", "留空使用远端默认 Agent;执行权限由该 Agent 与运行环境管理。", "build")}
<div className="grid gap-4 sm:grid-cols-2">
{textField("modelProviderId", "远端模型 Provider ID(可选)", "与 Model ID 同时填写,留空使用远端默认模型。", "anthropic")}
{textField("modelId", "远端 Model ID(可选)", "模型凭据在远端配置。")}
</div>
<BackendField label="远端技能说明(可选)" hint="记录远端部署的技能及配置来源;此处不会上传或安装技能。">
<Textarea rows={3} value={draft.skillsNote} onChange={event => setDraft(previous => ({ ...previous, skillsNote: event.target.value }))} />
</BackendField>
<div className="rounded-xl border border-hairline bg-canvas-soft p-3 text-xs leading-6 text-muted-foreground">
文件、Shell、沙箱与技能权限由远端 OpenCode Agent 和运行账号管理。服务密码只用于连接服务,不会授予额外的执行权限。
</div>
</div>;
}
@@ -0,0 +1,84 @@
import type {
ReasoningEffort,
TaskBackendResource,
TaskBackendType,
TaskBackendUpsert,
TaskSkill,
ToolBinding,
} from "@/lib/api";
/** Keep both branches in the draft so switching a new resource's type keeps input. */
export type TaskBackendDraft = {
name: string;
type: TaskBackendType;
description: string;
enabled: boolean;
instructions: string;
timeoutSeconds: string;
modelResourceId: string;
reasoningEffort: ReasoningEffort;
toolBindings: ToolBinding[];
skills: TaskSkill[];
url: string;
directory: string;
username: string;
password: string;
agent: string;
modelProviderId: string;
modelId: string;
skillsNote: string;
};
export function emptyBackendDraft(): TaskBackendDraft {
return {
name: "", type: "reasoner", description: "", enabled: true,
instructions: "", timeoutSeconds: "600", modelResourceId: "",
reasoningEffort: "provider_default", toolBindings: [], skills: [],
url: "", directory: "", username: "opencode", password: "", agent: "",
modelProviderId: "", modelId: "", skillsNote: "",
};
}
export function draftFromBackend(resource: TaskBackendResource): TaskBackendDraft {
const draft = {
...emptyBackendDraft(), name: resource.name, type: resource.type,
description: resource.description, enabled: resource.enabled,
instructions: resource.config.instructions,
timeoutSeconds: String(resource.config.timeoutSeconds),
};
if (resource.type === "reasoner") {
return { ...draft, ...resource.config, timeoutSeconds: draft.timeoutSeconds };
}
// An empty password tells the server to retain the stored credential.
return { ...draft, ...resource.config, password: "", timeoutSeconds: draft.timeoutSeconds };
}
export function backendPayload(draft: TaskBackendDraft): TaskBackendUpsert {
if (!draft.name.trim()) throw new Error("请输入任务后端名称");
const timeoutSeconds = Number(draft.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 30 || timeoutSeconds > 7200) {
throw new Error("任务时限须为 30–7200 秒的整数");
}
const base = { name: draft.name.trim(), description: draft.description.trim(), enabled: draft.enabled };
const common = { instructions: draft.instructions, timeoutSeconds, maxConcurrency: 1 as const };
if (draft.type === "reasoner") {
if (!draft.modelResourceId) throw new Error("请选择推理模型");
if (draft.skills.some(skill => !skill.name.trim() || !skill.content.trim())) {
throw new Error("请填写每项技能的名称与任务说明,或移除空技能");
}
return {
...base, type: "reasoner", secrets: {},
config: { ...common, modelResourceId: draft.modelResourceId, reasoningEffort: draft.reasoningEffort,
toolBindings: draft.toolBindings, skills: draft.skills.map(skill => ({ name: skill.name.trim(), content: skill.content })) },
};
}
if (!draft.url.trim() || !draft.directory.trim()) throw new Error("请输入服务地址和远端工作目录");
if (Boolean(draft.modelProviderId.trim()) !== Boolean(draft.modelId.trim())) {
throw new Error("远端模型的 Provider ID 和 Model ID 需要同时填写或同时留空");
}
return {
...base, type: "opencode", secrets: { password: draft.password },
config: { ...common, url: draft.url.trim(), directory: draft.directory.trim(), username: draft.username.trim() || "opencode",
agent: draft.agent.trim(), modelProviderId: draft.modelProviderId.trim(), modelId: draft.modelId.trim(), skillsNote: draft.skillsNote },
};
}
@@ -150,7 +150,7 @@ function toolParametersFromDefinition(tool: Tool): EditorToolOption["parameters"
return []; return [];
} }
function toEditorToolOption(tool: Tool & { type: Exclude<Tool["type"], "task"> }): EditorToolOption { function toEditorToolOption(tool: Tool): EditorToolOption {
return { return {
id: tool.id, id: tool.id,
functionName: tool.functionName || tool.name, functionName: tool.functionName || tool.name,
@@ -206,7 +206,7 @@ export function NextReplyEditorBody({
try { try {
const list = await toolsApi.list(); const list = await toolsApi.list();
if (cancelled) return; if (cancelled) return;
setLoadedTools(list.filter((tool): tool is Tool & { type: Exclude<Tool["type"], "task"> } => tool.type !== "task").map(toEditorToolOption)); setLoadedTools(list.map(toEditorToolOption));
} catch { } catch {
if (!cancelled) setLoadedTools([]); if (!cancelled) setLoadedTools([]);
} }
@@ -58,7 +58,6 @@ const TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
http: "HTTP", http: "HTTP",
mcp: "MCP", mcp: "MCP",
client: "Client", client: "Client",
task: "后台任务",
}; };
type ResultPathSuggestion = { type ResultPathSuggestion = {
@@ -147,9 +146,6 @@ function suggestedResultVariable(tool: Tool): {
} }
function parametersFor(tool: Tool): ToolParameter[] { function parametersFor(tool: Tool): ToolParameter[] {
if (tool.definition.type === "task") {
return [{ name: "instruction", type: "string", location: "body", description: "远程任务要求", required: true }];
}
if (tool.definition.type === "mcp") { if (tool.definition.type === "mcp") {
const schema = tool.definition.config.inputSchema as { const schema = tool.definition.config.inputSchema as {
properties?: Record<string, { type?: string; description?: string }>; properties?: Record<string, { type?: string; description?: string }>;
@@ -539,7 +535,6 @@ export function ToolBindingEditor({
onChange, onChange,
allowModel = true, allowModel = true,
maxBindings, maxBindings,
allowTask = false,
}: { }: {
tools: Tool[]; tools: Tool[];
selectableTools?: Tool[]; selectableTools?: Tool[];
@@ -548,7 +543,6 @@ export function ToolBindingEditor({
onChange: (value: ToolBinding[]) => void; onChange: (value: ToolBinding[]) => void;
allowModel?: boolean; allowModel?: boolean;
maxBindings?: number; maxBindings?: number;
allowTask?: boolean;
}) { }) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [openToolId, setOpenToolId] = useState<string | null>(null); const [openToolId, setOpenToolId] = useState<string | null>(null);
@@ -556,16 +550,14 @@ export function ToolBindingEditor({
() => tools.filter((tool) => tool.status === "active"), () => tools.filter((tool) => tool.status === "active"),
[tools], [tools],
); );
const selectedTaskId = value.find(binding => tools.find(tool => tool.id === binding.toolId)?.type === "task")?.toolId;
const pickerTools = useMemo( const pickerTools = useMemo(
() => (selectableTools ?? tools).filter((tool) => tool.status === "active" && (allowTask || tool.type !== "task") && (tool.type !== "task" || !selectedTaskId || tool.id === selectedTaskId)), () => (selectableTools ?? tools).filter((tool) => tool.status === "active"),
[selectableTools, tools, allowTask, selectedTaskId], [selectableTools, tools],
); );
const selectedIds = new Set(value.map((binding) => binding.toolId)); const selectedIds = new Set(value.map((binding) => binding.toolId));
const variableNames = [...Object.keys(variables), ...SYSTEM_VARIABLE_NAMES]; const variableNames = [...Object.keys(variables), ...SYSTEM_VARIABLE_NAMES];
function addTool(tool: Tool) { function addTool(tool: Tool) {
if (tool.type === "task" && value.some(binding => tools.find(item => item.id === binding.toolId)?.type === "task")) return;
const binding = defaultToolBinding(tool); const binding = defaultToolBinding(tool);
if (!allowModel) { if (!allowModel) {
binding.argumentBindings = Object.fromEntries( binding.argumentBindings = Object.fromEntries(
@@ -743,14 +735,14 @@ export function ToolBindingEditor({
); );
})} })}
{tool.type !== "task" && <ResultAssignmentsEditor <ResultAssignmentsEditor
tool={tool} tool={tool}
value={binding.resultAssignments} value={binding.resultAssignments}
variables={variables} variables={variables}
onChange={(resultAssignments) => onChange={(resultAssignments) =>
updateBinding(index, { ...binding, resultAssignments }) updateBinding(index, { ...binding, resultAssignments })
} }
/>} />
</div> </div>
)} )}
</div> </div>
+56 -18
View File
@@ -406,6 +406,7 @@ export type Assistant = {
knowledgeBaseId: string | null; knowledgeBaseId: string | null;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig; knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
toolBindings: ToolBinding[]; toolBindings: ToolBinding[];
taskBackend: AssistantTaskBackend | null;
builtInTools: BuiltInTools; builtInTools: BuiltInTools;
prompt: string; prompt: string;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>; dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
@@ -623,31 +624,16 @@ export type McpToolDefinition = {
}; };
}; };
export type TaskToolDefinition = {
schemaVersion: number;
type: "task";
config: {
provider: "opencode";
url: string;
directory: string;
username: string;
timeoutSeconds: number;
allowInterruptions: true;
executionMode: "immediate";
};
};
export type Tool = { export type Tool = {
id: string; id: string;
name: string; name: string;
functionName: string; functionName: string;
type: "http" | "mcp" | "client" | "task"; type: "http" | "mcp" | "client";
description: string; description: string;
definition: definition:
| HttpToolDefinition | HttpToolDefinition
| ClientToolDefinition | ClientToolDefinition
| McpToolDefinition | McpToolDefinition;
| TaskToolDefinition;
secrets: Record<string, unknown>; secrets: Record<string, unknown>;
status: ToolStatus; status: ToolStatus;
mcpServerId?: string | null; mcpServerId?: string | null;
@@ -982,12 +968,64 @@ export const batchRunsApi = {
), ),
}; };
// ---------- 任务后端资源 ----------
export type TaskBackendType = "reasoner" | "opencode";
export type ReasoningEffort = "provider_default" | "low" | "medium" | "high";
export type TaskSkill = { name: string; content: string };
export type TaskBackendCommonConfig = {
instructions: string;
timeoutSeconds: number;
maxConcurrency: 1;
};
export type ReasonerBackendConfig = TaskBackendCommonConfig & {
modelResourceId: string;
reasoningEffort: ReasoningEffort;
toolBindings: ToolBinding[];
skills: TaskSkill[];
};
export type OpenCodeBackendConfig = TaskBackendCommonConfig & {
url: string;
directory: string;
username: string;
agent: string;
modelProviderId: string;
modelId: string;
skillsNote: string;
};
type TaskBackendKind =
| { type: "reasoner"; config: ReasonerBackendConfig }
| { type: "opencode"; config: OpenCodeBackendConfig };
export type TaskBackendUpsert = TaskBackendKind & {
name: string;
description: string;
secrets: Record<string, unknown>;
enabled: boolean;
};
export type TaskBackendResource = TaskBackendUpsert & {
id: string;
updatedAt?: string | null;
};
export type AssistantTaskBackend = {
backendId: string;
instructions: string;
instructionSource: ValueSource;
};
export const taskBackendsApi = {
list: () => request<TaskBackendResource[]>("/api/task-backends"),
create: (body: TaskBackendUpsert) => request<TaskBackendResource>("/api/task-backends", { method: "POST", body: JSON.stringify(body) }),
update: (id: string, body: TaskBackendUpsert) => request<TaskBackendResource>(`/api/task-backends/${id}`, { method: "PUT", body: JSON.stringify(body) }),
duplicate: (id: string) => request<TaskBackendResource>(`/api/task-backends/${id}/duplicate`, { method: "POST" }),
remove: (id: string) => request<{ ok: boolean }>(`/api/task-backends/${id}`, { method: "DELETE" }),
};
// ---------- 提示词后台任务 ---------- // ---------- 提示词后台任务 ----------
export type BackgroundTask = { export type BackgroundTask = {
id: string; id: string;
assistantId: string; assistantId: string;
conversationId: string; conversationId: string;
toolId: string; backendId: string | null;
backendType: TaskBackendType;
toolId?: string | null;
instruction: string; instruction: string;
status: "queued" | "submitted" | "running" | "waiting_input" | "cancel_requested" | "succeeded" | "failed" | "cancelled" | "unknown"; status: "queued" | "submitted" | "running" | "waiting_input" | "cancel_requested" | "succeeded" | "failed" | "cancelled" | "unknown";
summary: string; summary: string;