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:
2026-03-08 20:20:20 +00:00
co-authored by Claude Opus 4.6
parent 3993c7d1c7
commit 22c60d4a57
15 changed files with 1158 additions and 62 deletions
+55
View File
@@ -99,6 +99,59 @@ function parseFrontmatter(content: string) {
}
}
// Parse pipeline steps
const stepsMatch = yaml.match(/^steps:\s*\n((?:[ \t]+.+\n?)*)/m);
if (stepsMatch) {
const stepsBlock = stepsMatch[1]!;
const steps: Record<string, unknown>[] = [];
let currentStep: Record<string, unknown> | null = null;
let inInputs = false;
let stepInputs: Record<string, string> = {};
for (const line of stepsBlock.split('\n')) {
// New step entry (2 spaces + dash)
const stepStart = line.match(/^\s{2}-\s+task:\s*(.+)$/);
if (stepStart) {
if (currentStep) {
if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs;
steps.push(currentStep);
}
currentStep = { task: stepStart[1]!.trim() };
stepInputs = {};
inInputs = false;
continue;
}
if (!currentStep) continue;
// Step-level properties (4 spaces)
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/);
if (propMatch) {
const key = propMatch[1]!;
const value = propMatch[2]!.trim();
if (key === 'inputs' && value === '') {
inInputs = true;
} else {
inInputs = false;
currentStep[key] = value;
}
continue;
}
// Step input entries (6 spaces)
const inputMatch = line.match(/^\s{6}(\w[\w_-]*):\s*(.+)$/);
if (inputMatch && inInputs) {
stepInputs[inputMatch[1]!] = inputMatch[2]!.trim();
}
}
if (currentStep) {
if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs;
steps.push(currentStep);
}
if (steps.length > 0) {
meta.config = { steps };
}
}
return { meta, body };
}
@@ -151,6 +204,7 @@ async function seedTasks() {
args: (meta.args as string[]) || null,
trigger: (meta.trigger as TriggerConfig[]) || null,
inputs: (meta.inputs as Record<string, unknown>) || null,
config: (meta.config as Record<string, unknown>) || null,
};
// Upsert: check by dirName + scope since unique constraint doesn't work with NULL userId
@@ -174,6 +228,7 @@ async function seedTasks() {
args: values.args,
trigger: values.trigger,
inputs: values.inputs,
config: values.config,
updatedAt: new Date(),
})
.where(eq(tasks.id, existing[0]!.id));