add pipeline task system, agentic music tasks, copy buttons, and pi-bridge cleanup
- Pipeline mode: new task mode that chains agentic tasks sequentially with foreach/subdirectory iteration and skip_if conditions - Pipeline executor backend (WebSocket at /api/tasks/pipeline/ws) with support for both Pi and Claude Code models - Frontend PipelineRunner component with step progress, streaming output, and aggregate cost tracking - New agentic tasks: prepare-discography, fetch-album-info, build-discography (pipeline combining both) - Seed parser extended to handle pipeline steps in frontmatter config - CopyButton component added to assistant bubbles, error bubbles, and tool input/output sections - Removed obsolete SearXNG/Apify/browser relay code from pi-bridge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
type CopyButtonProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text.trim());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`p-1 rounded text-duck-dark dark:text-white opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { ToolActivity } from './ToolActivity';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
import { getRawUrl } from '../../FileViewer/file-types';
|
||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||
import { CopyButton } from './CopyButton';
|
||||
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
@@ -135,7 +136,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
const assistantText = typeof message.text === 'string' ? message.text : '';
|
||||
if (!assistantText) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex justify-start group">
|
||||
<div className="max-w-[85%]">
|
||||
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||
<div className="chat-md">
|
||||
@@ -143,7 +144,8 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
{injectImages(assistantText)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div className="flex justify-end -mb-1 -mr-1">
|
||||
<div className="flex justify-end -mb-1 -mr-1 gap-0.5">
|
||||
<CopyButton text={assistantText} />
|
||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,9 +171,12 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
|
||||
<div className="flex justify-start group">
|
||||
<div className="relative max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
|
||||
{message.text}
|
||||
<div className="flex justify-end -mb-1 -mr-1">
|
||||
<CopyButton text={message.text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { CopyButton } from './CopyButton';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
@@ -74,8 +75,16 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
|
||||
{open && (
|
||||
<div className="ml-7 mt-1 space-y-2 text-xs">
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/input">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Input</div>
|
||||
<CopyButton
|
||||
text={message.toolName === 'Bash'
|
||||
? (message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)
|
||||
: Object.entries(message.toolInput).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join('\n')}
|
||||
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{message.toolName === 'Bash' ? (
|
||||
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
|
||||
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
|
||||
@@ -90,8 +99,11 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
</div>
|
||||
|
||||
{message.output !== undefined && (
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Output</div>
|
||||
<CopyButton text={message.output} className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100" />
|
||||
</div>
|
||||
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+236
-11
@@ -10,6 +10,7 @@ import { useUserVisibleModels } from 'state/useModels';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { TaskSummary } from '../../useTasks';
|
||||
import { useTaskRunner } from './useTaskRunner';
|
||||
import { usePipelineRunner } from './usePipelineRunner';
|
||||
|
||||
const playDing = () => {
|
||||
const ctx = new AudioContext();
|
||||
@@ -38,17 +39,43 @@ const playDing = () => {
|
||||
type Phase = 'ready' | 'running' | 'done';
|
||||
|
||||
type PiMonoInnerProps = {
|
||||
taskDirName: string;
|
||||
defaultInput: string;
|
||||
cwd: { root?: string; path: string };
|
||||
initialModel: string | null;
|
||||
taskInfo: TaskInfo;
|
||||
sandboxed?: boolean;
|
||||
context: Record<string, string>;
|
||||
};
|
||||
|
||||
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: PiMonoInnerProps) => {
|
||||
const PiMonoInner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, sandboxed, context }: PiMonoInnerProps) => {
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const availableModels = useUserVisibleModels();
|
||||
const client = useClient();
|
||||
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [taskBody, setTaskBody] = useState<string | null>(null);
|
||||
|
||||
// Fetch task detail for inputs and body
|
||||
useEffect(() => {
|
||||
client.get<{ inputs?: Record<string, TaskInputDef>; body?: string }>(`/tasks/${taskDirName}`).then((task) => {
|
||||
const defs = task.inputs ?? {};
|
||||
setInputDefs(defs);
|
||||
setTaskBody(task.body ?? null);
|
||||
// Initialize from defaults and autofill
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(initial);
|
||||
});
|
||||
}, [taskDirName]);
|
||||
|
||||
const handleInputChange = (key: string, value: string) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// --- Independent message accumulator (never loses messages) ---
|
||||
const accRef = useRef<ChatMessage[]>([]);
|
||||
@@ -114,17 +141,42 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
}, [chat.isGenerating]);
|
||||
|
||||
const handleRun = () => {
|
||||
// Build prompt from task body + input values, or fall back to generic prompt
|
||||
let prompt = defaultInput;
|
||||
if (taskBody && inputDefs) {
|
||||
const inputLines = Object.entries(formValues)
|
||||
.filter(([, v]) => v.trim())
|
||||
.map(([key, value]) => {
|
||||
const label = inputDefs[key]?.description ?? key;
|
||||
return `- **${label}**: ${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
const contextLines: string[] = [];
|
||||
if (context.entry_path) contextLines.push(`- **Target directory**: ${context.entry_path}`);
|
||||
const contextSection = contextLines.length > 0 ? `\n\n## Context\n\n${contextLines.join('\n')}` : '';
|
||||
prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
||||
}
|
||||
setPhase('running');
|
||||
chat.sendPrompt(defaultInput, undefined, undefined, cwd, undefined, sandboxed);
|
||||
chat.sendPrompt(prompt, undefined, undefined, cwd, undefined, sandboxed);
|
||||
};
|
||||
|
||||
const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;
|
||||
|
||||
if (phase === 'ready') {
|
||||
return (
|
||||
<>
|
||||
{hasConfigurableInputs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
values={formValues}
|
||||
onChange={handleInputChange}
|
||||
autoFilledKeys={new Set()}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!chat.isConnected}
|
||||
disabled={!chat.isConnected || !inputDefs}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
@@ -199,6 +251,7 @@ type TaskInputDef = {
|
||||
description?: string;
|
||||
default?: string;
|
||||
options?: string[];
|
||||
autofill?: string;
|
||||
};
|
||||
|
||||
type TaskInputFormProps = {
|
||||
@@ -282,10 +335,11 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: TaskInpu
|
||||
type ScriptRunnerProps = {
|
||||
taskDirName: string;
|
||||
autoInputs: Record<string, string>;
|
||||
context: Record<string, string>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
|
||||
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerProps) => {
|
||||
const runner = useTaskRunner();
|
||||
const client = useClient();
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -297,12 +351,13 @@ const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
|
||||
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
|
||||
const defs = task.inputs ?? {};
|
||||
setInputDefs(defs);
|
||||
// Initialize form values from defaults
|
||||
const defaults: Record<string, string> = {};
|
||||
// Initialize from autofill context, then defaults
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.default !== undefined) defaults[key] = def.default;
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(defaults);
|
||||
setFormValues(initial);
|
||||
});
|
||||
}, [taskDirName]);
|
||||
|
||||
@@ -421,6 +476,158 @@ const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── Pipeline-mode runner ──
|
||||
|
||||
type PipelineRunnerProps = {
|
||||
taskDirName: string;
|
||||
context: Record<string, string>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
const pipeline = usePipelineRunner();
|
||||
const client = useClient();
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
|
||||
// Fetch task detail for inputs
|
||||
useEffect(() => {
|
||||
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
|
||||
const defs = task.inputs ?? {};
|
||||
setInputDefs(defs);
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(initial);
|
||||
});
|
||||
}, [taskDirName]);
|
||||
|
||||
const handleInputChange = (key: string, value: string) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep]);
|
||||
|
||||
// Ding on completion
|
||||
const prevPhaseRef = useRef(pipeline.phase);
|
||||
useEffect(() => {
|
||||
if (prevPhaseRef.current === 'running' && pipeline.phase === 'done') {
|
||||
playDing();
|
||||
}
|
||||
prevPhaseRef.current = pipeline.phase;
|
||||
}, [pipeline.phase]);
|
||||
|
||||
const handleRun = () => {
|
||||
pipeline.run(taskDirName, formValues, cwd);
|
||||
};
|
||||
|
||||
const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;
|
||||
|
||||
if (pipeline.phase === 'ready') {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{hasConfigurableInputs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
values={formValues}
|
||||
onChange={handleInputChange}
|
||||
autoFilledKeys={new Set()}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!pipeline.isConnected || !inputDefs}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Run Pipeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Step progress header */}
|
||||
{pipeline.currentStep && (
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-duck-dark">{pipeline.currentStep.taskName}</span>
|
||||
{pipeline.currentStep.iteration && (
|
||||
<span className="text-duck-dark/50 text-xs">
|
||||
({pipeline.currentStep.iteration.current}/{pipeline.currentStep.iteration.total})
|
||||
<span className="ml-1 font-mono">{pipeline.currentStep.iteration.label}</span>
|
||||
</span>
|
||||
)}
|
||||
{pipeline.currentStep.status === 'running' && (
|
||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{pipeline.currentStep.status === 'complete' && (
|
||||
<span className="ml-auto text-xs text-green-600">done</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{pipeline.messages.map((msg, i) => (
|
||||
<div key={i} className="px-4 py-1.5">
|
||||
<MessageBubble message={msg} onAnswer={() => {}} />
|
||||
</div>
|
||||
))}
|
||||
{pipeline.streamingText && (
|
||||
<div className="px-4 py-1.5">
|
||||
<StreamingBubble text={pipeline.streamingText} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 flex flex-col items-center gap-1 py-3 border-t border-duck-dark/10">
|
||||
{pipeline.phase === 'running' ? (
|
||||
<button
|
||||
onClick={pipeline.stop}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop Pipeline
|
||||
</button>
|
||||
) : pipeline.hasError ? (
|
||||
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
|
||||
<CircleX className="h-4 w-4" />
|
||||
Pipeline failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
Pipeline complete
|
||||
</span>
|
||||
)}
|
||||
{pipeline.totalCost && (
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
${pipeline.totalCost.totalUSD.toFixed(3)} · {pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens} tokens
|
||||
</span>
|
||||
)}
|
||||
{pipeline.skippedItems.length > 0 && (
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
{pipeline.skippedItems.length} skipped ({pipeline.skippedItems.map((s) => s.label).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskRunnerModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -439,14 +646,22 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
const taskSettings = settings.tasks;
|
||||
const entryRef = entryFullPath ?? entryName;
|
||||
const isScript = task.mode === 'script';
|
||||
const isPipeline = task.mode === 'pipeline';
|
||||
|
||||
// Agentic mode prompt
|
||||
// Agentic mode prompt (fallback if task has no body)
|
||||
const defaultInput = promptOverride
|
||||
?? (entryRef && entryType
|
||||
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
|
||||
: `Execute the task "${task.name}" (${task.dirName})`);
|
||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
||||
|
||||
// Context values for autofill
|
||||
// Build a ~/relative path for the agent (works inside bwrap sandbox)
|
||||
const entryRelPath = entryName && cwd.path ? `~/${cwd.path}/${entryName}` : entryName ? `~/${entryName}` : undefined;
|
||||
const autofillContext: Record<string, string> = {};
|
||||
if (entryName) autofillContext.entry_name = entryName;
|
||||
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
||||
|
||||
// Script mode: auto-filled inputs from context (e.g. file_path from file browser)
|
||||
const autoInputs: Record<string, string> = {};
|
||||
if (entryFullPath) autoInputs.file_path = entryFullPath;
|
||||
@@ -477,21 +692,31 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
</div>
|
||||
|
||||
{/* Task Runner — branch on mode */}
|
||||
{isScript ? (
|
||||
{isPipeline ? (
|
||||
<PipelineRunner
|
||||
key="pipeline"
|
||||
taskDirName={task.dirName}
|
||||
context={autofillContext}
|
||||
cwd={entryType === 'directory' && entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : cwd.path || undefined}
|
||||
/>
|
||||
) : isScript ? (
|
||||
<ScriptRunner
|
||||
key="script"
|
||||
taskDirName={task.dirName}
|
||||
autoInputs={autoInputs}
|
||||
context={autofillContext}
|
||||
cwd={cwd.path || undefined}
|
||||
/>
|
||||
) : (
|
||||
<PiMonoInner
|
||||
key="pi"
|
||||
taskDirName={task.dirName}
|
||||
defaultInput={defaultInput}
|
||||
cwd={cwd}
|
||||
cwd={entryType === 'directory' && entryName ? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName } : cwd}
|
||||
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
|
||||
taskInfo={taskInfo}
|
||||
sandboxed={sandboxed}
|
||||
context={autofillContext}
|
||||
/>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { ChatMessage } from '../../../Chat';
|
||||
|
||||
type Phase = 'ready' | 'running' | 'done';
|
||||
|
||||
type StepDef = {
|
||||
task: string;
|
||||
foreach?: string;
|
||||
};
|
||||
|
||||
type StepStatus = {
|
||||
taskName: string;
|
||||
iteration?: { current: number; total: number; label: string };
|
||||
status: 'pending' | 'running' | 'complete' | 'skipped';
|
||||
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
| { type: 'pipeline:init'; steps: StepDef[] }
|
||||
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||
| { type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| { type: 'assistant:delta'; text: string }
|
||||
| { type: 'assistant:text'; text: string }
|
||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
|
||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
|
||||
| { type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
|
||||
export function usePipelineRunner() {
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [steps, setSteps] = useState<StepDef[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<StepStatus | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const streamBufferRef = useRef('');
|
||||
|
||||
const flushStream = useCallback(() => {
|
||||
const text = streamBufferRef.current;
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
|
||||
streamBufferRef.current = '';
|
||||
setStreamingText('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}/api/tasks/pipeline/ws?token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => setIsConnected(true));
|
||||
ws.addEventListener('close', () => setIsConnected(false));
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as ServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'pipeline:init':
|
||||
setSteps(msg.steps);
|
||||
break;
|
||||
|
||||
case 'step:start':
|
||||
// Flush any streaming text from the previous step
|
||||
flushStream();
|
||||
// Clear messages for the new step iteration
|
||||
setMessages([]);
|
||||
setCurrentStep({
|
||||
taskName: msg.taskName,
|
||||
iteration: msg.iteration,
|
||||
status: 'running',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'step:complete':
|
||||
flushStream();
|
||||
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
|
||||
break;
|
||||
|
||||
case 'step:skip':
|
||||
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
|
||||
break;
|
||||
|
||||
case 'assistant:delta':
|
||||
streamBufferRef.current += msg.text;
|
||||
setStreamingText(streamBufferRef.current);
|
||||
break;
|
||||
|
||||
case 'assistant:text': {
|
||||
const text = msg.text || streamBufferRef.current;
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
|
||||
}
|
||||
streamBufferRef.current = '';
|
||||
setStreamingText('');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start':
|
||||
flushStream();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'tool' as const,
|
||||
id: crypto.randomUUID(),
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
output: undefined,
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
|
||||
? { ...m, output: msg.output, isError: msg.isError }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pipeline:complete':
|
||||
flushStream();
|
||||
setTotalCost(msg.totalCost);
|
||||
setPhase('done');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
flushStream();
|
||||
setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
|
||||
setHasError(true);
|
||||
setPhase('done');
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
flushStream();
|
||||
setPhase('done');
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
setPhase('running');
|
||||
setMessages([]);
|
||||
setStreamingText('');
|
||||
setTotalCost(null);
|
||||
setHasError(false);
|
||||
setSkippedItems([]);
|
||||
streamBufferRef.current = '';
|
||||
|
||||
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }));
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
wsRef.current.send(JSON.stringify({ type: 'stop' }));
|
||||
}, []);
|
||||
|
||||
return { phase, isConnected, steps, currentStep, messages, streamingText, totalCost, hasError, skippedItems, run, stop };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export type TaskSummary = {
|
||||
description: string;
|
||||
scope: string;
|
||||
triggers: TriggerConfig[];
|
||||
mode: 'script' | 'agentic';
|
||||
mode: 'script' | 'agentic' | 'pipeline';
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user