F3: connect Prompt tools and web task controls to native Jobs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user