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,23 @@
|
|||||||
|
---
|
||||||
|
name: Build Discography
|
||||||
|
description: Identify all albums in an artist folder, rename directories, fetch detailed info and cover art for each.
|
||||||
|
version: 1
|
||||||
|
mode: pipeline
|
||||||
|
triggers:
|
||||||
|
- type: directory
|
||||||
|
inputs:
|
||||||
|
artist_name:
|
||||||
|
type: string
|
||||||
|
description: Artist / band name
|
||||||
|
autofill: entry_name
|
||||||
|
steps:
|
||||||
|
- task: prepare-discography
|
||||||
|
inputs:
|
||||||
|
artist_name: ${artist_name}
|
||||||
|
- task: fetch-album-info
|
||||||
|
foreach: subdirectory
|
||||||
|
skip_if: album-info.md
|
||||||
|
inputs:
|
||||||
|
artist_name: ${artist_name}
|
||||||
|
album_name: ${folder_name}
|
||||||
|
---
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: Fetch Album Info
|
||||||
|
description: Search the web for album information and save a detailed info file.
|
||||||
|
version: 1
|
||||||
|
mode: agentic
|
||||||
|
triggers:
|
||||||
|
- type: directory
|
||||||
|
inputs:
|
||||||
|
artist_name:
|
||||||
|
type: string
|
||||||
|
description: Artist name
|
||||||
|
autofill: entry_name
|
||||||
|
album_name:
|
||||||
|
type: string
|
||||||
|
description: Album name
|
||||||
|
autofill: entry_name
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fetch Album Info
|
||||||
|
|
||||||
|
You are given an artist name and album name. Your job is to find comprehensive information about this album from the web.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Search** for the album using `web_search` with queries like `"<artist> <album> wikipedia"`, `"<artist> <album> musicbrainz"`, `"<artist> <album> allmusic"`.
|
||||||
|
2. **Fetch** the most relevant pages using `web_fetch` to extract detailed information.
|
||||||
|
3. **Cross-reference** multiple sources to get the most accurate and complete data.
|
||||||
|
4. **Download** the front cover image and save it as `cover.{ext}` (matching the image format, e.g. `cover.jpg`) in the target directory. Overwrite if it already exists.
|
||||||
|
5. **Save** the result as an `album-info.md` file in the target directory (provided in Context).
|
||||||
|
|
||||||
|
## Information to collect
|
||||||
|
|
||||||
|
- **Artist** name (canonical/correct spelling)
|
||||||
|
- **Album** title (canonical/correct spelling)
|
||||||
|
- **Release year** (original release)
|
||||||
|
- **Genre(s)** and subgenres
|
||||||
|
- **Label** / record company
|
||||||
|
- **Tracklist** — for each track: number, title, duration. For multi-disc releases, organize by disc.
|
||||||
|
- **Total duration**
|
||||||
|
- **Front cover image URL** — find the highest quality cover art URL available (Wikipedia, MusicBrainz cover art archive)
|
||||||
|
- **Credits** — producer, engineer, notable musicians (if available)
|
||||||
|
- **Additional notes** — compilation info, remaster details, notable facts
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Write a markdown file named `album-info.md` in the **target directory** (from Context) with this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Artist — Album Title
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Artist | ... |
|
||||||
|
| Album | ... |
|
||||||
|
| Year | ... |
|
||||||
|
| Genre | ... |
|
||||||
|
| Label | ... |
|
||||||
|
| Duration | ... |
|
||||||
|
|
||||||
|
## Tracklist
|
||||||
|
|
||||||
|
### Disc 1
|
||||||
|
| # | Title | Duration |
|
||||||
|
|---|-------|----------|
|
||||||
|
| 1 | ... | 0:00 |
|
||||||
|
|
||||||
|
### Disc 2
|
||||||
|
...
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
- Producer: ...
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is only one disc, omit the "Disc 1" heading and just use a flat tracklist.
|
||||||
|
|
||||||
|
## Cover art download
|
||||||
|
|
||||||
|
Once you have a cover image URL, download it to the target directory using curl:
|
||||||
|
```
|
||||||
|
curl -sL -o "<target_dir>/cover.<ext>" "<image_url>"
|
||||||
|
```
|
||||||
|
Use the correct extension based on the image format (jpg, png, etc.). Overwrite any existing file.
|
||||||
|
In the `album-info.md`, keep the original remote URL in the image tag: ``.
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Prefer Wikipedia for context, genre classification, credits, and tracklist data.
|
||||||
|
- Use MusicBrainz / Cover Art Archive for high-quality cover image URLs and precise edition identification.
|
||||||
|
- Use AllMusic as an additional source for credits and reviews.
|
||||||
|
- If information conflicts between sources, prefer Wikipedia for metadata and MusicBrainz for tracklist data.
|
||||||
|
- Do NOT make up information. If something is not found, omit it.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
name: Prepare Discography
|
||||||
|
description: Scan an artist's album folders, identify exact release editions, and produce a discography summary.
|
||||||
|
version: 1
|
||||||
|
mode: agentic
|
||||||
|
triggers:
|
||||||
|
- type: directory
|
||||||
|
inputs:
|
||||||
|
artist_name:
|
||||||
|
type: string
|
||||||
|
description: Artist / band name
|
||||||
|
autofill: entry_name
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prepare Discography
|
||||||
|
|
||||||
|
You are given an artist/band directory containing one subfolder per album. Your job is to identify the **exact release edition** of each album and produce a `discography.md` summary file.
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
Album folders often contain special editions, anniversary reissues, deluxe multi-disc sets, etc. A naive search for "The Doors 1967" returns the original 11-track release, but the folder might contain a 50th Anniversary 3CD edition with 30+ tracks. You must identify the **specific edition** the user has.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
For each album subfolder in the artist directory:
|
||||||
|
|
||||||
|
### 1. Analyze local signals
|
||||||
|
|
||||||
|
Gather as much information as possible from the folder itself **before** searching the web:
|
||||||
|
|
||||||
|
- **Folder name**: often contains year and album title, sometimes edition hints like `(3CD)`, `(Deluxe)`, `(Remaster)`
|
||||||
|
- **Disc structure**: count `CD1/`, `CD2/`, etc. subdirectories — this tells you how many discs the release has
|
||||||
|
- **Track count per disc**: list audio files in each disc folder (or root if single-disc)
|
||||||
|
- **Track names**: the filenames often contain track titles (e.g. `01 - Break on Through.flac`)
|
||||||
|
- **Audio tags**: use the `mutagen` tool with `action: read` on 1-2 representative tracks per disc to get tagged metadata (artist, album, year, genre, label, etc.)
|
||||||
|
- **Existing files**: check for `album-info.md`, `Front.jpg`, `cover.jpg`, or `Scans/` — these provide additional context
|
||||||
|
|
||||||
|
### 2. Build a search profile
|
||||||
|
|
||||||
|
From the local signals, construct a profile:
|
||||||
|
- Artist name (from tags or folder name)
|
||||||
|
- Album title (from tags or folder name)
|
||||||
|
- Release year (from tags or folder name)
|
||||||
|
- Number of discs
|
||||||
|
- Track count per disc
|
||||||
|
- Any edition keywords (deluxe, remaster, anniversary, etc.)
|
||||||
|
|
||||||
|
### 3. Search for the exact release
|
||||||
|
|
||||||
|
Use `web_search` to find the specific edition:
|
||||||
|
- Search Wikipedia first: `"<artist> <album> wikipedia"` — Wikipedia often has edition/reissue details
|
||||||
|
- Search MusicBrainz: `"<artist> <album> musicbrainz"` — MusicBrainz catalogs every pressing and edition
|
||||||
|
- If the disc/track count doesn't match the first result, refine: `"<artist> <album> deluxe 3CD"` or similar
|
||||||
|
- Use `web_fetch` on the most relevant pages to confirm the tracklist matches your local files
|
||||||
|
|
||||||
|
### 4. Confirm the match
|
||||||
|
|
||||||
|
Compare the web result against local signals:
|
||||||
|
- Does the disc count match?
|
||||||
|
- Does the track count per disc match (approximately)?
|
||||||
|
- Do track names align?
|
||||||
|
- If there's a mismatch, search for alternative editions until you find the best match
|
||||||
|
|
||||||
|
### 5. Rename the album folder
|
||||||
|
|
||||||
|
Once you have confidently identified the release, rename the album folder to the standardized format:
|
||||||
|
|
||||||
|
```
|
||||||
|
[YYYY] Album Title (Edition Marker)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **YYYY** = original release year (NOT the reissue/special edition year)
|
||||||
|
- **Album Title** = canonical album name
|
||||||
|
- **Edition Marker** = only if it's not the standard original release. Examples: `50th Anniversary 3CD Deluxe Edition`, `2017 Remaster`, `Deluxe Edition`
|
||||||
|
- If it IS the plain original release, omit the parenthetical: `[1967] The Doors`
|
||||||
|
|
||||||
|
Use `mv` via Bash to rename. Do this **before** writing discography.md so the file references the new folder names.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Write a `discography.md` file in the **artist directory** (the target directory from Context) with this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Artist Name — Discography
|
||||||
|
|
||||||
|
## Albums
|
||||||
|
|
||||||
|
### [YYYY] Album Title (Edition Marker)
|
||||||
|
- **Folder**: `[YYYY] Album Title (Edition Marker)/`
|
||||||
|
- **Format**: FLAC / MP3 / Mixed
|
||||||
|
- **Discs**: N
|
||||||
|
- **Tracks**: N (or N per disc: D1: X, D2: Y, ...)
|
||||||
|
- **Year**: YYYY (original release) / YYYY (this edition)
|
||||||
|
- **Label**: ...
|
||||||
|
- **Genre**: ...
|
||||||
|
- **Edition**: 50th Anniversary Deluxe Edition / Original / Remaster / etc.
|
||||||
|
- **Wikipedia**: https://en.wikipedia.org/wiki/... (if found)
|
||||||
|
- **MusicBrainz**: https://musicbrainz.org/release/... (if found)
|
||||||
|
- **Notes**: ...
|
||||||
|
|
||||||
|
### [YYYY] Another Album
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Sort albums chronologically by original release year.
|
||||||
|
|
||||||
|
Include **direct URLs** to the sources used (Discogs release page, Wikipedia article, MusicBrainz release) — these will be used by downstream tasks to avoid redundant searching.
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- **Accuracy over speed**: it's better to correctly identify 3 out of 4 albums than to guess all 4 wrong
|
||||||
|
- **Wikipedia and MusicBrainz are your primary sources** for edition identification
|
||||||
|
- **Use mutagen sparingly**: read 1-2 tracks per disc, not every file
|
||||||
|
- **Track count is the strongest signal** for distinguishing editions — a 3CD set with 10+12+8 tracks is very different from a single-disc 11-track original
|
||||||
|
- **Folder name hints**: `(3CD)`, `(2LP)`, `(Deluxe)`, `(Remaster)`, `(Anniversary)` in the folder name are strong clues
|
||||||
|
- **Do NOT make up information**. If you cannot confidently identify an edition, note what you found and flag the uncertainty
|
||||||
|
- The artist name in the folder may be formatted as `Last, First` or `Name, The` — normalize it when searching (e.g. `Doors, the` → `The Doors`)
|
||||||
@@ -20,6 +20,17 @@ All task inputs defined in the TASK.md frontmatter are available to the script i
|
|||||||
|
|
||||||
Inputs provided by context (e.g. `file_path` from the file browser) are auto-filled and hidden from the form.
|
Inputs provided by context (e.g. `file_path` from the file browser) are auto-filled and hidden from the form.
|
||||||
|
|
||||||
|
### Autofill
|
||||||
|
|
||||||
|
Inputs can declare `autofill` to pre-fill from the trigger context. Available context values:
|
||||||
|
|
||||||
|
| Value | Source |
|
||||||
|
|-------|--------|
|
||||||
|
| `entry_name` | Name of the file or directory that triggered the task |
|
||||||
|
| `entry_path` | Full path of the file or directory |
|
||||||
|
|
||||||
|
Autofilled inputs are visible and editable (unlike `file_path` which is hidden).
|
||||||
|
|
||||||
## Templates
|
## Templates
|
||||||
|
|
||||||
### 1. File Processor
|
### 1. File Processor
|
||||||
@@ -180,6 +191,7 @@ inputs:
|
|||||||
type: string # string, number, boolean
|
type: string # string, number, boolean
|
||||||
description: What this input is
|
description: What this input is
|
||||||
default: value # optional — default value
|
default: value # optional — default value
|
||||||
|
autofill: entry_name # optional — pre-fill from context (entry_name or entry_path)
|
||||||
options: # optional — renders as selectable pills in UI
|
options: # optional — renders as selectable pills in UI
|
||||||
- option1
|
- option1
|
||||||
- option2
|
- option2
|
||||||
|
|||||||
@@ -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 };
|
return { meta, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +204,7 @@ async function seedTasks() {
|
|||||||
args: (meta.args as string[]) || null,
|
args: (meta.args as string[]) || null,
|
||||||
trigger: (meta.trigger as TriggerConfig[]) || null,
|
trigger: (meta.trigger as TriggerConfig[]) || null,
|
||||||
inputs: (meta.inputs as Record<string, unknown>) || 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
|
// Upsert: check by dirName + scope since unique constraint doesn't work with NULL userId
|
||||||
@@ -174,6 +228,7 @@ async function seedTasks() {
|
|||||||
args: values.args,
|
args: values.args,
|
||||||
trigger: values.trigger,
|
trigger: values.trigger,
|
||||||
inputs: values.inputs,
|
inputs: values.inputs,
|
||||||
|
config: values.config,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(tasks.id, existing[0]!.id));
|
.where(eq(tasks.id, existing[0]!.id));
|
||||||
|
|||||||
+5
-2
@@ -7,6 +7,7 @@ import { isTokenBlacklisted } from 'officerdb';
|
|||||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||||
import { piWebsocket } from './servers/api/pi/websocket';
|
import { piWebsocket } from './servers/api/pi/websocket';
|
||||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||||
|
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
||||||
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
||||||
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
||||||
import { desktopWebsocket } from './servers/api/desktop/websocket';
|
import { desktopWebsocket } from './servers/api/desktop/websocket';
|
||||||
@@ -24,7 +25,7 @@ type WSData = {
|
|||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
provider: 'terminal' | 'pi' | 'task-runner' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar';
|
provider: 'terminal' | 'pi' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar';
|
||||||
sandboxed: boolean;
|
sandboxed: boolean;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -118,6 +119,7 @@ const handlers: Record<string, any> = {
|
|||||||
terminal: terminalWebsocket,
|
terminal: terminalWebsocket,
|
||||||
pi: piWebsocket,
|
pi: piWebsocket,
|
||||||
'task-runner': taskRunnerWebsocket,
|
'task-runner': taskRunnerWebsocket,
|
||||||
|
pipeline: pipelineWebsocket,
|
||||||
cliamp: cliampWebsocket,
|
cliamp: cliampWebsocket,
|
||||||
'cliamp-audio': cliampAudioWebsocket,
|
'cliamp-audio': cliampAudioWebsocket,
|
||||||
desktop: desktopWebsocket,
|
desktop: desktopWebsocket,
|
||||||
@@ -189,7 +191,7 @@ const devServerWebsocket = {
|
|||||||
};
|
};
|
||||||
handlers['dev-server'] = devServerWebsocket;
|
handlers['dev-server'] = devServerWebsocket;
|
||||||
|
|
||||||
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'task-runner' | 'cliamp' | 'cliamp-audio' | 'desktop') {
|
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop') {
|
||||||
const token = new URL(req.url).searchParams.get('token');
|
const token = new URL(req.url).searchParams.get('token');
|
||||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
@@ -274,6 +276,7 @@ const server = serve({
|
|||||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
},
|
},
|
||||||
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
||||||
|
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
||||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||||
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
|
'/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'),
|
||||||
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import {
|
|||||||
getUserToolsDir,
|
getUserToolsDir,
|
||||||
toShellUsername,
|
toShellUsername,
|
||||||
} from '../../data-path';
|
} from '../../data-path';
|
||||||
import { getServerIntegration, getUserIntegration, readConfigValue } from 'officerdb';
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { getRelayPort } from '../browser/relay';
|
|
||||||
import { registerUserToken } from '../browser/relay-auth';
|
|
||||||
|
|
||||||
// Resolve pi as [node, cli.js] — Bun.spawn async pipes break with shebang scripts under pm2
|
// Resolve pi as [node, cli.js] — Bun.spawn async pipes break with shebang scripts under pm2
|
||||||
const PI_CMD = (() => {
|
const PI_CMD = (() => {
|
||||||
@@ -71,32 +68,6 @@ function collectExtensionFlags(email: string): string[] {
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getApifyToken(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const integration = await getServerIntegration('apify');
|
|
||||||
const config = integration?.config as Record<string, string> | undefined;
|
|
||||||
return config?.apiToken ?? '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBrowserRelayEnv(userId: number): Promise<Record<string, string>> {
|
|
||||||
const port = getRelayPort();
|
|
||||||
if (!port) return {};
|
|
||||||
try {
|
|
||||||
const integration = await getUserIntegration(userId, 'browser-relay');
|
|
||||||
const salt = (integration?.config as { tokenSalt?: string })?.tokenSalt;
|
|
||||||
if (!salt) return {};
|
|
||||||
const token = registerUserToken(userId, port, salt);
|
|
||||||
return {
|
|
||||||
OFFICER_BROWSER_RELAY_PORT: String(port),
|
|
||||||
OFFICER_BROWSER_RELAY_TOKEN: token,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveApiKeyForModel(model: string): Promise<string | null> {
|
async function resolveApiKeyForModel(model: string): Promise<string | null> {
|
||||||
const provider = model.split('/')[0];
|
const provider = model.split('/')[0];
|
||||||
@@ -125,7 +96,6 @@ export async function spawnPi(
|
|||||||
onEvent: PiEventHandler,
|
onEvent: PiEventHandler,
|
||||||
options?: SpawnPiOptions,
|
options?: SpawnPiOptions,
|
||||||
): Promise<Subprocess> {
|
): Promise<Subprocess> {
|
||||||
const searxngUrl = await readConfigValue('searxng-url', '');
|
|
||||||
const skillFlags = collectSkillFlags(email);
|
const skillFlags = collectSkillFlags(email);
|
||||||
const extensionFlags = collectExtensionFlags(email);
|
const extensionFlags = collectExtensionFlags(email);
|
||||||
|
|
||||||
@@ -151,8 +121,6 @@ export async function spawnPi(
|
|||||||
|
|
||||||
const homeDir = getHomeDirForRole(email, options?.role ?? null);
|
const homeDir = getHomeDirForRole(email, options?.role ?? null);
|
||||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
|
||||||
const apifyToken = await getApifyToken();
|
|
||||||
const shellUsername = options?.username ?? toShellUsername('', email);
|
const shellUsername = options?.username ?? toShellUsername('', email);
|
||||||
|
|
||||||
const isServiceUser = (options?.username ?? toShellUsername('', email)) === (process.env.USER ?? '');
|
const isServiceUser = (options?.username ?? toShellUsername('', email)) === (process.env.USER ?? '');
|
||||||
@@ -163,12 +131,9 @@ export async function spawnPi(
|
|||||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||||
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
|
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
|
||||||
PI_TOOLS_DIRS: toolsDirs,
|
PI_TOOLS_DIRS: toolsDirs,
|
||||||
PI_SEARXNG_URL: searxngUrl,
|
|
||||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
PATH: process.env.PATH ?? '',
|
PATH: process.env.PATH ?? '',
|
||||||
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
|
|
||||||
...browserRelayEnv,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// For service user, keep real HOME so Pi finds its config
|
// For service user, keep real HOME so Pi finds its config
|
||||||
@@ -453,9 +418,7 @@ export function killPi(process: Subprocess): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build env vars needed by Officer tools when running on the host. */
|
/** Build env vars needed by Officer tools when running on the host. */
|
||||||
export async function buildHostToolEnv(userId: number, email: string, role?: string): Promise<Record<string, string>> {
|
export async function buildHostToolEnv(email: string, role?: string): Promise<Record<string, string>> {
|
||||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
|
||||||
const apifyToken = await getApifyToken();
|
|
||||||
const homeDir = getHomeDirForRole(email, role ?? null);
|
const homeDir = getHomeDirForRole(email, role ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -463,7 +426,5 @@ export async function buildHostToolEnv(userId: number, email: string, role?: str
|
|||||||
OFFICER_USER_HOME: homeDir,
|
OFFICER_USER_HOME: homeDir,
|
||||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||||
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
|
|
||||||
...browserRelayEnv,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import type { ServerWebSocket } from 'bun';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getTaskByDirName, getUserSettings } from 'officerdb';
|
||||||
|
import { getHomeDirForRole } from '../../data-path';
|
||||||
|
import { resolveBaseCwd } from '../pi/websocket';
|
||||||
|
import * as sidecar from '../../sidecar-registry';
|
||||||
|
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||||
|
import type { PiEvent, MessageCost } from '../pi/types';
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = 'claude-code';
|
||||||
|
|
||||||
|
async function resolveModel(userId: number): Promise<string> {
|
||||||
|
try {
|
||||||
|
const settings = await getUserSettings(userId);
|
||||||
|
const tasks = settings?.tasks as Record<string, unknown> | undefined;
|
||||||
|
return (tasks?.defaultModel as string) || DEFAULT_MODEL;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSData = {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
sandboxed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PipelineStep = {
|
||||||
|
task: string;
|
||||||
|
inputs?: Record<string, string>;
|
||||||
|
foreach?: 'subdirectory';
|
||||||
|
skip_if?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PipelineConfig = {
|
||||||
|
steps: PipelineStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunMessage = {
|
||||||
|
type: 'run';
|
||||||
|
taskDirName: string;
|
||||||
|
inputs: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientMessage = RunMessage | { type: 'stop' };
|
||||||
|
|
||||||
|
// Messages sent to client
|
||||||
|
type OutMessage =
|
||||||
|
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string }> }
|
||||||
|
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||||
|
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
|
||||||
|
| { 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: MessageCost }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
| { type: 'stopped' };
|
||||||
|
|
||||||
|
// Active pipelines per WebSocket
|
||||||
|
const activePipelines = new WeakMap<ServerWebSocket<WSData>, { abort: () => void }>();
|
||||||
|
|
||||||
|
function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
|
||||||
|
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
type AbortSignal = { aborted: boolean };
|
||||||
|
|
||||||
|
type RunStepParams = {
|
||||||
|
ws: ServerWebSocket<WSData>;
|
||||||
|
taskDirName: string;
|
||||||
|
prompt: string;
|
||||||
|
cwd: string;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runAgenticStep({ ws, taskDirName, prompt, cwd, abortSignal }: RunStepParams): Promise<MessageCost> {
|
||||||
|
const { email, username, userId, role } = ws.data;
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const model = await resolveModel(userId);
|
||||||
|
const isClaudeCode = model.startsWith('claude-code');
|
||||||
|
|
||||||
|
console.log(`[pipeline] starting step for session ${sessionId} (model=${model})`);
|
||||||
|
|
||||||
|
return new Promise<MessageCost>(async (resolve, reject) => {
|
||||||
|
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
|
||||||
|
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const onEvent = (event: PiEvent) => {
|
||||||
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'delta':
|
||||||
|
send(ws, { type: 'assistant:delta', text: event.text });
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
send(ws, { type: 'assistant:text', text: event.text });
|
||||||
|
break;
|
||||||
|
case 'tool:start':
|
||||||
|
send(ws, { type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput });
|
||||||
|
break;
|
||||||
|
case 'tool:result':
|
||||||
|
send(ws, { type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError });
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
cleanup?.();
|
||||||
|
resolve(event.cost);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
cleanup?.();
|
||||||
|
reject(new Error(event.message));
|
||||||
|
break;
|
||||||
|
case 'stopped':
|
||||||
|
cleanup?.();
|
||||||
|
reject(new Error('Step was stopped'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isClaudeCode) {
|
||||||
|
const handle = await sendClaudeCodeStreaming({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
prompt,
|
||||||
|
sessionKey: sessionId,
|
||||||
|
cwd,
|
||||||
|
model,
|
||||||
|
role,
|
||||||
|
onEvent,
|
||||||
|
});
|
||||||
|
cleanup = handle.kill;
|
||||||
|
} else {
|
||||||
|
const unsub = sidecar.onPiEvent((evtSessionId, event) => {
|
||||||
|
if (evtSessionId === sessionId) onEvent(event);
|
||||||
|
});
|
||||||
|
cleanup = () => {
|
||||||
|
unsub();
|
||||||
|
sidecar.killPi(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
await sidecar.spawnPi({ sessionId, email, userId, username, role, cwd, model });
|
||||||
|
sidecar.sendPiPrompt(sessionId, prompt, randomUUID());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
cleanup?.();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInputTemplate(template: string, variables: Record<string, string>): string {
|
||||||
|
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targetDir?: string): string {
|
||||||
|
const inputLines = Object.entries(inputs)
|
||||||
|
.filter(([, v]) => v.trim())
|
||||||
|
.map(([key, value]) => `- **${key}**: ${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const contextLines: string[] = [];
|
||||||
|
if (targetDir) contextLines.push(`- **Target directory**: ${targetDir}`);
|
||||||
|
const contextSection = contextLines.length > 0 ? `\n\n## Context\n\n${contextLines.join('\n')}` : '';
|
||||||
|
|
||||||
|
return `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||||
|
const { email, role, userId } = ws.data;
|
||||||
|
|
||||||
|
const pipelineTask = await getTaskByDirName(msg.taskDirName, userId);
|
||||||
|
if (!pipelineTask) {
|
||||||
|
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pipelineTask.mode !== 'pipeline') {
|
||||||
|
send(ws, { type: 'error', message: 'Task is not a pipeline-mode task' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = pipelineTask.config as PipelineConfig | null;
|
||||||
|
if (!config?.steps?.length) {
|
||||||
|
send(ws, { type: 'error', message: 'Pipeline has no steps defined' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortSignal = { aborted: false };
|
||||||
|
activePipelines.set(ws, {
|
||||||
|
abort: () => { abortSignal.aborted = true; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseCwd = resolveBaseCwd(email, role, msg.cwd);
|
||||||
|
const totalCost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||||
|
|
||||||
|
// Send pipeline init
|
||||||
|
send(ws, {
|
||||||
|
type: 'pipeline:init',
|
||||||
|
steps: config.steps.map((s) => ({ task: s.task, foreach: s.foreach })),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let stepIdx = 0; stepIdx < config.steps.length; stepIdx++) {
|
||||||
|
if (abortSignal.aborted) break;
|
||||||
|
|
||||||
|
const step = config.steps[stepIdx]!;
|
||||||
|
|
||||||
|
// Resolve the referenced task
|
||||||
|
const stepTask = await getTaskByDirName(step.task, userId);
|
||||||
|
if (!stepTask) {
|
||||||
|
send(ws, { type: 'error', message: `Step task not found: ${step.task}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stepTask.body) {
|
||||||
|
send(ws, { type: 'error', message: `Step task "${step.task}" has no body` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve input templates using pipeline inputs
|
||||||
|
const resolvedInputs: Record<string, string> = {};
|
||||||
|
if (step.inputs) {
|
||||||
|
for (const [key, template] of Object.entries(step.inputs)) {
|
||||||
|
resolvedInputs[key] = resolveInputTemplate(template, msg.inputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.foreach === 'subdirectory') {
|
||||||
|
// Iterate over subdirectories
|
||||||
|
let subdirs: string[];
|
||||||
|
try {
|
||||||
|
subdirs = readdirSync(baseCwd, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory())
|
||||||
|
.map((d) => d.name)
|
||||||
|
.filter((name) => !name.startsWith('.'))
|
||||||
|
.sort();
|
||||||
|
} catch {
|
||||||
|
send(ws, { type: 'error', message: `Cannot read directory: ${baseCwd}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < subdirs.length; i++) {
|
||||||
|
if (abortSignal.aborted) break;
|
||||||
|
|
||||||
|
const subdir = subdirs[i]!;
|
||||||
|
const subdirPath = join(baseCwd, subdir);
|
||||||
|
|
||||||
|
// Check skip condition
|
||||||
|
if (step.skip_if && existsSync(join(subdirPath, step.skip_if))) {
|
||||||
|
send(ws, { type: 'step:skip', stepIndex: stepIdx, label: subdir, reason: `${step.skip_if} already exists` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve folder-specific variables
|
||||||
|
const iterVars = { ...msg.inputs, folder_name: subdir };
|
||||||
|
const iterInputs: Record<string, string> = {};
|
||||||
|
if (step.inputs) {
|
||||||
|
for (const [key, template] of Object.entries(step.inputs)) {
|
||||||
|
iterInputs[key] = resolveInputTemplate(template, iterVars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(ws, {
|
||||||
|
type: 'step:start',
|
||||||
|
stepIndex: stepIdx,
|
||||||
|
taskName: stepTask.name,
|
||||||
|
iteration: { current: i + 1, total: subdirs.length, label: subdir },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build relative path for cwd (sandbox-safe)
|
||||||
|
const stepCwd = subdirPath;
|
||||||
|
const cwdRelative = msg.cwd ? `${msg.cwd}/${subdir}` : subdir;
|
||||||
|
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
||||||
|
|
||||||
|
// Build ~/relative path for context
|
||||||
|
const targetDir = msg.cwd ? `~/${msg.cwd}/${subdir}` : `~/${subdir}`;
|
||||||
|
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
|
||||||
|
|
||||||
|
const cost = await runAgenticStep({
|
||||||
|
ws,
|
||||||
|
taskDirName: step.task,
|
||||||
|
prompt,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
abortSignal,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalCost.inputTokens += cost.inputTokens;
|
||||||
|
totalCost.outputTokens += cost.outputTokens;
|
||||||
|
totalCost.totalUSD += cost.totalUSD;
|
||||||
|
|
||||||
|
send(ws, { type: 'step:complete', stepIndex: stepIdx, cost });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single execution step
|
||||||
|
send(ws, { type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
|
||||||
|
|
||||||
|
const targetDir = msg.cwd ? `~/${msg.cwd}` : '~';
|
||||||
|
const prompt = buildStepPrompt(stepTask.body, resolvedInputs, targetDir);
|
||||||
|
|
||||||
|
const cost = await runAgenticStep({
|
||||||
|
ws,
|
||||||
|
taskDirName: step.task,
|
||||||
|
prompt,
|
||||||
|
cwd: baseCwd,
|
||||||
|
abortSignal,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalCost.inputTokens += cost.inputTokens;
|
||||||
|
totalCost.outputTokens += cost.outputTokens;
|
||||||
|
totalCost.totalUSD += cost.totalUSD;
|
||||||
|
|
||||||
|
send(ws, { type: 'step:complete', stepIndex: stepIdx, cost });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abortSignal.aborted) {
|
||||||
|
send(ws, { type: 'pipeline:complete', totalCost });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!abortSignal.aborted) {
|
||||||
|
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activePipelines.delete(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function open(_ws: ServerWebSocket<WSData>) {}
|
||||||
|
|
||||||
|
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||||
|
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data) as ClientMessage;
|
||||||
|
|
||||||
|
if (msg.type === 'run') {
|
||||||
|
handleRun(ws, msg);
|
||||||
|
} else if (msg.type === 'stop') {
|
||||||
|
const active = activePipelines.get(ws);
|
||||||
|
if (active) {
|
||||||
|
active.abort();
|
||||||
|
activePipelines.delete(ws);
|
||||||
|
send(ws, { type: 'stopped' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
send(ws, { type: 'error', message: 'Failed to parse message' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function close(ws: ServerWebSocket<WSData>) {
|
||||||
|
const active = activePipelines.get(ws);
|
||||||
|
if (active) {
|
||||||
|
active.abort();
|
||||||
|
activePipelines.delete(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pipelineWebsocket = {
|
||||||
|
open,
|
||||||
|
message,
|
||||||
|
close,
|
||||||
|
drain() {},
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@ tasksRouter.get('/:name', async (ctx) => {
|
|||||||
inputs: task.inputs,
|
inputs: task.inputs,
|
||||||
args: task.args,
|
args: task.args,
|
||||||
trigger: task.trigger,
|
trigger: task.trigger,
|
||||||
|
config: task.config,
|
||||||
version: task.version,
|
version: task.version,
|
||||||
userId: task.userId,
|
userId: task.userId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { QuestionActivity } from './QuestionActivity';
|
||||||
import { getRawUrl } from '../../FileViewer/file-types';
|
import { getRawUrl } from '../../FileViewer/file-types';
|
||||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
|
||||||
const sanitizeSchema = {
|
const sanitizeSchema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
@@ -135,7 +136,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
const assistantText = typeof message.text === 'string' ? message.text : '';
|
const assistantText = typeof message.text === 'string' ? message.text : '';
|
||||||
if (!assistantText) return null;
|
if (!assistantText) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start group">
|
||||||
<div className="max-w-[85%]">
|
<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="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||||
<div className="chat-md">
|
<div className="chat-md">
|
||||||
@@ -143,7 +144,8 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
{injectImages(assistantText)}
|
{injectImages(assistantText)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</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} />
|
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,9 +171,12 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start group">
|
||||||
<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="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}
|
{message.text}
|
||||||
|
<div className="flex justify-end -mb-1 -mr-1">
|
||||||
|
<CopyButton text={message.text} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
||||||
import type { ChatMessage } from '../types';
|
import type { ChatMessage } from '../types';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
|
||||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||||
|
|
||||||
@@ -74,8 +75,16 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="ml-7 mt-1 space-y-2 text-xs">
|
<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="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/input">
|
||||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
|
<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' ? (
|
{message.toolName === 'Bash' ? (
|
||||||
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
|
<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)}
|
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
|
||||||
@@ -90,8 +99,11 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message.output !== undefined && (
|
{message.output !== undefined && (
|
||||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
|
||||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
|
<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} />
|
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+236
-11
@@ -10,6 +10,7 @@ import { useUserVisibleModels } from 'state/useModels';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { TaskSummary } from '../../useTasks';
|
import type { TaskSummary } from '../../useTasks';
|
||||||
import { useTaskRunner } from './useTaskRunner';
|
import { useTaskRunner } from './useTaskRunner';
|
||||||
|
import { usePipelineRunner } from './usePipelineRunner';
|
||||||
|
|
||||||
const playDing = () => {
|
const playDing = () => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
@@ -38,17 +39,43 @@ const playDing = () => {
|
|||||||
type Phase = 'ready' | 'running' | 'done';
|
type Phase = 'ready' | 'running' | 'done';
|
||||||
|
|
||||||
type PiMonoInnerProps = {
|
type PiMonoInnerProps = {
|
||||||
|
taskDirName: string;
|
||||||
defaultInput: string;
|
defaultInput: string;
|
||||||
cwd: { root?: string; path: string };
|
cwd: { root?: string; path: string };
|
||||||
initialModel: string | null;
|
initialModel: string | null;
|
||||||
taskInfo: TaskInfo;
|
taskInfo: TaskInfo;
|
||||||
sandboxed?: boolean;
|
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 [phase, setPhase] = useState<Phase>('ready');
|
||||||
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||||
const availableModels = useUserVisibleModels();
|
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) ---
|
// --- Independent message accumulator (never loses messages) ---
|
||||||
const accRef = useRef<ChatMessage[]>([]);
|
const accRef = useRef<ChatMessage[]>([]);
|
||||||
@@ -114,17 +141,42 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
|||||||
}, [chat.isGenerating]);
|
}, [chat.isGenerating]);
|
||||||
|
|
||||||
const handleRun = () => {
|
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');
|
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') {
|
if (phase === 'ready') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{hasConfigurableInputs && (
|
||||||
|
<TaskInputForm
|
||||||
|
inputDefs={inputDefs}
|
||||||
|
values={formValues}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoFilledKeys={new Set()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleRun}
|
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"
|
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" />
|
<Play className="h-4 w-4" />
|
||||||
@@ -199,6 +251,7 @@ type TaskInputDef = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
default?: string;
|
default?: string;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
|
autofill?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TaskInputFormProps = {
|
type TaskInputFormProps = {
|
||||||
@@ -282,10 +335,11 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: TaskInpu
|
|||||||
type ScriptRunnerProps = {
|
type ScriptRunnerProps = {
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
autoInputs: Record<string, string>;
|
autoInputs: Record<string, string>;
|
||||||
|
context: Record<string, string>;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
|
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerProps) => {
|
||||||
const runner = useTaskRunner();
|
const runner = useTaskRunner();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
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) => {
|
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
|
||||||
const defs = task.inputs ?? {};
|
const defs = task.inputs ?? {};
|
||||||
setInputDefs(defs);
|
setInputDefs(defs);
|
||||||
// Initialize form values from defaults
|
// Initialize from autofill context, then defaults
|
||||||
const defaults: Record<string, string> = {};
|
const initial: Record<string, string> = {};
|
||||||
for (const [key, def] of Object.entries(defs)) {
|
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]);
|
}, [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 = {
|
type TaskRunnerModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -439,14 +646,22 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
const taskSettings = settings.tasks;
|
const taskSettings = settings.tasks;
|
||||||
const entryRef = entryFullPath ?? entryName;
|
const entryRef = entryFullPath ?? entryName;
|
||||||
const isScript = task.mode === 'script';
|
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
|
const defaultInput = promptOverride
|
||||||
?? (entryRef && entryType
|
?? (entryRef && entryType
|
||||||
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
|
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
|
||||||
: `Execute the task "${task.name}" (${task.dirName})`);
|
: `Execute the task "${task.name}" (${task.dirName})`);
|
||||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
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)
|
// Script mode: auto-filled inputs from context (e.g. file_path from file browser)
|
||||||
const autoInputs: Record<string, string> = {};
|
const autoInputs: Record<string, string> = {};
|
||||||
if (entryFullPath) autoInputs.file_path = entryFullPath;
|
if (entryFullPath) autoInputs.file_path = entryFullPath;
|
||||||
@@ -477,21 +692,31 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task Runner — branch on mode */}
|
{/* 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
|
<ScriptRunner
|
||||||
key="script"
|
key="script"
|
||||||
taskDirName={task.dirName}
|
taskDirName={task.dirName}
|
||||||
autoInputs={autoInputs}
|
autoInputs={autoInputs}
|
||||||
|
context={autofillContext}
|
||||||
cwd={cwd.path || undefined}
|
cwd={cwd.path || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PiMonoInner
|
<PiMonoInner
|
||||||
key="pi"
|
key="pi"
|
||||||
|
taskDirName={task.dirName}
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
cwd={cwd}
|
cwd={entryType === 'directory' && entryName ? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName } : cwd}
|
||||||
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
|
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
|
||||||
taskInfo={taskInfo}
|
taskInfo={taskInfo}
|
||||||
sandboxed={sandboxed}
|
sandboxed={sandboxed}
|
||||||
|
context={autofillContext}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</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;
|
description: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
triggers: TriggerConfig[];
|
triggers: TriggerConfig[];
|
||||||
mode: 'script' | 'agentic';
|
mode: 'script' | 'agentic' | 'pipeline';
|
||||||
userId: number | null;
|
userId: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user