F3: configure Speaker and task backends in prompt mode
This commit is contained in:
@@ -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="shrink-0 rounded-full bg-surface-strong px-2 py-1">{labels[task.status]}</span>
|
||||
</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">
|
||||
<p>远端请求:{request.permission} {request.patterns?.join("、")}</p>
|
||||
<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>
|
||||
</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.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>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
</article>;
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||
import { SectionCard } from "@/components/editor/section-card";
|
||||
import { VisionConfigSection } from "@/components/editor/vision-config-section";
|
||||
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 { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -90,7 +91,6 @@ const EXTERNAL_TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
|
||||
http: "HTTP",
|
||||
mcp: "MCP",
|
||||
client: "Client",
|
||||
task: "后台任务",
|
||||
};
|
||||
|
||||
const OPENING_MODE_OPTIONS: Array<{
|
||||
@@ -126,6 +126,7 @@ const ENTRY_MODE_OPTIONS: Array<{
|
||||
const promptSections = [
|
||||
{ id: "conversation", label: "对话内容" },
|
||||
{ id: "models", label: "模型与语音" },
|
||||
{ id: "tasks", label: "任务后端" },
|
||||
{ id: "capabilities", label: "知识与工具" },
|
||||
{ id: "interaction", label: "交互策略" },
|
||||
{ id: "variables", label: "动态变量" },
|
||||
@@ -191,6 +192,7 @@ export function PromptEditor({
|
||||
const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({
|
||||
conversation: null,
|
||||
models: null,
|
||||
tasks: null,
|
||||
capabilities: null,
|
||||
interaction: null,
|
||||
variables: null,
|
||||
@@ -640,6 +642,13 @@ export function PromptEditor({
|
||||
<RuntimeModeSelector
|
||||
value={form.runtimeMode}
|
||||
onChange={(runtimeMode) => {
|
||||
if (runtimeMode === "realtime" && form.taskBackend) {
|
||||
const confirmed = window.confirm(
|
||||
"Realtime 暂不支持任务后端,切换会解除当前助手的后端绑定,后端资源仍会保留。是否继续?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
updateForm("taskBackend", null);
|
||||
}
|
||||
updateForm("runtimeMode", runtimeMode);
|
||||
if (
|
||||
runtimeMode === "realtime" &&
|
||||
@@ -662,7 +671,7 @@ export function PromptEditor({
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<>
|
||||
<ResourceSelectField
|
||||
label="大语言模型"
|
||||
label="对话模型 Speaker"
|
||||
value={form.model}
|
||||
onChange={handlePromptModelChange}
|
||||
options={llmOptions}
|
||||
@@ -695,6 +704,21 @@ export function PromptEditor({
|
||||
</SectionCard>
|
||||
</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
|
||||
ref={(element) => {
|
||||
sectionRefs.current.capabilities = element;
|
||||
@@ -882,7 +906,6 @@ export function PromptEditor({
|
||||
description="从资源库添加工具,并配置当前助手的参数来源与结果写入"
|
||||
>
|
||||
<ToolBindingEditor
|
||||
allowTask={form.runtimeMode === "pipeline"}
|
||||
tools={tools}
|
||||
selectableTools={
|
||||
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 {
|
||||
AnalysisConfig,
|
||||
AssistantTaskBackend,
|
||||
DynamicVariableDefinition,
|
||||
KnowledgeRetrievalConfig,
|
||||
StartupConfig,
|
||||
@@ -32,5 +33,6 @@ export type AssistantForm = {
|
||||
visionPromptMode: VisionPromptMode;
|
||||
visionPrompt: string;
|
||||
toolBindings: ToolBinding[];
|
||||
taskBackend: AssistantTaskBackend | null;
|
||||
builtInTools: BuiltInTools;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PlayCircle,
|
||||
User,
|
||||
Video,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/AuthProvider";
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ const componentSubItems: NavItem[] = [
|
||||
{ href: "/components/models", label: "模型资源", icon: Brain },
|
||||
{ href: "/components/knowledge", label: "知识库", icon: Database },
|
||||
{ href: "/components/tools", label: "工具资源", icon: Wrench },
|
||||
{ href: "/components/task-backends", label: "任务后端", icon: Workflow },
|
||||
];
|
||||
|
||||
const monitorSubItems: NavItem[] = [
|
||||
|
||||
@@ -165,6 +165,7 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
visionPromptMode: "runtime",
|
||||
visionPrompt: "",
|
||||
toolBindings: [],
|
||||
taskBackend: null,
|
||||
builtInTools: {},
|
||||
};
|
||||
}
|
||||
@@ -266,9 +267,16 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
} = useWorkflowEditorState();
|
||||
|
||||
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.toolBindings, form.builtInTools, form.startup],
|
||||
[form.toolBindings, form.builtInTools, form.startup, form.taskBackend],
|
||||
);
|
||||
const effectiveWorkflowDynamicVariableDefinitions =
|
||||
activeWorkflowDynamicVariableDefinitions(
|
||||
@@ -407,6 +415,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
visionPromptMode: a.visionPromptMode ?? "runtime",
|
||||
visionPrompt: a.visionPrompt ?? "",
|
||||
toolBindings: a.toolBindings ?? [],
|
||||
taskBackend: a.taskBackend ?? null,
|
||||
builtInTools: a.builtInTools ?? {},
|
||||
};
|
||||
setForm(next);
|
||||
@@ -463,6 +472,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
knowledgeBaseId: null,
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolBindings: [],
|
||||
taskBackend: null,
|
||||
builtInTools: {},
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
@@ -531,6 +541,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
|
||||
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
|
||||
toolBindings: form.toolBindings,
|
||||
taskBackend: form.runtimeMode === "pipeline" ? form.taskBackend : null,
|
||||
builtInTools: form.builtInTools,
|
||||
prompt: form.prompt,
|
||||
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,
|
||||
} from "@/lib/api";
|
||||
|
||||
type ToolKind = "http" | "client" | "task";
|
||||
type ToolKind = "http" | "client";
|
||||
type HttpMethod = HttpToolDefinition["config"]["method"];
|
||||
type ToolFilter = "全部" | "HTTP" | "Client" | "MCP" | "后台任务";
|
||||
type ToolFilter = "全部" | "HTTP" | "Client" | "MCP";
|
||||
type SortOrder = "newest" | "oldest";
|
||||
type ToolResource =
|
||||
| { kind: "tool"; id: string; tool: Tool }
|
||||
@@ -80,7 +80,6 @@ const toolFilters: readonly ToolFilter[] = [
|
||||
"HTTP",
|
||||
"Client",
|
||||
"MCP",
|
||||
"后台任务",
|
||||
];
|
||||
|
||||
type ToolForm = {
|
||||
@@ -91,9 +90,6 @@ type ToolForm = {
|
||||
status: ToolStatus;
|
||||
method: HttpMethod;
|
||||
url: string;
|
||||
directory: string;
|
||||
username: string;
|
||||
password: string;
|
||||
timeoutSeconds: string;
|
||||
allowInterruptions: boolean;
|
||||
executionMode: ToolExecutionMode;
|
||||
@@ -118,9 +114,6 @@ function blankForm(): ToolForm {
|
||||
status: "active",
|
||||
method: "GET",
|
||||
url: "",
|
||||
directory: "",
|
||||
username: "opencode",
|
||||
password: "",
|
||||
timeoutSeconds: "15",
|
||||
allowInterruptions: true,
|
||||
executionMode: "immediate",
|
||||
@@ -147,14 +140,6 @@ function formFromTool(tool: Tool): ToolForm {
|
||||
base.description = tool.description;
|
||||
base.status = tool.status;
|
||||
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") {
|
||||
base.allowInterruptions =
|
||||
tool.definition.config.allowInterruptions ?? true;
|
||||
@@ -226,21 +211,6 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
|
||||
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") {
|
||||
const parsedTimeoutSeconds = Number(form.timeoutSeconds);
|
||||
const hasValidTimeout =
|
||||
@@ -398,8 +368,7 @@ export function ComponentsToolsPage() {
|
||||
return (
|
||||
(filter === "全部" ||
|
||||
(filter === "HTTP" && tool.type === "http") ||
|
||||
(filter === "Client" && tool.type === "client") ||
|
||||
(filter === "后台任务" && tool.type === "task")) &&
|
||||
(filter === "Client" && tool.type === "client")) &&
|
||||
(!query ||
|
||||
[tool.name, tool.functionName, tool.description].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
@@ -548,7 +517,7 @@ export function ComponentsToolsPage() {
|
||||
>
|
||||
{resource.kind === "mcp"
|
||||
? "MCP"
|
||||
: resource.tool.type === "task" ? "后台任务" : resource.tool.type === "client"
|
||||
: resource.tool.type === "client"
|
||||
? "Client"
|
||||
: "HTTP"}
|
||||
</Badge>
|
||||
@@ -755,9 +724,7 @@ export function ComponentsToolsPage() {
|
||||
executionMode: "async" as const,
|
||||
responseWaitMode: "timeout" as const,
|
||||
}
|
||||
: type === "http"
|
||||
? { timeoutSeconds: "15", executionMode: "immediate" as const }
|
||||
: { timeoutSeconds: "600" }),
|
||||
: { timeoutSeconds: "15", executionMode: "immediate" as const }),
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -765,7 +732,6 @@ export function ComponentsToolsPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="client">Client Tool</SelectItem>
|
||||
<SelectItem value="task">后台任务 · OpenCode</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
@@ -806,9 +772,7 @@ export function ComponentsToolsPage() {
|
||||
</FieldSection>
|
||||
|
||||
<FieldSection title="参数配置" scrollable tall>
|
||||
{form.type === "task" ? (
|
||||
<TaskFields form={form} setForm={setForm} />
|
||||
) : form.type === "client" ? (
|
||||
{form.type === "client" ? (
|
||||
<ClientToolFields form={form} setForm={setForm} />
|
||||
) : (
|
||||
<HttpFields form={form} setForm={setForm} />
|
||||
@@ -1039,14 +1003,3 @@ function ToolRuntimeFields({
|
||||
</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,
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolBindings: [],
|
||||
taskBackend: null,
|
||||
builtInTools: {},
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
|
||||
@@ -102,6 +102,7 @@ function baseUpsertFromTemplate(
|
||||
knowledgeBaseId: null,
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolBindings: [],
|
||||
taskBackend: null,
|
||||
builtInTools: {},
|
||||
prompt: template.prompt,
|
||||
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 [];
|
||||
}
|
||||
|
||||
function toEditorToolOption(tool: Tool & { type: Exclude<Tool["type"], "task"> }): EditorToolOption {
|
||||
function toEditorToolOption(tool: Tool): EditorToolOption {
|
||||
return {
|
||||
id: tool.id,
|
||||
functionName: tool.functionName || tool.name,
|
||||
@@ -206,7 +206,7 @@ export function NextReplyEditorBody({
|
||||
try {
|
||||
const list = await toolsApi.list();
|
||||
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 {
|
||||
if (!cancelled) setLoadedTools([]);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ const TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
|
||||
http: "HTTP",
|
||||
mcp: "MCP",
|
||||
client: "Client",
|
||||
task: "后台任务",
|
||||
};
|
||||
|
||||
type ResultPathSuggestion = {
|
||||
@@ -147,9 +146,6 @@ function suggestedResultVariable(tool: Tool): {
|
||||
}
|
||||
|
||||
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") {
|
||||
const schema = tool.definition.config.inputSchema as {
|
||||
properties?: Record<string, { type?: string; description?: string }>;
|
||||
@@ -539,7 +535,6 @@ export function ToolBindingEditor({
|
||||
onChange,
|
||||
allowModel = true,
|
||||
maxBindings,
|
||||
allowTask = false,
|
||||
}: {
|
||||
tools: Tool[];
|
||||
selectableTools?: Tool[];
|
||||
@@ -548,7 +543,6 @@ export function ToolBindingEditor({
|
||||
onChange: (value: ToolBinding[]) => void;
|
||||
allowModel?: boolean;
|
||||
maxBindings?: number;
|
||||
allowTask?: boolean;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [openToolId, setOpenToolId] = useState<string | null>(null);
|
||||
@@ -556,16 +550,14 @@ export function ToolBindingEditor({
|
||||
() => tools.filter((tool) => tool.status === "active"),
|
||||
[tools],
|
||||
);
|
||||
const selectedTaskId = value.find(binding => tools.find(tool => tool.id === binding.toolId)?.type === "task")?.toolId;
|
||||
const pickerTools = useMemo(
|
||||
() => (selectableTools ?? tools).filter((tool) => tool.status === "active" && (allowTask || tool.type !== "task") && (tool.type !== "task" || !selectedTaskId || tool.id === selectedTaskId)),
|
||||
[selectableTools, tools, allowTask, selectedTaskId],
|
||||
() => (selectableTools ?? tools).filter((tool) => tool.status === "active"),
|
||||
[selectableTools, tools],
|
||||
);
|
||||
const selectedIds = new Set(value.map((binding) => binding.toolId));
|
||||
const variableNames = [...Object.keys(variables), ...SYSTEM_VARIABLE_NAMES];
|
||||
|
||||
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);
|
||||
if (!allowModel) {
|
||||
binding.argumentBindings = Object.fromEntries(
|
||||
@@ -743,14 +735,14 @@ export function ToolBindingEditor({
|
||||
);
|
||||
})}
|
||||
|
||||
{tool.type !== "task" && <ResultAssignmentsEditor
|
||||
<ResultAssignmentsEditor
|
||||
tool={tool}
|
||||
value={binding.resultAssignments}
|
||||
variables={variables}
|
||||
onChange={(resultAssignments) =>
|
||||
updateBinding(index, { ...binding, resultAssignments })
|
||||
}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+56
-18
@@ -406,6 +406,7 @@ export type Assistant = {
|
||||
knowledgeBaseId: string | null;
|
||||
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||||
toolBindings: ToolBinding[];
|
||||
taskBackend: AssistantTaskBackend | null;
|
||||
builtInTools: BuiltInTools;
|
||||
prompt: string;
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
functionName: string;
|
||||
type: "http" | "mcp" | "client" | "task";
|
||||
type: "http" | "mcp" | "client";
|
||||
description: string;
|
||||
definition:
|
||||
| HttpToolDefinition
|
||||
| ClientToolDefinition
|
||||
| McpToolDefinition
|
||||
| TaskToolDefinition;
|
||||
| McpToolDefinition;
|
||||
secrets: Record<string, unknown>;
|
||||
status: ToolStatus;
|
||||
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 = {
|
||||
id: string;
|
||||
assistantId: string;
|
||||
conversationId: string;
|
||||
toolId: string;
|
||||
backendId: string | null;
|
||||
backendType: TaskBackendType;
|
||||
toolId?: string | null;
|
||||
instruction: string;
|
||||
status: "queued" | "submitted" | "running" | "waiting_input" | "cancel_requested" | "succeeded" | "failed" | "cancelled" | "unknown";
|
||||
summary: string;
|
||||
|
||||
Reference in New Issue
Block a user