@geminilight/mindos 0.6.29 → 0.6.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/app/app/api/acp/config/route.ts +82 -0
- package/app/app/api/acp/detect/route.ts +71 -48
- package/app/app/api/acp/install/route.ts +51 -0
- package/app/app/api/acp/session/route.ts +141 -11
- package/app/app/api/ask/route.ts +116 -13
- package/app/app/api/workflows/route.ts +156 -0
- package/app/app/page.tsx +7 -2
- package/app/components/ActivityBar.tsx +12 -4
- package/app/components/AskModal.tsx +4 -1
- package/app/components/FileTree.tsx +21 -10
- package/app/components/HomeContent.tsx +1 -0
- package/app/components/Panel.tsx +1 -0
- package/app/components/RightAskPanel.tsx +5 -1
- package/app/components/SidebarLayout.tsx +6 -0
- package/app/components/agents/AgentDetailContent.tsx +263 -47
- package/app/components/agents/AgentsContentPage.tsx +11 -0
- package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
- package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
- package/app/components/agents/agents-content-model.ts +2 -2
- package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
- package/app/components/ask/AskContent.tsx +197 -239
- package/app/components/ask/FileChip.tsx +82 -17
- package/app/components/ask/MentionPopover.tsx +21 -3
- package/app/components/ask/MessageList.tsx +30 -9
- package/app/components/ask/SlashCommandPopover.tsx +21 -3
- package/app/components/panels/AgentsPanel.tsx +1 -0
- package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
- package/app/components/panels/WorkflowsPanel.tsx +206 -0
- package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
- package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
- package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
- package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
- package/app/components/renderers/workflow-yaml/execution.ts +177 -0
- package/app/components/renderers/workflow-yaml/index.ts +6 -0
- package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
- package/app/components/renderers/workflow-yaml/parser.ts +172 -0
- package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
- package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
- package/app/components/renderers/workflow-yaml/types.ts +46 -0
- package/app/hooks/useAcpConfig.ts +96 -0
- package/app/hooks/useAcpDetection.ts +69 -14
- package/app/hooks/useAcpRegistry.ts +46 -11
- package/app/hooks/useAskModal.ts +12 -5
- package/app/hooks/useAskPanel.ts +8 -5
- package/app/hooks/useAskSession.ts +19 -2
- package/app/hooks/useImageUpload.ts +152 -0
- package/app/lib/acp/acp-tools.ts +3 -1
- package/app/lib/acp/agent-descriptors.ts +274 -0
- package/app/lib/acp/bridge.ts +6 -0
- package/app/lib/acp/index.ts +20 -4
- package/app/lib/acp/registry.ts +74 -7
- package/app/lib/acp/session.ts +481 -28
- package/app/lib/acp/subprocess.ts +307 -21
- package/app/lib/acp/types.ts +158 -20
- package/app/lib/agent/model.ts +18 -3
- package/app/lib/agent/to-agent-messages.ts +25 -2
- package/app/lib/i18n/modules/knowledge.ts +4 -0
- package/app/lib/i18n/modules/navigation.ts +2 -0
- package/app/lib/i18n/modules/panels.ts +146 -2
- package/app/lib/pi-integration/skills.ts +21 -6
- package/app/lib/renderers/index.ts +2 -2
- package/app/lib/settings.ts +10 -0
- package/app/lib/types.ts +12 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +3 -1
- package/package.json +1 -1
- package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
- package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
- package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
- package/app/components/renderers/workflow/manifest.ts +0 -14
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react';
|
|
5
|
+
import { AgentSelector, ModelSelector, SkillsSelector, ContextSelector } from './selectors';
|
|
6
|
+
import type { WorkflowStep } from './types';
|
|
7
|
+
|
|
8
|
+
interface StepEditorProps {
|
|
9
|
+
step: WorkflowStep;
|
|
10
|
+
index: number;
|
|
11
|
+
onChange: (step: WorkflowStep) => void;
|
|
12
|
+
onDelete: () => void;
|
|
13
|
+
onMoveUp?: () => void;
|
|
14
|
+
onMoveDown?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function StepEditor({ step, index, onChange, onDelete, onMoveUp, onMoveDown }: StepEditorProps) {
|
|
18
|
+
// Auto-expand if step has no prompt (newly created)
|
|
19
|
+
const [expanded, setExpanded] = useState(!step.prompt);
|
|
20
|
+
|
|
21
|
+
const update = (patch: Partial<WorkflowStep>) => onChange({ ...step, ...patch });
|
|
22
|
+
|
|
23
|
+
// Merge legacy single skill into skills array for display
|
|
24
|
+
const allSkills = step.skills?.length ? step.skills : (step.skill ? [step.skill] : []);
|
|
25
|
+
|
|
26
|
+
// Collapsed view: summary line
|
|
27
|
+
if (!expanded) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="group flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border bg-card hover:border-[var(--amber)]/30 transition-colors cursor-pointer"
|
|
30
|
+
onClick={() => setExpanded(true)}>
|
|
31
|
+
<span className="text-2xs text-muted-foreground/60 font-mono w-5 text-center shrink-0">{index + 1}</span>
|
|
32
|
+
<span className={`text-sm font-medium truncate flex-1 ${step.name ? 'text-foreground' : 'text-muted-foreground italic'}`}>
|
|
33
|
+
{step.name || 'Untitled step'}
|
|
34
|
+
</span>
|
|
35
|
+
<div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">
|
|
36
|
+
{allSkills.slice(0, 2).map(s => (
|
|
37
|
+
<span key={s} className="text-2xs px-1.5 py-0.5 rounded bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20">{s}</span>
|
|
38
|
+
))}
|
|
39
|
+
{allSkills.length > 2 && (
|
|
40
|
+
<span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground">+{allSkills.length - 2}</span>
|
|
41
|
+
)}
|
|
42
|
+
{step.agent && <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">🤖 {step.agent}</span>}
|
|
43
|
+
{step.agent && step.model && <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">🧠 {step.model}</span>}
|
|
44
|
+
{step.context?.length ? <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">📎 {step.context.length}</span> : null}
|
|
45
|
+
</div>
|
|
46
|
+
<ChevronDown size={12} className="text-muted-foreground/50 shrink-0" />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Expanded edit form
|
|
52
|
+
return (
|
|
53
|
+
<div className="rounded-xl border border-[var(--amber)]/30 bg-card overflow-hidden">
|
|
54
|
+
{/* Header */}
|
|
55
|
+
<div className="flex items-center gap-2 px-3.5 py-2.5 border-b border-border bg-muted/30">
|
|
56
|
+
<span className="text-2xs text-muted-foreground/60 font-mono w-5 text-center shrink-0">{index + 1}</span>
|
|
57
|
+
<span className={`text-xs font-medium flex-1 truncate ${step.name ? 'text-foreground' : 'text-muted-foreground italic'}`}>
|
|
58
|
+
{step.name || 'Untitled step'}
|
|
59
|
+
</span>
|
|
60
|
+
<div className="flex items-center gap-0.5">
|
|
61
|
+
{onMoveUp && (
|
|
62
|
+
<button onClick={onMoveUp} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Move up">
|
|
63
|
+
<ChevronUp size={13} />
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
{onMoveDown && (
|
|
67
|
+
<button onClick={onMoveDown} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Move down">
|
|
68
|
+
<ChevronDown size={13} />
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
<button onClick={onDelete} className="p-1 rounded hover:bg-[var(--error)]/10 text-muted-foreground hover:text-[var(--error)] transition-colors" title="Delete step">
|
|
72
|
+
<Trash2 size={12} />
|
|
73
|
+
</button>
|
|
74
|
+
<button onClick={() => setExpanded(false)} className="p-1 rounded hover:bg-muted text-muted-foreground transition-colors" title="Collapse">
|
|
75
|
+
<ChevronUp size={13} />
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Form body */}
|
|
81
|
+
<div className="px-3.5 py-3 space-y-3">
|
|
82
|
+
{/* Step name */}
|
|
83
|
+
<div>
|
|
84
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Step name</label>
|
|
85
|
+
<input type="text" value={step.name} onChange={e => update({ name: e.target.value })}
|
|
86
|
+
placeholder="e.g. Run Tests"
|
|
87
|
+
autoFocus={!step.name}
|
|
88
|
+
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Prompt */}
|
|
93
|
+
<div>
|
|
94
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Prompt</label>
|
|
95
|
+
<textarea value={step.prompt} onChange={e => update({ prompt: e.target.value })}
|
|
96
|
+
placeholder="Describe what the AI should do in this step..."
|
|
97
|
+
rows={4}
|
|
98
|
+
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y leading-relaxed"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Agent + Model */}
|
|
103
|
+
<div className={`grid ${step.agent ? 'grid-cols-2' : 'grid-cols-1'} gap-3`}>
|
|
104
|
+
<div>
|
|
105
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Agent</label>
|
|
106
|
+
<AgentSelector value={step.agent} onChange={agent => update({ agent, model: agent ? step.model : undefined })} />
|
|
107
|
+
</div>
|
|
108
|
+
{step.agent && (
|
|
109
|
+
<div>
|
|
110
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Model</label>
|
|
111
|
+
<ModelSelector value={step.model} onChange={model => update({ model })} />
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Skills (multi-select) */}
|
|
117
|
+
<div>
|
|
118
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Skills</label>
|
|
119
|
+
<SkillsSelector
|
|
120
|
+
value={allSkills}
|
|
121
|
+
onChange={skills => update({ skills, skill: undefined })}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Context files */}
|
|
126
|
+
<div>
|
|
127
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Context files</label>
|
|
128
|
+
<ContextSelector
|
|
129
|
+
value={step.context ?? []}
|
|
130
|
+
onChange={context => update({ context: context.length ? context : undefined })}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Description + Timeout in one row */}
|
|
135
|
+
<div className="grid grid-cols-[1fr,auto] gap-3">
|
|
136
|
+
<div>
|
|
137
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Description <span className="text-muted-foreground/50">(optional)</span></label>
|
|
138
|
+
<input type="text" value={step.description || ''} onChange={e => update({ description: e.target.value || undefined })}
|
|
139
|
+
placeholder="Brief description of this step"
|
|
140
|
+
className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Timeout</label>
|
|
145
|
+
<div className="flex items-center gap-1.5">
|
|
146
|
+
<input type="number" min={0} value={step.timeout || ''} onChange={e => update({ timeout: e.target.value ? Number(e.target.value) : undefined })}
|
|
147
|
+
placeholder="120"
|
|
148
|
+
className="w-20 px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
149
|
+
/>
|
|
150
|
+
<span className="text-2xs text-muted-foreground/50">sec</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { Plus, Save, Loader2, FolderOpen, Zap, CheckCircle2 } from 'lucide-react';
|
|
5
|
+
import StepEditor from './StepEditor';
|
|
6
|
+
import { serializeWorkflowYaml, generateStepId } from './serializer';
|
|
7
|
+
import type { WorkflowYaml, WorkflowStep } from './types';
|
|
8
|
+
import { DirPicker } from './selectors';
|
|
9
|
+
|
|
10
|
+
interface WorkflowEditorProps {
|
|
11
|
+
workflow: WorkflowYaml;
|
|
12
|
+
filePath: string;
|
|
13
|
+
onChange: (workflow: WorkflowYaml) => void;
|
|
14
|
+
onSaved?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function WorkflowEditor({ workflow, filePath, onChange, onSaved }: WorkflowEditorProps) {
|
|
18
|
+
const [saving, setSaving] = useState(false);
|
|
19
|
+
const [saveError, setSaveError] = useState('');
|
|
20
|
+
const [saveSuccess, setSaveSuccess] = useState(false);
|
|
21
|
+
|
|
22
|
+
// Clear success indicator after 3s
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!saveSuccess) return;
|
|
25
|
+
const t = setTimeout(() => setSaveSuccess(false), 3000);
|
|
26
|
+
return () => clearTimeout(t);
|
|
27
|
+
}, [saveSuccess]);
|
|
28
|
+
|
|
29
|
+
const updateMeta = (patch: Partial<WorkflowYaml>) => {
|
|
30
|
+
onChange({ ...workflow, ...patch });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const updateStep = useCallback((index: number, step: WorkflowStep) => {
|
|
34
|
+
const steps = [...workflow.steps];
|
|
35
|
+
steps[index] = step;
|
|
36
|
+
onChange({ ...workflow, steps });
|
|
37
|
+
}, [workflow, onChange]);
|
|
38
|
+
|
|
39
|
+
const deleteStep = useCallback((index: number) => {
|
|
40
|
+
const step = workflow.steps[index];
|
|
41
|
+
const hasContent = step.name || step.prompt;
|
|
42
|
+
if (hasContent && !window.confirm(`Delete step "${step.name || 'Untitled'}"?`)) return;
|
|
43
|
+
onChange({ ...workflow, steps: workflow.steps.filter((_, i) => i !== index) });
|
|
44
|
+
}, [workflow, onChange]);
|
|
45
|
+
|
|
46
|
+
const addStep = useCallback(() => {
|
|
47
|
+
const existingIds = workflow.steps.map(s => s.id);
|
|
48
|
+
const num = workflow.steps.length + 1;
|
|
49
|
+
const id = generateStepId(`step-${num}`, existingIds);
|
|
50
|
+
const step: WorkflowStep = { id, name: `Step ${num}`, prompt: '' };
|
|
51
|
+
onChange({ ...workflow, steps: [...workflow.steps, step] });
|
|
52
|
+
}, [workflow, onChange]);
|
|
53
|
+
|
|
54
|
+
const moveStep = useCallback((from: number, to: number) => {
|
|
55
|
+
if (to < 0 || to >= workflow.steps.length) return;
|
|
56
|
+
const steps = [...workflow.steps];
|
|
57
|
+
const [moved] = steps.splice(from, 1);
|
|
58
|
+
steps.splice(to, 0, moved);
|
|
59
|
+
onChange({ ...workflow, steps });
|
|
60
|
+
}, [workflow, onChange]);
|
|
61
|
+
|
|
62
|
+
const handleSave = async () => {
|
|
63
|
+
setSaving(true);
|
|
64
|
+
setSaveError('');
|
|
65
|
+
setSaveSuccess(false);
|
|
66
|
+
try {
|
|
67
|
+
const yaml = serializeWorkflowYaml(workflow);
|
|
68
|
+
const res = await fetch('/api/file', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ path: filePath, op: 'save_file', content: yaml }),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const data = await res.json().catch(() => ({}));
|
|
75
|
+
throw new Error(data.error || `Save failed (HTTP ${res.status})`);
|
|
76
|
+
}
|
|
77
|
+
setSaveSuccess(true);
|
|
78
|
+
onSaved?.();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
setSaveError(err instanceof Error ? err.message : 'Save failed');
|
|
81
|
+
} finally {
|
|
82
|
+
setSaving(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Keyboard shortcut: Cmd/Ctrl+S to save
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const handler = (e: KeyboardEvent) => {
|
|
89
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
if (!saving && workflow.title.trim() && workflow.steps.length > 0) {
|
|
92
|
+
handleSave();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener('keydown', handler);
|
|
97
|
+
return () => window.removeEventListener('keydown', handler);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const canSave = !saving && !!workflow.title.trim() && workflow.steps.length > 0;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div>
|
|
104
|
+
{/* Metadata */}
|
|
105
|
+
<div className="space-y-3 mb-6">
|
|
106
|
+
<div>
|
|
107
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Title</label>
|
|
108
|
+
<input type="text" value={workflow.title} onChange={e => updateMeta({ title: e.target.value })}
|
|
109
|
+
placeholder="Workflow title"
|
|
110
|
+
className="w-full px-3 py-2 text-sm font-medium rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="grid grid-cols-[1fr,auto] gap-3">
|
|
114
|
+
<div>
|
|
115
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">Description <span className="text-muted-foreground/50">(optional)</span></label>
|
|
116
|
+
<input type="text" value={workflow.description || ''} onChange={e => updateMeta({ description: e.target.value || undefined })}
|
|
117
|
+
placeholder="What does this workflow do?"
|
|
118
|
+
className="w-full px-3 py-1.5 text-xs rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div>
|
|
122
|
+
<label className="block text-2xs font-medium text-muted-foreground mb-1">
|
|
123
|
+
<FolderOpen size={10} className="inline mr-0.5 -mt-0.5" />
|
|
124
|
+
Working dir
|
|
125
|
+
</label>
|
|
126
|
+
<DirPicker value={workflow.workDir || ''} onChange={v => updateMeta({ workDir: v || undefined })} />
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Steps section */}
|
|
132
|
+
{workflow.steps.length > 0 ? (
|
|
133
|
+
<>
|
|
134
|
+
{/* Steps header */}
|
|
135
|
+
<div className="flex items-center justify-between mb-3">
|
|
136
|
+
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
137
|
+
Steps ({workflow.steps.length})
|
|
138
|
+
</h3>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Step list */}
|
|
142
|
+
<div className="flex flex-col gap-2 mb-4">
|
|
143
|
+
{workflow.steps.map((step, i) => (
|
|
144
|
+
<StepEditor
|
|
145
|
+
key={step.id}
|
|
146
|
+
step={step}
|
|
147
|
+
index={i}
|
|
148
|
+
onChange={s => updateStep(i, s)}
|
|
149
|
+
onDelete={() => deleteStep(i)}
|
|
150
|
+
onMoveUp={i > 0 ? () => moveStep(i, i - 1) : undefined}
|
|
151
|
+
onMoveDown={i < workflow.steps.length - 1 ? () => moveStep(i, i + 1) : undefined}
|
|
152
|
+
/>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* Add step */}
|
|
157
|
+
<button onClick={addStep}
|
|
158
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-border text-xs text-muted-foreground hover:text-foreground hover:border-[var(--amber)]/30 hover:bg-muted/30 transition-colors">
|
|
159
|
+
<Plus size={13} />
|
|
160
|
+
Add step
|
|
161
|
+
</button>
|
|
162
|
+
</>
|
|
163
|
+
) : (
|
|
164
|
+
/* Empty state: prominent CTA */
|
|
165
|
+
<div className="flex flex-col items-center justify-center py-10 px-4 text-center rounded-xl border border-dashed border-border bg-muted/10">
|
|
166
|
+
<Zap size={28} className="text-muted-foreground/30 mb-3" />
|
|
167
|
+
<p className="text-sm font-medium text-muted-foreground mb-1">No steps yet</p>
|
|
168
|
+
<p className="text-xs text-muted-foreground/60 mb-4 max-w-[260px]">
|
|
169
|
+
Add your first step to define what the AI should do.
|
|
170
|
+
</p>
|
|
171
|
+
<button onClick={addStep}
|
|
172
|
+
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-medium bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors hover:opacity-90">
|
|
173
|
+
<Plus size={13} />
|
|
174
|
+
Add first step
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/* Save bar */}
|
|
180
|
+
<div className="flex items-center gap-3 mt-6 pt-4 border-t border-border">
|
|
181
|
+
<button onClick={handleSave} disabled={!canSave}
|
|
182
|
+
title={!workflow.title.trim() ? 'Title is required' : workflow.steps.length === 0 ? 'Add at least one step' : undefined}
|
|
183
|
+
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-[var(--amber)] text-[var(--amber-foreground)]">
|
|
184
|
+
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />}
|
|
185
|
+
{saving ? 'Saving...' : 'Save'}
|
|
186
|
+
</button>
|
|
187
|
+
|
|
188
|
+
{saveError && <span className="text-xs text-[var(--error)]">{saveError}</span>}
|
|
189
|
+
|
|
190
|
+
{saveSuccess && !saveError && (
|
|
191
|
+
<span className="flex items-center gap-1 text-2xs text-[var(--success)] animate-in fade-in">
|
|
192
|
+
<CheckCircle2 size={11} />
|
|
193
|
+
Saved
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<span className="text-2xs text-muted-foreground/40 ml-auto">Ctrl+S</span>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
|
+
import { Play, SkipForward, RotateCcw, CheckCircle2, Circle, Loader2, AlertCircle, ChevronDown, Sparkles, XCircle, Clock } from 'lucide-react';
|
|
5
|
+
import { runStepWithAI, clearSkillCache } from './execution';
|
|
6
|
+
import type { WorkflowYaml, WorkflowStepRuntime, StepStatus } from './types';
|
|
7
|
+
|
|
8
|
+
function initSteps(workflow: WorkflowYaml): WorkflowStepRuntime[] {
|
|
9
|
+
return workflow.steps.map((s, idx) => ({
|
|
10
|
+
...s, index: idx, status: 'pending' as const, output: '', error: undefined,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function StatusIcon({ status }: { status: StepStatus }) {
|
|
15
|
+
if (status === 'pending') return <Circle size={15} className="text-border" />;
|
|
16
|
+
if (status === 'running') return <Loader2 size={15} className="text-[var(--amber)] animate-spin" />;
|
|
17
|
+
if (status === 'done') return <CheckCircle2 size={15} className="text-[var(--success)]" />;
|
|
18
|
+
if (status === 'skipped') return <SkipForward size={15} className="text-muted-foreground opacity-50" />;
|
|
19
|
+
return <AlertCircle size={15} className="text-[var(--error)]" />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Badge({ label, variant }: { label: string; variant?: 'amber' | 'default' }) {
|
|
23
|
+
return (
|
|
24
|
+
<span className={`inline-flex items-center gap-1 text-2xs px-2 py-0.5 rounded whitespace-nowrap ${
|
|
25
|
+
variant === 'amber'
|
|
26
|
+
? 'bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20'
|
|
27
|
+
: 'bg-muted text-muted-foreground'
|
|
28
|
+
}`}>
|
|
29
|
+
{label}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatDuration(ms: number): string {
|
|
35
|
+
if (ms < 1000) return `${ms}ms`;
|
|
36
|
+
const secs = Math.round(ms / 1000);
|
|
37
|
+
if (secs < 60) return `${secs}s`;
|
|
38
|
+
return `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function RunStepCard({ step, canRun, onRun, onSkip, onCancel }: {
|
|
42
|
+
step: WorkflowStepRuntime; canRun: boolean;
|
|
43
|
+
onRun: () => void; onSkip: () => void; onCancel: () => void;
|
|
44
|
+
}) {
|
|
45
|
+
const [expanded, setExpanded] = useState(false);
|
|
46
|
+
const hasContent = !!(step.description || step.output || step.error);
|
|
47
|
+
const borderColor = {
|
|
48
|
+
pending: 'border-border', running: 'border-[var(--amber)]/50',
|
|
49
|
+
done: 'border-[var(--success)]/40', skipped: 'border-border', error: 'border-[var(--error)]/40',
|
|
50
|
+
}[step.status];
|
|
51
|
+
|
|
52
|
+
// Merge skills for display
|
|
53
|
+
const allSkills = step.skills?.length ? step.skills : (step.skill ? [step.skill] : []);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={`rounded-xl border overflow-hidden bg-card transition-all ${borderColor} ${step.status === 'skipped' ? 'opacity-60' : ''}`}>
|
|
57
|
+
<div className="flex items-center gap-2.5 px-3.5 py-2.5 justify-between flex-wrap">
|
|
58
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
59
|
+
<StatusIcon status={step.status} />
|
|
60
|
+
<div className="min-w-0">
|
|
61
|
+
<div className="font-semibold text-sm text-foreground cursor-pointer" onClick={() => hasContent && setExpanded(v => !v)}>
|
|
62
|
+
{step.name}
|
|
63
|
+
</div>
|
|
64
|
+
{(step.agent || allSkills.length > 0 || step.model) && (
|
|
65
|
+
<div className="flex gap-1.5 mt-1 flex-wrap">
|
|
66
|
+
{allSkills.map(s => <Badge key={s} label={`🎓 ${s}`} variant="amber" />)}
|
|
67
|
+
{step.agent && <Badge label={`🤖 ${step.agent}`} />}
|
|
68
|
+
{step.model && <Badge label={`🧠 ${step.model}`} />}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
74
|
+
{/* Duration display */}
|
|
75
|
+
{step.durationMs != null && (step.status === 'done' || step.status === 'error') && (
|
|
76
|
+
<span className="flex items-center gap-1 text-2xs text-muted-foreground/60">
|
|
77
|
+
<Clock size={10} />
|
|
78
|
+
{formatDuration(step.durationMs)}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{step.status === 'pending' && (
|
|
83
|
+
<>
|
|
84
|
+
<button onClick={onRun} disabled={!canRun}
|
|
85
|
+
title={!canRun ? 'Another step is running' : undefined}
|
|
86
|
+
className="flex items-center gap-1 px-2.5 py-1 rounded-md text-2xs font-medium border-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-[var(--amber)] text-[var(--amber-foreground)] disabled:bg-muted disabled:text-muted-foreground"
|
|
87
|
+
><Play size={10} /> Run</button>
|
|
88
|
+
<button onClick={onSkip} className="px-2 py-1 rounded-md text-2xs border border-border bg-transparent text-muted-foreground hover:bg-muted transition-colors">Skip</button>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
{step.status === 'running' && (
|
|
92
|
+
<button onClick={onCancel} className="flex items-center gap-1 px-2.5 py-1 rounded-md text-2xs border border-[var(--error)] bg-transparent text-[var(--error)] hover:bg-[var(--error)]/10 transition-colors">
|
|
93
|
+
<XCircle size={10} /> Cancel
|
|
94
|
+
</button>
|
|
95
|
+
)}
|
|
96
|
+
{(step.status === 'done' || step.status === 'error') && (
|
|
97
|
+
<button onClick={() => setExpanded(v => !v)} className="px-2 py-1 rounded-md text-2xs border border-border bg-transparent text-muted-foreground hover:bg-muted transition-colors">
|
|
98
|
+
<ChevronDown size={11} className={`inline transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
{(expanded || step.status === 'running') && hasContent && (
|
|
104
|
+
<div className="border-t border-border">
|
|
105
|
+
{step.description && (
|
|
106
|
+
<div className={`px-3.5 py-2.5 text-xs leading-relaxed text-muted-foreground ${(step.output || step.error) ? 'border-b border-border' : ''}`}>
|
|
107
|
+
{step.description}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
{step.error && (
|
|
111
|
+
<div className="px-3.5 py-2.5 bg-[var(--error)]/10">
|
|
112
|
+
<div className="flex items-center gap-1.5 mb-1.5">
|
|
113
|
+
<AlertCircle size={11} className="text-[var(--error)]" />
|
|
114
|
+
<span className="text-2xs text-[var(--error)] uppercase font-medium">Error</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="text-xs text-[var(--error)] whitespace-pre-wrap break-words">{step.error}</div>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
{step.output && (
|
|
120
|
+
<div className="px-3.5 py-2.5 bg-background">
|
|
121
|
+
<div className="flex items-center gap-1.5 mb-1.5">
|
|
122
|
+
<Sparkles size={11} className="text-[var(--amber)]" />
|
|
123
|
+
<span className="text-2xs text-muted-foreground uppercase tracking-wide">AI Output</span>
|
|
124
|
+
{step.status === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)] animate-pulse ml-1" />}
|
|
125
|
+
</div>
|
|
126
|
+
<div className="text-xs leading-relaxed text-foreground whitespace-pre-wrap break-words">{step.output}</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Main Runner ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
export default function WorkflowRunner({ workflow, filePath }: { workflow: WorkflowYaml; filePath: string }) {
|
|
138
|
+
const [steps, setSteps] = useState<WorkflowStepRuntime[]>(() => initSteps(workflow));
|
|
139
|
+
const [running, setRunning] = useState(false);
|
|
140
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
setSteps(initSteps(workflow));
|
|
144
|
+
setRunning(false);
|
|
145
|
+
}, [workflow]);
|
|
146
|
+
|
|
147
|
+
const cancelExecution = useCallback(() => {
|
|
148
|
+
abortRef.current?.abort();
|
|
149
|
+
abortRef.current = null;
|
|
150
|
+
setRunning(false);
|
|
151
|
+
setSteps(prev => prev.map(s => s.status === 'running' ? { ...s, status: 'error', error: 'Cancelled by user' } : s));
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const runStep = useCallback(async (idx: number) => {
|
|
155
|
+
if (running) return;
|
|
156
|
+
abortRef.current?.abort();
|
|
157
|
+
const ctrl = new AbortController();
|
|
158
|
+
abortRef.current = ctrl;
|
|
159
|
+
setRunning(true);
|
|
160
|
+
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'running', output: '', error: undefined, startedAt: new Date() } : s));
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const step = workflow.steps[idx];
|
|
166
|
+
const runtimeStep: WorkflowStepRuntime = { ...step, index: idx, status: 'running', output: '' };
|
|
167
|
+
await runStepWithAI(runtimeStep, workflow, filePath,
|
|
168
|
+
(acc) => setSteps(prev => prev.map((s, i) => i === idx ? { ...s, output: acc } : s)),
|
|
169
|
+
ctrl.signal);
|
|
170
|
+
const duration = Date.now() - startTime;
|
|
171
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'done', completedAt: new Date(), durationMs: duration } : s));
|
|
172
|
+
} catch (err: unknown) {
|
|
173
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
174
|
+
const duration = Date.now() - startTime;
|
|
175
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'error', error: err instanceof Error ? err.message : String(err), durationMs: duration } : s));
|
|
176
|
+
} finally { setRunning(false); }
|
|
177
|
+
}, [running, workflow, filePath]);
|
|
178
|
+
|
|
179
|
+
const skipStep = useCallback((idx: number) => {
|
|
180
|
+
setSteps(prev => prev.map((s, i) => i === idx ? { ...s, status: 'skipped' } : s));
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const reset = useCallback(() => {
|
|
184
|
+
abortRef.current?.abort();
|
|
185
|
+
abortRef.current = null;
|
|
186
|
+
setRunning(false);
|
|
187
|
+
clearSkillCache();
|
|
188
|
+
setSteps(initSteps(workflow));
|
|
189
|
+
}, [workflow]);
|
|
190
|
+
|
|
191
|
+
const doneCount = steps.filter(s => s.status === 'done').length;
|
|
192
|
+
const progress = steps.length > 0 ? Math.round((doneCount / steps.length) * 100) : 0;
|
|
193
|
+
const allDone = doneCount === steps.length && steps.length > 0;
|
|
194
|
+
const nextPendingIdx = steps.findIndex(s => s.status === 'pending');
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div>
|
|
198
|
+
{workflow.description && (
|
|
199
|
+
<p className="text-xs text-muted-foreground leading-relaxed mb-3">{workflow.description}</p>
|
|
200
|
+
)}
|
|
201
|
+
<div className="flex items-center gap-2.5 flex-wrap mb-4">
|
|
202
|
+
<div className="flex-1 min-w-[120px] h-1 rounded-full bg-border overflow-hidden">
|
|
203
|
+
<div className={`h-full rounded-full transition-all duration-300 ${allDone ? 'bg-[var(--success)]' : 'bg-[var(--amber)]'}`} style={{ width: `${progress}%` }} />
|
|
204
|
+
</div>
|
|
205
|
+
<span className={`text-2xs shrink-0 ${allDone ? 'text-[var(--success)] font-medium' : 'text-muted-foreground'}`}>
|
|
206
|
+
{doneCount}/{steps.length}{allDone ? ' Complete' : ''}
|
|
207
|
+
</span>
|
|
208
|
+
{nextPendingIdx >= 0 && (
|
|
209
|
+
<button onClick={() => runStep(nextPendingIdx)} disabled={running}
|
|
210
|
+
title={running ? 'A step is currently running' : undefined}
|
|
211
|
+
className="flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium border-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed bg-[var(--amber)] text-[var(--amber-foreground)] disabled:bg-muted disabled:text-muted-foreground"
|
|
212
|
+
>{running ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />} Run next</button>
|
|
213
|
+
)}
|
|
214
|
+
<button onClick={reset} className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs border border-border bg-transparent text-muted-foreground hover:bg-muted transition-colors">
|
|
215
|
+
<RotateCcw size={11} /> Reset
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
<div className="flex flex-col gap-2">
|
|
219
|
+
{steps.map((step, i) => (
|
|
220
|
+
<RunStepCard key={step.id} step={step} canRun={!running}
|
|
221
|
+
onRun={() => runStep(i)} onSkip={() => skipStep(i)} onCancel={cancelExecution} />
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|