F3: connect Prompt tools and web task controls to native Jobs

This commit is contained in:
Xin Wang
2026-10-23 16:14:17 +08:00
parent 33fde56edd
commit 9a4d48a86e
14 changed files with 362 additions and 23 deletions
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import { backgroundTasksApi, type BackgroundTask } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const labels: Record<BackgroundTask["status"], string> = {
queued: "已排队", submitted: "已提交", running: "执行中", waiting_input: "等待回复",
cancel_requested: "停止待确认", succeeded: "已完成", failed: "失败", cancelled: "已停止", unknown: "状态待核查",
};
const terminal = new Set(["succeeded", "failed", "cancelled"]);
function TaskCard({ task, onChanged }: { task: BackgroundTask; onChanged: () => Promise<void> }) {
const [answers, setAnswers] = useState<Record<number, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function act(operation: () => Promise<unknown>) {
setBusy(true);
setError("");
try { await operation(); await onChanged(); }
catch (error) { setError(error instanceof Error ? error.message : "操作失败"); }
finally { setBusy(false); }
}
const request = task.inputRequest;
return <article className="space-y-2 rounded-2xl border border-hairline bg-background p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<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>
{task.status === "waiting_input" && request.kind === "permission" && <div className="space-y-2">
<p>远端请求:{request.permission} {request.patterns?.join("、")}</p>
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => void act(() => backgroundTasksApi.input(task.id, { reply: "once" }))}>允许本次</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => void act(() => backgroundTasksApi.input(task.id, { reply: "reject" }))}>拒绝</Button>
</div>
</div>}
{task.status === "waiting_input" && request.kind === "question" && <div className="space-y-2">
{request.questions?.map((question, index) => <label key={index} className="block space-y-1">
<span>{question.question}</span>
{question.options?.length ? <span className="block text-muted-foreground">选项:{question.options.map(option => option.label).join("、")}</span> : null}
<Input aria-label={question.question} value={answers[index] ?? ""} placeholder={question.multiple ? "多个选项用逗号分隔" : "输入回答或选项名称"} onChange={event => setAnswers(value => ({ ...value, [index]: event.target.value }))} />
</label>)}
<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>}
{!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>;
}
export function BackgroundTaskPanel({ assistantId }: { assistantId: string | null }) {
const [tasks, setTasks] = useState<BackgroundTask[]>([]);
const [error, setError] = useState("");
useEffect(() => {
if (!assistantId) return;
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const tasks = await backgroundTasksApi.list(assistantId!);
if (active) { setTasks(tasks); setError(""); }
} catch (error) { if (active) setError(error instanceof Error ? error.message : "任务列表加载失败"); }
if (active) timer = setTimeout(load, 2500);
}
void load();
return () => { active = false; clearTimeout(timer); };
}, [assistantId]);
async function refresh() {
if (assistantId) setTasks(await backgroundTasksApi.list(assistantId));
}
if (!assistantId || (!tasks.length && !error)) return null;
return <details className="shrink-0 border-t border-hairline bg-card px-3 py-2">
<summary className="cursor-pointer text-xs text-muted-foreground">后台任务 {tasks.length ? `· ${tasks.filter(task => !terminal.has(task.status)).length} 项待处理` : ""}</summary>
<div className="mt-2 max-h-64 space-y-2 overflow-y-auto">
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{tasks.slice(0, 20).map(task => <TaskCard key={task.id} task={task} onChanged={refresh} />)}
</div>
</details>;
}
@@ -1,6 +1,7 @@
"use client";
import type React from "react";
import { BackgroundTaskPanel } from "@/components/assistant-editor/background-task-panel";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
AudioLines,
@@ -1246,6 +1247,7 @@ function DebugVoicePanel({
</div>
</div>
<BackgroundTaskPanel key={assistantId} assistantId={assistantId} />
<div className="shrink-0 border-t border-hairline bg-card p-3">
{callEndActive && (
<div
@@ -90,6 +90,7 @@ const EXTERNAL_TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
http: "HTTP",
mcp: "MCP",
client: "Client",
task: "后台任务",
};
const OPENING_MODE_OPTIONS: Array<{
@@ -220,7 +221,7 @@ export function PromptEditor({
(key) =>
Boolean(form.builtInTools[key]) && !allowedRealtimeBuiltIns.has(key),
);
const allowedRealtimeExternalTypes = new Set(
const allowedRealtimeExternalTypes = new Set<string>(
realtimeToolFeatures?.externalTypes ?? [],
);
const unsupportedToolBindings = form.toolBindings.filter((binding) => {
@@ -881,11 +882,12 @@ export function PromptEditor({
description="从资源库添加工具,并配置当前助手的参数来源与结果写入"
>
<ToolBindingEditor
allowTask={form.runtimeMode === "pipeline"}
tools={tools}
selectableTools={
form.runtimeMode === "realtime"
? tools.filter((tool) =>
realtimeToolFeatures?.externalTypes.includes(tool.type),
realtimeToolFeatures?.externalTypes.some(type => type === tool.type),
)
: tools
}
@@ -67,9 +67,9 @@ import {
type ToolUpsert,
} from "@/lib/api";
type ToolKind = "http" | "client";
type ToolKind = "http" | "client" | "task";
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,6 +80,7 @@ const toolFilters: readonly ToolFilter[] = [
"HTTP",
"Client",
"MCP",
"后台任务",
];
type ToolForm = {
@@ -90,6 +91,9 @@ type ToolForm = {
status: ToolStatus;
method: HttpMethod;
url: string;
directory: string;
username: string;
password: string;
timeoutSeconds: string;
allowInterruptions: boolean;
executionMode: ToolExecutionMode;
@@ -114,6 +118,9 @@ function blankForm(): ToolForm {
status: "active",
method: "GET",
url: "",
directory: "",
username: "opencode",
password: "",
timeoutSeconds: "15",
allowInterruptions: true,
executionMode: "immediate",
@@ -140,6 +147,14 @@ 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;
@@ -211,6 +226,21 @@ 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 =
@@ -368,7 +398,8 @@ export function ComponentsToolsPage() {
return (
(filter === "全部" ||
(filter === "HTTP" && tool.type === "http") ||
(filter === "Client" && tool.type === "client")) &&
(filter === "Client" && tool.type === "client") ||
(filter === "后台任务" && tool.type === "task")) &&
(!query ||
[tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query),
@@ -517,7 +548,7 @@ export function ComponentsToolsPage() {
>
{resource.kind === "mcp"
? "MCP"
: resource.tool.type === "client"
: resource.tool.type === "task" ? "后台任务" : resource.tool.type === "client"
? "Client"
: "HTTP"}
</Badge>
@@ -726,7 +757,7 @@ export function ComponentsToolsPage() {
}
: type === "http"
? { timeoutSeconds: "15", executionMode: "immediate" as const }
: {}),
: { timeoutSeconds: "600" }),
}))
}
>
@@ -734,6 +765,7 @@ export function ComponentsToolsPage() {
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="client">Client Tool</SelectItem>
<SelectItem value="task">后台任务 · OpenCode</SelectItem>
</SelectContent>
</Select>
</Field>
@@ -774,7 +806,9 @@ export function ComponentsToolsPage() {
</FieldSection>
<FieldSection title="参数配置" scrollable tall>
{form.type === "client" ? (
{form.type === "task" ? (
<TaskFields form={form} setForm={setForm} />
) : form.type === "client" ? (
<ClientToolFields form={form} setForm={setForm} />
) : (
<HttpFields form={form} setForm={setForm} />
@@ -1005,3 +1039,14 @@ 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 }))} /></Field>
</div>;
}
@@ -150,7 +150,7 @@ function toolParametersFromDefinition(tool: Tool): EditorToolOption["parameters"
return [];
}
function toEditorToolOption(tool: Tool): EditorToolOption {
function toEditorToolOption(tool: Tool & { type: Exclude<Tool["type"], "task"> }): 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.map(toEditorToolOption));
setLoadedTools(list.filter((tool): tool is Tool & { type: Exclude<Tool["type"], "task"> } => tool.type !== "task").map(toEditorToolOption));
} catch {
if (!cancelled) setLoadedTools([]);
}
@@ -58,6 +58,7 @@ const TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
http: "HTTP",
mcp: "MCP",
client: "Client",
task: "后台任务",
};
type ResultPathSuggestion = {
@@ -146,6 +147,9 @@ 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 }>;
@@ -535,6 +539,7 @@ export function ToolBindingEditor({
onChange,
allowModel = true,
maxBindings,
allowTask = false,
}: {
tools: Tool[];
selectableTools?: Tool[];
@@ -543,6 +548,7 @@ 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);
@@ -551,13 +557,14 @@ export function ToolBindingEditor({
[tools],
);
const pickerTools = useMemo(
() => (selectableTools ?? tools).filter((tool) => tool.status === "active"),
[selectableTools, tools],
() => (selectableTools ?? tools).filter((tool) => tool.status === "active" && (allowTask || tool.type !== "task")),
[selectableTools, tools, allowTask],
);
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(
@@ -735,14 +742,14 @@ export function ToolBindingEditor({
);
})}
<ResultAssignmentsEditor
{tool.type !== "task" && <ResultAssignmentsEditor
tool={tool}
value={binding.resultAssignments}
variables={variables}
onChange={(resultAssignments) =>
updateBinding(index, { ...binding, resultAssignments })
}
/>
/>}
</div>
)}
</div>
+1 -1
View File
@@ -663,7 +663,7 @@ export function useVoicePreview(
]),
);
} else if (
msg.type === "workflow-event" &&
(msg.type === "workflow-event" || msg.type === "task-event") &&
typeof msg.event === "string" &&
msg.internalStage !== true
) {
+44 -2
View File
@@ -623,16 +623,31 @@ 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";
type: "http" | "mcp" | "client" | "task";
description: string;
definition:
| HttpToolDefinition
| ClientToolDefinition
| McpToolDefinition;
| McpToolDefinition
| TaskToolDefinition;
secrets: Record<string, unknown>;
status: ToolStatus;
mcpServerId?: string | null;
@@ -966,3 +981,30 @@ export const batchRunsApi = {
{ method: "POST" },
),
};
// ---------- 提示词后台任务 ----------
export type BackgroundTask = {
id: string;
assistantId: string;
conversationId: string;
toolId: string;
instruction: string;
status: "queued" | "submitted" | "running" | "waiting_input" | "cancel_requested" | "succeeded" | "failed" | "cancelled" | "unknown";
summary: string;
result: string;
sequence: number;
createdAt: string;
updatedAt: string;
inputRequest: {
id?: string;
kind?: "permission" | "question";
permission?: string;
patterns?: string[];
questions?: Array<{ question: string; header?: string; multiple?: boolean; options?: Array<{ label: string; description: string }> }>;
};
};
export const backgroundTasksApi = {
list: (assistantId: string) => request<BackgroundTask[]>(`/api/background-tasks?assistant_id=${encodeURIComponent(assistantId)}`),
cancel: (id: string) => request<BackgroundTask>(`/api/background-tasks/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
input: (id: string, value: { reply?: string; answers?: string[][] }) => request<BackgroundTask>(`/api/background-tasks/${encodeURIComponent(id)}/input`, { method: "POST", body: JSON.stringify(value) }),
};