add task input form and improve script runner output
- fetch task input definitions from API and render configurable inputs - boolean inputs render as No/Yes toggle (e.g. delete_source) - auto-filled inputs (file_path) are hidden from the form - wrap script output in dark pre/code block with copy button - fix ffmpeg -nostdin for batch directory processing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,10 @@ inputs:
|
|||||||
file_path:
|
file_path:
|
||||||
type: string
|
type: string
|
||||||
description: Path to an audio file or directory to convert.
|
description: Path to an audio file or directory to convert.
|
||||||
|
delete_source:
|
||||||
|
type: boolean
|
||||||
|
description: Delete source files after successful conversion.
|
||||||
|
default: false
|
||||||
args: [file_path]
|
args: [file_path]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
# ── Task: Convert To MP3 ──
|
# ── Task: Convert To MP3 ──
|
||||||
|
|
||||||
EXTENSIONS="flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff"
|
EXTENSIONS="flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff"
|
||||||
|
DELETE_SOURCE="${INPUT_DELETE_SOURCE:-false}"
|
||||||
|
|
||||||
process_file() {
|
process_file() {
|
||||||
local input="$1"
|
local input="$1"
|
||||||
@@ -25,8 +26,12 @@ process_file() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Converting: $input → $output"
|
echo "Converting: $input → $output"
|
||||||
if ffmpeg -i "$input" -codec:a libmp3lame -b:a 320k -map_metadata 0 -id3v2_version 3 -y "$output" 2>/dev/null; then
|
if ffmpeg -nostdin -i "$input" -codec:a libmp3lame -b:a 320k -map_metadata 0 -id3v2_version 3 -y "$output" 2>/dev/null; then
|
||||||
echo " Done"
|
echo " Done"
|
||||||
|
if [[ "$DELETE_SOURCE" == "true" ]]; then
|
||||||
|
rm "$input"
|
||||||
|
echo " Deleted source"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " Failed" >&2
|
echo " Failed" >&2
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+128
-10
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Play, Square, CircleCheck, CircleX } from 'lucide-react';
|
import { X, Play, Square, CircleCheck, CircleX, Copy, Check } from 'lucide-react';
|
||||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
@@ -7,6 +7,7 @@ import type { TaskInfo, ChatMessage } from '../../../Chat';
|
|||||||
import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
|
import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
import { useUserVisibleModels } from 'state/useModels';
|
import { useUserVisibleModels } from 'state/useModels';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { TaskSummary } from '../../useTasks';
|
import type { TaskSummary } from '../../useTasks';
|
||||||
import { useTaskRunner } from './useTaskRunner';
|
import { useTaskRunner } from './useTaskRunner';
|
||||||
|
|
||||||
@@ -191,17 +192,104 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Task input definitions ──
|
||||||
|
|
||||||
|
type TaskInputDef = {
|
||||||
|
type: string;
|
||||||
|
description?: string;
|
||||||
|
default?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskInputFormProps = {
|
||||||
|
inputDefs: Record<string, TaskInputDef>;
|
||||||
|
values: Record<string, string>;
|
||||||
|
onChange: (key: string, value: string) => void;
|
||||||
|
autoFilledKeys: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: TaskInputFormProps) => {
|
||||||
|
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
|
||||||
|
if (configurableInputs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
||||||
|
{configurableInputs.map(([key, def]) => {
|
||||||
|
if (def.type === 'boolean') {
|
||||||
|
const isTrue = values[key] === 'true';
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(key, 'false')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${!isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(key, 'true')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: text input for string/number
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">{def.description ?? key}</label>
|
||||||
|
<input
|
||||||
|
type={def.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={values[key] ?? ''}
|
||||||
|
onChange={(e) => onChange(key, e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Script-mode runner ──
|
// ── Script-mode runner ──
|
||||||
|
|
||||||
type ScriptRunnerProps = {
|
type ScriptRunnerProps = {
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
inputs: Record<string, string>;
|
autoInputs: Record<string, string>;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
|
const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
|
||||||
const runner = useTaskRunner();
|
const runner = useTaskRunner();
|
||||||
|
const client = useClient();
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
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 to get input definitions
|
||||||
|
useEffect(() => {
|
||||||
|
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> = {};
|
||||||
|
for (const [key, def] of Object.entries(defs)) {
|
||||||
|
if (def.default !== undefined) defaults[key] = def.default;
|
||||||
|
}
|
||||||
|
setFormValues(defaults);
|
||||||
|
});
|
||||||
|
}, [taskDirName]);
|
||||||
|
|
||||||
|
const autoFilledKeys = new Set(Object.keys(autoInputs));
|
||||||
|
|
||||||
|
const handleInputChange = (key: string, value: string) => {
|
||||||
|
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-scroll
|
// Auto-scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -217,29 +305,57 @@ const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
|
|||||||
prevPhaseRef.current = runner.phase;
|
prevPhaseRef.current = runner.phase;
|
||||||
}, [runner.phase]);
|
}, [runner.phase]);
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const handleCopy = () => {
|
||||||
|
const text = runner.output.map((line) => line.text).join('');
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRun = () => {
|
const handleRun = () => {
|
||||||
runner.run(taskDirName, inputs, cwd);
|
const allInputs = { ...formValues, ...autoInputs };
|
||||||
|
runner.run(taskDirName, allInputs, cwd);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (runner.phase === 'ready') {
|
if (runner.phase === 'ready') {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{inputDefs && (
|
||||||
|
<TaskInputForm
|
||||||
|
inputDefs={inputDefs}
|
||||||
|
values={formValues}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
autoFilledKeys={autoFilledKeys}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<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={!runner.isConnected}
|
disabled={!runner.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" />
|
||||||
Run
|
Run
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
||||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
<div className="relative group">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 text-white/40 hover:text-white hover:bg-white/20 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer z-10"
|
||||||
|
title="Copy output"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-gray-200 rounded-lg p-4 pr-10">
|
||||||
|
<code>
|
||||||
{runner.output.map((line, i) => (
|
{runner.output.map((line, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
@@ -254,7 +370,9 @@ const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
|
|||||||
{line.text}
|
{line.text}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
</div>
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
|
<div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
|
||||||
@@ -308,9 +426,9 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
: `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' };
|
||||||
|
|
||||||
// Script mode inputs — for now, map the entry path to file_path
|
// Script mode: auto-filled inputs from context (e.g. file_path from file browser)
|
||||||
const scriptInputs: Record<string, string> = {};
|
const autoInputs: Record<string, string> = {};
|
||||||
if (entryFullPath) scriptInputs.file_path = entryFullPath;
|
if (entryFullPath) autoInputs.file_path = entryFullPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -342,7 +460,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
<ScriptRunner
|
<ScriptRunner
|
||||||
key="script"
|
key="script"
|
||||||
taskDirName={task.dirName}
|
taskDirName={task.dirName}
|
||||||
inputs={scriptInputs}
|
autoInputs={autoInputs}
|
||||||
cwd={cwd.path || undefined}
|
cwd={cwd.path || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user