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="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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user