feat(rah-light): replace workflows with guides system

- Remove entire workflow system (API routes, services, tools, components)
- Add guides system from main ra-h repo (for external agent integration)
- Update pane types: 'workflows' → 'guides' throughout
- Update LeftToolbar to show guides icon instead of workflows
- Update SettingsModal with GuidesViewer tab
- Add GuidesPane for browsing guides in main UI
- Clean up prompts to remove workflow references
- Update tool registry to remove workflow tools
- Add gray-matter package for frontmatter parsing

Guides are essential for RA-H Light as they help external agents
(via MCP) understand the knowledge base context and usage patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-29 16:29:24 +11:00
co-authored by Claude Opus 4.5
parent f383d770ca
commit b31441b35f
46 changed files with 1126 additions and 2189 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ export default function ContextViewer() {
return (
<div style={containerStyle}>
<p style={descStyle}>
Top 10 most-connected nodes are added to background context for workflow execution.
Top 10 most-connected nodes are added to background context for tool execution.
</p>
{/* Toggle */}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Save, X, FileText } from 'lucide-react';
interface GuideMeta {
name: string;
description: string;
}
interface Guide extends GuideMeta {
content: string;
}
export default function GuidesViewer() {
const [guides, setGuides] = useState<GuideMeta[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Guide | null>(null);
const [isNew, setIsNew] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchGuides();
}, []);
const fetchGuides = async () => {
try {
const res = await fetch('/api/guides');
const data = await res.json();
if (data.success) {
setGuides(data.data);
}
} catch (err) {
console.error('Failed to fetch guides:', err);
} finally {
setLoading(false);
}
};
const handleEdit = async (name: string) => {
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setEditing(data.data);
setIsNew(false);
setError(null);
}
} catch (err) {
console.error('Failed to fetch guide:', err);
}
};
const handleNew = () => {
setEditing({
name: '',
description: '',
content: '# New Guide\n\nWrite your guide content here...',
});
setIsNew(true);
setError(null);
};
const handleSave = async () => {
if (!editing) return;
if (!editing.name.trim()) {
setError('Name is required');
return;
}
try {
const res = await fetch(`/api/guides/${encodeURIComponent(editing.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editing.content,
description: editing.description,
}),
});
const data = await res.json();
if (data.success) {
setEditing(null);
fetchGuides();
window.dispatchEvent(new Event('guides:updated'));
} else {
setError(data.error || 'Failed to save');
}
} catch (err) {
setError('Failed to save guide');
}
};
const handleDelete = async (name: string) => {
if (!confirm(`Delete guide "${name}"?`)) return;
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (data.success) {
fetchGuides();
window.dispatchEvent(new Event('guides:updated'));
}
} catch (err) {
console.error('Failed to delete guide:', err);
}
};
if (loading) {
return (
<div style={{ padding: '24px', color: '#666' }}>Loading guides...</div>
);
}
if (editing) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Guide name"
disabled={!isNew}
style={{
flex: 1,
padding: '8px 12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontSize: '14px',
}}
/>
<button
onClick={handleSave}
style={{
padding: '8px 16px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
}}
>
<Save size={14} /> Save
</button>
<button
onClick={() => setEditing(null)}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #333',
borderRadius: '6px',
color: '#888',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<X size={14} /> Cancel
</button>
</div>
{error && (
<div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '12px' }}>{error}</div>
)}
<input
type="text"
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
placeholder="Brief description"
style={{
padding: '8px 12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontSize: '13px',
marginBottom: '16px',
}}
/>
<textarea
value={editing.content}
onChange={(e) => setEditing({ ...editing, content: e.target.value })}
placeholder="Guide content (markdown)"
style={{
flex: 1,
padding: '12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#ccc',
fontSize: '13px',
fontFamily: 'monospace',
resize: 'none',
lineHeight: 1.5,
}}
/>
<p style={{ color: '#666', fontSize: '12px', marginTop: '12px' }}>
Guides are markdown files that external agents can read via MCP tools. Use them to provide context, instructions, or reference material.
</p>
</div>
);
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ color: '#888', fontSize: '13px', margin: 0 }}>
Guides provide context and instructions for external AI agents via MCP.
</p>
<button
onClick={handleNew}
style={{
padding: '8px 16px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
}}
>
<Plus size={14} /> New Guide
</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{guides.length === 0 ? (
<div style={{ color: '#555', textAlign: 'center', paddingTop: '48px' }}>
<FileText size={48} style={{ marginBottom: '12px', opacity: 0.5 }} />
<p style={{ fontSize: '14px' }}>No guides yet</p>
<p style={{ fontSize: '12px', color: '#444' }}>Create guides to help external agents understand your knowledge base</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{guides.map((guide) => (
<div
key={guide.name}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: '#161616',
border: '1px solid #222',
borderRadius: '8px',
}}
>
<FileText size={18} style={{ color: '#22c55e', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#ddd', fontSize: '14px', fontWeight: 500 }}>{guide.name}</div>
<div style={{ color: '#666', fontSize: '12px', marginTop: '2px' }}>{guide.description}</div>
</div>
<button
onClick={() => handleEdit(guide.name)}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: '#666',
cursor: 'pointer',
borderRadius: '4px',
}}
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(guide.name)}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: '#666',
cursor: 'pointer',
borderRadius: '4px',
}}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+9 -9
View File
@@ -4,17 +4,17 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import LogsViewer from './LogsViewer';
import ToolsViewer from './ToolsViewer';
import WorkflowsViewer from './WorkflowsViewer';
import ApiKeysViewer from './ApiKeysViewer';
import DatabaseViewer from './DatabaseViewer';
import ExternalAgentsPanel from './ExternalAgentsPanel';
import ContextViewer from './ContextViewer';
import GuidesViewer from './GuidesViewer';
import { apiKeyService } from '@/services/storage/apiKeys';
export type SettingsTab =
| 'logs'
| 'tools'
| 'workflows'
| 'guides'
| 'apikeys'
| 'database'
| 'context'
@@ -140,18 +140,18 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
Tools
</div>
<div
onClick={() => setActiveTab('workflows')}
onClick={() => setActiveTab('guides')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'workflows' ? '#fff' : '#888',
background: activeTab === 'workflows' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'workflows' ? '3px solid #22c55e' : '3px solid transparent',
color: activeTab === 'guides' ? '#fff' : '#888',
background: activeTab === 'guides' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'guides' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Workflows
Guides
</div>
<div
onClick={() => setActiveTab('apikeys')}
@@ -295,7 +295,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
>
{activeTab === 'logs' && 'System Logs'}
{activeTab === 'tools' && 'Tools'}
{activeTab === 'workflows' && 'Workflows'}
{activeTab === 'guides' && 'Guides'}
{activeTab === 'apikeys' && 'API Keys'}
{activeTab === 'database' && 'Knowledge Database'}
{activeTab === 'context' && 'Auto-Context'}
@@ -329,7 +329,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
<div style={{ flex: 1, overflow: 'hidden' }}>
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
{activeTab === 'tools' && <ToolsViewer />}
{activeTab === 'workflows' && <WorkflowsViewer />}
{activeTab === 'guides' && <GuidesViewer />}
{activeTab === 'apikeys' && <ApiKeysViewer />}
{activeTab === 'database' && <DatabaseViewer />}
{activeTab === 'context' && <ContextViewer />}
-464
View File
@@ -1,464 +0,0 @@
"use client";
import { useState, useEffect, type CSSProperties } from 'react';
interface WorkflowDefinition {
id: number;
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
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 [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(() => {
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}>
<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) => (
<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={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>
</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', 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)',
borderRadius: 8,
overflow: 'hidden',
};
const cardContentStyle: CSSProperties = {
padding: '14px 16px',
display: 'flex',
justifyContent: 'space-between',
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', lineHeight: 1.5 };
const actionsStyle: CSSProperties = {
display: 'flex',
gap: 8,
flexShrink: 0,
};
const buttonStyle: CSSProperties = {
padding: '6px 12px',
fontSize: 12,
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 6,
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,
};