sync: editable workflows from private repo
- Users can edit/create workflows from Settings → Workflows tab - File-based storage for custom workflows - Bundled workflows serve as fallback when no user file exists - Added createEdge tool access for workflows (Quick Link) - Cleaned up stale terminology in workflow prompts Files synced: - src/services/workflows/workflowFileService.ts (NEW) - app/api/workflows/[key]/route.ts (NEW) - src/services/workflows/registry.ts - src/components/settings/WorkflowsViewer.tsx - src/tools/infrastructure/registry.ts - src/services/agents/wiseRAHExecutor.ts - src/config/workflows/integrate.ts - src/config/prompts/wise-rah.ts
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { saveWorkflow, deleteWorkflow, userWorkflowExists } from '@/services/workflows/workflowFileService';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
|
||||
// PUT /api/workflows/[key] - Create or update a workflow
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
try {
|
||||
const { key } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.displayName || typeof body.displayName !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'displayName is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.instructions || typeof body.instructions !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'instructions is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save the workflow
|
||||
saveWorkflow({
|
||||
key,
|
||||
displayName: body.displayName.trim(),
|
||||
description: body.description?.trim() || '',
|
||||
instructions: body.instructions,
|
||||
enabled: body.enabled !== false,
|
||||
requiresFocusedNode: body.requiresFocusedNode !== false,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving workflow:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to save workflow' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/workflows/[key] - Delete a workflow (resets to default if bundled)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
try {
|
||||
const { key } = await params;
|
||||
|
||||
// Check if user file exists
|
||||
if (!userWorkflowExists(key)) {
|
||||
// If it's a bundled workflow, nothing to delete
|
||||
if (BUNDLED_WORKFLOW_KEYS.has(key)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Cannot delete bundled workflow (no user override exists)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Workflow not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = deleteWorkflow(key);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete workflow' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// If this was a bundled workflow, it will now fall back to default
|
||||
const isBundled = BUNDLED_WORKFLOW_KEYS.has(key);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
resetToDefault: isBundled,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting workflow:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete workflow' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/workflows/[key] - Get a single workflow
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
try {
|
||||
const { key } = await params;
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(key);
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Workflow not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Include metadata about whether it's user-modified
|
||||
const hasUserOverride = userWorkflowExists(key);
|
||||
const isBundled = BUNDLED_WORKFLOW_KEYS.has(key);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...workflow,
|
||||
isBundled,
|
||||
hasUserOverride,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflow:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch workflow' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,89 +12,319 @@ interface WorkflowDefinition {
|
||||
requiresFocusedNode: boolean;
|
||||
primaryActor: 'oracle' | 'main';
|
||||
expectedOutcome?: string;
|
||||
isBundled?: boolean;
|
||||
hasUserOverride?: boolean;
|
||||
}
|
||||
|
||||
interface EditingWorkflow {
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
requiresFocusedNode: boolean;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export default function WorkflowsViewer() {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<EditingWorkflow | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadWorkflows = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/workflows');
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
// Fetch additional metadata for each workflow
|
||||
const enriched = await Promise.all(
|
||||
result.data.map(async (w: WorkflowDefinition) => {
|
||||
try {
|
||||
const detailRes = await fetch(`/api/workflows/${w.key}`);
|
||||
const detail = await detailRes.json();
|
||||
return detail.success ? detail.data : w;
|
||||
} catch {
|
||||
return w;
|
||||
}
|
||||
})
|
||||
);
|
||||
setWorkflows(enriched);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load workflows:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/workflows');
|
||||
const result = await res.json();
|
||||
if (result.success) setWorkflows(result.data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load workflows:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
loadWorkflows();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (workflow: WorkflowDefinition) => {
|
||||
setEditing({
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled,
|
||||
requiresFocusedNode: workflow.requiresFocusedNode,
|
||||
isNew: false,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleNewWorkflow = () => {
|
||||
setEditing({
|
||||
key: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
isNew: true,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const generateKey = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return;
|
||||
|
||||
const key = editing.isNew ? generateKey(editing.displayName) : editing.key;
|
||||
|
||||
if (!key) {
|
||||
setError('Please enter a workflow name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editing.instructions.trim()) {
|
||||
setError('Please enter instructions');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
displayName: editing.displayName,
|
||||
description: editing.description,
|
||||
instructions: editing.instructions,
|
||||
enabled: editing.enabled,
|
||||
requiresFocusedNode: editing.requiresFocusedNode,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
setEditing(null);
|
||||
await loadWorkflows();
|
||||
} else {
|
||||
setError(result.error || 'Failed to save workflow');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to save workflow');
|
||||
console.error('Save error:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (workflow: WorkflowDefinition) => {
|
||||
const action = workflow.isBundled ? 'reset to default' : 'delete';
|
||||
if (!confirm(`Are you sure you want to ${action} "${workflow.displayName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${workflow.key}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
await loadWorkflows();
|
||||
} else {
|
||||
alert(result.error || 'Failed to delete workflow');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to delete workflow');
|
||||
console.error('Delete error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={loadingStyle}>Loading...</div>;
|
||||
}
|
||||
|
||||
// Editing view
|
||||
if (editing) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>
|
||||
{editing.isNew ? 'New Workflow' : `Edit: ${editing.displayName}`}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleCancel} style={buttonStyle} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSave} style={primaryButtonStyle} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={errorStyle}>{error}</div>}
|
||||
|
||||
<div style={formStyle}>
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.displayName}
|
||||
onChange={(e) => setEditing({ ...editing, displayName: e.target.value })}
|
||||
placeholder="My Workflow"
|
||||
style={inputStyle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Description (shown to agent)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
placeholder="Brief description of what this workflow does"
|
||||
style={inputStyle}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span style={hintStyle}>Keep this short — it tells the agent when to use this workflow</span>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Instructions</label>
|
||||
<textarea
|
||||
value={editing.instructions}
|
||||
onChange={(e) => setEditing({ ...editing, instructions: e.target.value })}
|
||||
placeholder="Enter the workflow instructions..."
|
||||
style={textareaStyle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={checkboxRowStyle}>
|
||||
<label style={checkboxLabelStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.enabled}
|
||||
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
|
||||
disabled={saving}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label style={checkboxLabelStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.requiresFocusedNode}
|
||||
onChange={(e) => setEditing({ ...editing, requiresFocusedNode: e.target.checked })}
|
||||
disabled={saving}
|
||||
/>
|
||||
Requires focused node
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>Available workflows. Click to view instructions.</p>
|
||||
<div style={headerStyle}>
|
||||
<p style={descStyle}>Workflows available to the agent.</p>
|
||||
<button onClick={handleNewWorkflow} style={primaryButtonStyle}>
|
||||
+ New Workflow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workflows.length === 0 ? (
|
||||
<div style={emptyStyle}>No workflows defined.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{workflows.map((w) => {
|
||||
const expanded = expandedId === w.id;
|
||||
return (
|
||||
<div key={w.id} style={cardStyle}>
|
||||
<div
|
||||
onClick={() => setExpandedId(expanded ? null : w.id)}
|
||||
style={cardHeaderStyle}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{workflows.map((w) => (
|
||||
<div key={w.key} style={cardStyle}>
|
||||
<div style={cardContentStyle}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<span style={titleStyle}>{w.displayName}</span>
|
||||
<span style={keyStyle}>{w.key}</span>
|
||||
{w.hasUserOverride && w.isBundled && (
|
||||
<span style={modifiedBadgeStyle}>modified</span>
|
||||
)}
|
||||
{!w.enabled && (
|
||||
<span style={disabledBadgeStyle}>disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: w.enabled ? '#22c55e' : '#6b7280',
|
||||
}}>
|
||||
{w.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0)',
|
||||
transition: 'transform 0.15s',
|
||||
}}>
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
<div style={descRowStyle}>{w.description}</div>
|
||||
</div>
|
||||
<div style={actionsStyle}>
|
||||
<button onClick={() => handleEdit(w)} style={buttonStyle}>
|
||||
Edit
|
||||
</button>
|
||||
{/* Show delete for user-created, or reset for modified bundled */}
|
||||
{(!w.isBundled || w.hasUserOverride) && (
|
||||
<button
|
||||
onClick={() => handleDelete(w)}
|
||||
style={w.isBundled ? resetButtonStyle : deleteButtonStyle}
|
||||
>
|
||||
{w.isBundled ? 'Reset' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={descRowStyle}>{w.description}</div>
|
||||
{expanded && (
|
||||
<div style={expandedStyle}>
|
||||
<div style={instructionsStyle}>{w.instructions}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Styles
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const loadingStyle: CSSProperties = { padding: 24, color: '#6b7280' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', marginBottom: 20 };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', margin: 0 };
|
||||
const emptyStyle: CSSProperties = { fontSize: 13, color: '#6b7280', textAlign: 'center', padding: 32 };
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
};
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
@@ -102,34 +332,133 @@ const cardStyle: CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const cardHeaderStyle: CSSProperties = {
|
||||
const cardContentStyle: CSSProperties = {
|
||||
padding: '14px 16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const titleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' };
|
||||
const keyStyle: CSSProperties = { fontSize: 11, fontFamily: 'monospace', color: '#6b7280' };
|
||||
const descRowStyle: CSSProperties = { fontSize: 12, color: '#9ca3af', padding: '0 16px 14px', lineHeight: 1.5 };
|
||||
const descRowStyle: CSSProperties = { fontSize: 12, color: '#9ca3af', lineHeight: 1.5 };
|
||||
|
||||
const expandedStyle: CSSProperties = {
|
||||
padding: 16,
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.04)',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
const actionsStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const instructionsStyle: CSSProperties = {
|
||||
const buttonStyle: CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: '#d1d5db',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
padding: 12,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 6,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
color: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const primaryButtonStyle: CSSProperties = {
|
||||
...buttonStyle,
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||
color: '#22c55e',
|
||||
};
|
||||
|
||||
const deleteButtonStyle: CSSProperties = {
|
||||
...buttonStyle,
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
borderColor: 'rgba(239, 68, 68, 0.2)',
|
||||
color: '#ef4444',
|
||||
};
|
||||
|
||||
const resetButtonStyle: CSSProperties = {
|
||||
...buttonStyle,
|
||||
background: 'rgba(251, 191, 36, 0.1)',
|
||||
borderColor: 'rgba(251, 191, 36, 0.2)',
|
||||
color: '#fbbf24',
|
||||
};
|
||||
|
||||
const modifiedBadgeStyle: CSSProperties = {
|
||||
fontSize: 10,
|
||||
padding: '2px 6px',
|
||||
background: 'rgba(251, 191, 36, 0.15)',
|
||||
color: '#fbbf24',
|
||||
borderRadius: 4,
|
||||
};
|
||||
|
||||
const disabledBadgeStyle: CSSProperties = {
|
||||
fontSize: 10,
|
||||
padding: '2px 6px',
|
||||
background: 'rgba(107, 114, 128, 0.2)',
|
||||
color: '#6b7280',
|
||||
borderRadius: 4,
|
||||
};
|
||||
|
||||
const formStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const fieldStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#9ca3af',
|
||||
};
|
||||
|
||||
const hintStyle: CSSProperties = {
|
||||
fontSize: 11,
|
||||
color: '#6b7280',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 6,
|
||||
color: '#e5e7eb',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const textareaStyle: CSSProperties = {
|
||||
...inputStyle,
|
||||
minHeight: 300,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
};
|
||||
|
||||
const checkboxRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 24,
|
||||
};
|
||||
|
||||
const checkboxLabelStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const errorStyle: CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
marginBottom: 16,
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 6,
|
||||
color: '#ef4444',
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ Available tools:
|
||||
- queryNodes — search nodes by title/content/dimensions across ENTIRE database
|
||||
- getNodesById — retrieve full node data
|
||||
- queryEdge — inspect existing edges
|
||||
- searchContentEmbeddings — semantic search across ALL nodes (not just pinned)
|
||||
- searchContentEmbeddings — semantic search across ALL nodes
|
||||
- webSearch — external research when necessary
|
||||
- think — internal planning/reflection (use once per workflow unless plan changes)
|
||||
- updateNode — append content to nodes (tool handles appending automatically)
|
||||
@@ -24,16 +24,16 @@ When you receive a workflow task:
|
||||
2. Call think once to produce a numbered plan matching the workflow steps.
|
||||
3. Execute the plan step-by-step:
|
||||
- Extract key entities (names, projects, concepts) from the node
|
||||
- Search the FULL database using those entities (ignore pinned context during search)
|
||||
- Search the FULL database using those entities
|
||||
- Find both obvious (structural) and thematic connections
|
||||
- Then contextualize findings with pinned context
|
||||
- Contextualize findings using background context (top nodes by edge count)
|
||||
4. Stay within the tool budget and avoid redundant queries.
|
||||
5. When calling updateNode, provide ONLY the new content (never include existing content) - tool appends automatically.
|
||||
6. Finish with a concise Task / Actions / Result / Nodes / Follow-up summary.
|
||||
</execution>
|
||||
|
||||
<constraints>
|
||||
- Search the ENTIRE database, not just pinned nodes
|
||||
- Search the ENTIRE database, not just top nodes
|
||||
- Extract entities first, then search using those entities
|
||||
- Use minimal tool calls needed for high-quality output
|
||||
- Keep responses structured, factual, and ≤120 words
|
||||
|
||||
@@ -18,7 +18,7 @@ WORKFLOW STEPS
|
||||
- Summarize the core insight in one sentence
|
||||
|
||||
2. SEARCH THE DATABASE FOR CONNECTIONS
|
||||
DO NOT reference pinned context yet. Search the ENTIRE database:
|
||||
Search the ENTIRE database:
|
||||
|
||||
a) Obvious structural connections:
|
||||
- If names mentioned → queryNodes to find existing nodes about those people
|
||||
@@ -33,10 +33,10 @@ WORKFLOW STEPS
|
||||
- Aim for 3–8 strong connections, not 20 weak ones
|
||||
- Check existing edges with queryEdge to avoid duplicating connections
|
||||
|
||||
3. CONTEXTUALIZE WITH PINNED NODES
|
||||
NOW review the supplied PINNED CONTEXT:
|
||||
3. CONTEXTUALIZE WITH TOP NODES
|
||||
Review the BACKGROUND CONTEXT (top nodes by edge count):
|
||||
- Why might this node matter given the user's focus areas?
|
||||
- Does it advance any themes visible in pinned nodes?
|
||||
- Does it advance any themes visible in their most connected nodes?
|
||||
- Keep this brief – 1–2 sentences maximum
|
||||
|
||||
4. APPEND INTEGRATION ANALYSIS
|
||||
@@ -52,7 +52,7 @@ WORKFLOW STEPS
|
||||
- [NODE:456:"Title"] — [why: ...]
|
||||
- [continue for 3–8 connections found in step 2]
|
||||
|
||||
**Relevance:** [1–2 sentences connecting to user's pinned context themes]
|
||||
**Relevance:** [1–2 sentences connecting to user's top nodes/focus areas]
|
||||
|
||||
CRITICAL: Send ONLY this new section. The tool will automatically append it to existing content.
|
||||
|
||||
@@ -62,7 +62,7 @@ WORKFLOW STEPS
|
||||
Reply with: Task / Actions / Result / Nodes / Follow-up (≤120 words)
|
||||
|
||||
CRITICAL RULES
|
||||
- Search the FULL database, not just pinned nodes
|
||||
- Search the FULL database, not just top nodes
|
||||
- Use entities from step 1 to guide searches in step 2
|
||||
- Call updateNode EXACTLY ONCE - after success, move to step 5 immediately
|
||||
- Keep total tool calls ≤ 18 (be efficient)
|
||||
|
||||
@@ -163,21 +163,24 @@ export class WiseRAHExecutor {
|
||||
);
|
||||
|
||||
// Enforce read-only constraint - remove write tools EXCEPT for workflows
|
||||
// Workflows (like integrate) need updateNode for direct content updates
|
||||
const writeToolsToRemove = isWorkflow
|
||||
? ['createNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
|
||||
// Workflows need updateNode for content updates and createEdge for Quick Link workflow
|
||||
const writeToolsToRemove = isWorkflow
|
||||
? ['createNode', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
|
||||
: ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'];
|
||||
|
||||
|
||||
writeToolsToRemove.forEach(toolName => {
|
||||
if (toolName in wrappedTools) {
|
||||
console.warn(`WiseRAHExecutor: ${toolName} detected in planner toolset. Removing to enforce read-only constraint.`);
|
||||
delete wrappedTools[toolName];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (isWorkflow && 'updateNode' in wrappedTools) {
|
||||
console.log('✅ [WiseRAHExecutor] updateNode preserved for workflow execution');
|
||||
}
|
||||
if (isWorkflow && 'createEdge' in wrappedTools) {
|
||||
console.log('✅ [WiseRAHExecutor] createEdge preserved for workflow execution');
|
||||
}
|
||||
console.log('🔒 [WiseRAHExecutor] Final tools after read-only enforcement:', Object.keys(wrappedTools));
|
||||
|
||||
console.log('📝 [WiseRAHExecutor] Starting manual agentic loop...');
|
||||
|
||||
@@ -1,30 +1,79 @@
|
||||
import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate';
|
||||
import type { WorkflowDefinition } from './types';
|
||||
import { listUserWorkflows, loadUserWorkflow } from './workflowFileService';
|
||||
|
||||
// Bundled default workflows (always available as fallback)
|
||||
const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
|
||||
'integrate': {
|
||||
id: 1,
|
||||
key: 'integrate',
|
||||
displayName: 'Integrate',
|
||||
description: 'Deep analysis and connection-building for focused node',
|
||||
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created',
|
||||
}
|
||||
};
|
||||
|
||||
// Set of bundled workflow keys (for UI to know which can be "reset to default")
|
||||
export const BUNDLED_WORKFLOW_KEYS = new Set(Object.keys(BUNDLED_WORKFLOWS));
|
||||
|
||||
function userWorkflowToDefinition(uw: ReturnType<typeof loadUserWorkflow>, id: number): WorkflowDefinition {
|
||||
if (!uw) throw new Error('Cannot convert null workflow');
|
||||
return {
|
||||
id,
|
||||
key: uw.key,
|
||||
displayName: uw.displayName,
|
||||
description: uw.description,
|
||||
instructions: uw.instructions,
|
||||
enabled: uw.enabled,
|
||||
requiresFocusedNode: uw.requiresFocusedNode,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkflowRegistry {
|
||||
private static readonly WORKFLOWS: Record<string, WorkflowDefinition> = {
|
||||
'integrate': {
|
||||
id: 1,
|
||||
key: 'integrate',
|
||||
displayName: 'Integrate',
|
||||
description: 'Deep analysis and connection-building for focused node',
|
||||
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created',
|
||||
}
|
||||
};
|
||||
|
||||
static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | null> {
|
||||
return this.WORKFLOWS[key] || null;
|
||||
// Try user file first
|
||||
const userWorkflow = loadUserWorkflow(key);
|
||||
if (userWorkflow) {
|
||||
return userWorkflowToDefinition(userWorkflow, BUNDLED_WORKFLOWS[key]?.id || 100);
|
||||
}
|
||||
|
||||
// Fall back to bundled
|
||||
return BUNDLED_WORKFLOWS[key] || null;
|
||||
}
|
||||
|
||||
static async getEnabledWorkflows(): Promise<WorkflowDefinition[]> {
|
||||
return Object.values(this.WORKFLOWS).filter(w => w.enabled);
|
||||
const all = await this.getAllWorkflows();
|
||||
return all.filter(w => w.enabled);
|
||||
}
|
||||
|
||||
static async getAllWorkflows(): Promise<WorkflowDefinition[]> {
|
||||
return Object.values(this.WORKFLOWS);
|
||||
// Start with bundled defaults
|
||||
const result: Record<string, WorkflowDefinition> = {};
|
||||
|
||||
for (const [key, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
|
||||
result[key] = { ...workflow };
|
||||
}
|
||||
|
||||
// Load user workflows (overwrite bundled if same key, add new ones)
|
||||
const userWorkflows = listUserWorkflows();
|
||||
let nextId = 100;
|
||||
|
||||
for (const uw of userWorkflows) {
|
||||
const existingId = result[uw.key]?.id || nextId++;
|
||||
result[uw.key] = userWorkflowToDefinition(uw, existingId);
|
||||
}
|
||||
|
||||
return Object.values(result);
|
||||
}
|
||||
|
||||
// Check if a workflow key is a bundled default
|
||||
static isBundledWorkflow(key: string): boolean {
|
||||
return BUNDLED_WORKFLOW_KEYS.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export interface UserWorkflow {
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
requiresFocusedNode: boolean;
|
||||
}
|
||||
|
||||
function resolveBaseConfigDir(): string {
|
||||
const override = process.env.RAH_CONFIG_DIR;
|
||||
if (override && override.trim().length > 0) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'RA-H');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
return path.join(roaming, 'RA-H');
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
||||
return path.join(xdgConfig, 'ra-h');
|
||||
}
|
||||
|
||||
export function getWorkflowsDir(): string {
|
||||
return path.join(resolveBaseConfigDir(), 'workflows');
|
||||
}
|
||||
|
||||
function ensureWorkflowsDirExists(): void {
|
||||
const dir = getWorkflowsDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function listUserWorkflows(): UserWorkflow[] {
|
||||
const dir = getWorkflowsDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workflows: UserWorkflow[] = [];
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(dir, file);
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
// Validate required fields
|
||||
if (parsed.key && parsed.displayName && parsed.instructions) {
|
||||
workflows.push({
|
||||
key: parsed.key,
|
||||
displayName: parsed.displayName,
|
||||
description: parsed.description || '',
|
||||
instructions: parsed.instructions,
|
||||
enabled: parsed.enabled !== false,
|
||||
requiresFocusedNode: parsed.requiresFocusedNode !== false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load workflow file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return workflows;
|
||||
}
|
||||
|
||||
export function loadUserWorkflow(key: string): UserWorkflow | null {
|
||||
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!parsed.key || !parsed.displayName || !parsed.instructions) {
|
||||
console.warn(`Invalid workflow file for key ${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: parsed.key,
|
||||
displayName: parsed.displayName,
|
||||
description: parsed.description || '',
|
||||
instructions: parsed.instructions,
|
||||
enabled: parsed.enabled !== false,
|
||||
requiresFocusedNode: parsed.requiresFocusedNode !== false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load workflow ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWorkflow(workflow: UserWorkflow): void {
|
||||
ensureWorkflowsDirExists();
|
||||
|
||||
const filePath = path.join(getWorkflowsDir(), `${workflow.key}.json`);
|
||||
const data = {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled,
|
||||
requiresFocusedNode: workflow.requiresFocusedNode,
|
||||
};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export function deleteWorkflow(key: string): boolean {
|
||||
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete workflow ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function userWorkflowExists(key: string): boolean {
|
||||
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { executeWorkflowTool } from '../orchestration/executeWorkflow';
|
||||
import { youtubeExtractTool } from '../other/youtubeExtract';
|
||||
import { websiteExtractTool } from '../other/websiteExtract';
|
||||
import { paperExtractTool } from '../other/paperExtract';
|
||||
import { logEvalToolCall } from '@/services/evals/evalsLogger';
|
||||
|
||||
// Core tools available to all agents (read-only graph operations)
|
||||
const CORE_TOOLS: Record<string, any> = {
|
||||
@@ -109,6 +110,7 @@ const PLANNER_TOOL_NAMES = [
|
||||
'think',
|
||||
'delegateToMiniRAH',
|
||||
'updateNode', // For workflow execution (integrate workflow needs direct write access)
|
||||
'createEdge', // For Quick Link workflow
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -155,7 +157,7 @@ export function getHelperTools(availableToolNames: string[]): Record<string, any
|
||||
|
||||
return availableToolNames.reduce((tools, name) => {
|
||||
if (TOOLS[name]) {
|
||||
tools[name] = TOOLS[name];
|
||||
tools[name] = wrapToolForEvalLogging(name, TOOLS[name]);
|
||||
} else {
|
||||
console.warn(`Tool '${name}' not found in registry`);
|
||||
}
|
||||
@@ -192,9 +194,23 @@ export async function executeTool(toolId: string, params: any, context: any) {
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await tool.execute(params, context);
|
||||
const result = await tool.execute(params, context);
|
||||
logEvalToolCall({
|
||||
toolName: toolId,
|
||||
args: params,
|
||||
result,
|
||||
latencyMs: Date.now() - startedAt
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
logEvalToolCall({
|
||||
toolName: toolId,
|
||||
args: params,
|
||||
error,
|
||||
latencyMs: Date.now() - startedAt
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : `Tool '${toolId}' execution failed`,
|
||||
@@ -203,5 +219,36 @@ export async function executeTool(toolId: string, params: any, context: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapToolForEvalLogging(toolName: string, tool: any) {
|
||||
if (!tool || typeof tool.execute !== 'function') {
|
||||
return tool;
|
||||
}
|
||||
|
||||
return {
|
||||
...tool,
|
||||
execute: async (params: any, context: any) => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await tool.execute(params, context);
|
||||
logEvalToolCall({
|
||||
toolName,
|
||||
args: params,
|
||||
result,
|
||||
latencyMs: Date.now() - startedAt
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
logEvalToolCall({
|
||||
toolName,
|
||||
args: params,
|
||||
error,
|
||||
latencyMs: Date.now() - startedAt
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Export group utilities
|
||||
export { getToolGroup, groupTools, getAllToolsByGroup };
|
||||
|
||||
Reference in New Issue
Block a user