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:
co-authored by
Claude Opus 4.5
parent
f383d770ca
commit
b31441b35f
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readGuide } from '@/services/guides/guideService';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const guide = readGuide(name);
|
||||
if (!guide) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Guide "${name}" not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, data: guide });
|
||||
} catch (error) {
|
||||
console.error('[API /guides/[name]] error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to read guide' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { listGuides } from '@/services/guides/guideService';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const guides = listGuides();
|
||||
return NextResponse.json({ success: true, data: guides });
|
||||
} catch (error) {
|
||||
console.error('[API /guides] error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to list guides' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
|
||||
import { getAutoContextSummaries } from '@/services/context/autoContext';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { workflowKey, nodeId, userContext } = body;
|
||||
|
||||
// Validate workflowKey
|
||||
if (!workflowKey || typeof workflowKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'workflowKey is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch workflow definition
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow '${workflowKey}' not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflow.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow '${workflowKey}' is disabled` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate node requirement
|
||||
if (workflow.requiresFocusedNode && !nodeId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow '${workflowKey}' requires a nodeId` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent re-running same workflow on same node within 1 hour
|
||||
if (nodeId) {
|
||||
const db = getSQLiteClient();
|
||||
const recentRuns = db.query<{ id: number }>(
|
||||
`SELECT id FROM chats
|
||||
WHERE json_extract(metadata, '$.workflow_key') = ?
|
||||
AND json_extract(metadata, '$.workflow_node_id') = ?
|
||||
AND datetime(created_at) > datetime('now', '-1 hour')
|
||||
LIMIT 1`,
|
||||
[workflowKey, nodeId]
|
||||
).rows;
|
||||
|
||||
if (recentRuns.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' already ran on node ${nodeId} within the last hour`
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build context
|
||||
let contextLines: string[] = [];
|
||||
if (nodeId) {
|
||||
const db = getSQLiteClient();
|
||||
const stmt = db.prepare('SELECT * FROM nodes WHERE id = ?');
|
||||
const node = stmt.get(nodeId) as any;
|
||||
if (!node) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Node ${nodeId} not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
contextLines = [
|
||||
`Focused Node: [NODE:${node.id}:"${node.title}"]`,
|
||||
node.description ? `Description: ${node.description}` : null,
|
||||
node.content ? `Content: ${node.content}` : null,
|
||||
node.link ? `Link: ${node.link}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
if (userContext) {
|
||||
contextLines.push(`User Context: ${userContext}`);
|
||||
}
|
||||
|
||||
// Add auto-context summaries
|
||||
const autoContextSummaries = getAutoContextSummaries(6);
|
||||
if (autoContextSummaries.length > 0) {
|
||||
contextLines.push('Background Context (Top Hubs):');
|
||||
autoContextSummaries.forEach((summary) => {
|
||||
contextLines.push(
|
||||
`[NODE:${summary.id}:"${summary.title}"] (${summary.edgeCount} edges)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const task = `Execute workflow: ${workflow.displayName}
|
||||
|
||||
${workflow.instructions}
|
||||
|
||||
${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general workflow)'}`;
|
||||
|
||||
const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
try {
|
||||
// Execute workflow synchronously
|
||||
const result = await WorkflowExecutor.execute({
|
||||
sessionId,
|
||||
task,
|
||||
context: contextLines,
|
||||
expectedOutcome: workflow.expectedOutcome,
|
||||
workflowKey,
|
||||
workflowNodeId: nodeId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
workflowKey,
|
||||
nodeId: nodeId || null,
|
||||
status: 'completed',
|
||||
summary: result.summary,
|
||||
message: `Workflow '${workflow.displayName}' completed`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[/api/workflows/execute] Execution failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow execution failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing workflow:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to execute workflow' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const workflows = await WorkflowRegistry.getAllWorkflows();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: workflows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflows:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch workflows' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Generated
+109
@@ -16,6 +16,7 @@
|
||||
"ai": "^6.0.27",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.3",
|
||||
"openai": "^4.103.0",
|
||||
@@ -5056,6 +5057,19 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
@@ -5171,6 +5185,18 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -5607,6 +5633,43 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -6100,6 +6163,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -6526,6 +6598,15 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.5.tgz",
|
||||
@@ -9131,6 +9212,19 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -9418,6 +9512,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -9620,6 +9720,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ai": "^6.0.27",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.3",
|
||||
"openai": "^4.103.0",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
LayoutList,
|
||||
Map,
|
||||
Folder,
|
||||
Workflow,
|
||||
FileText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { PaneType } from '../panes/types';
|
||||
@@ -27,18 +27,18 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
map: Map,
|
||||
dimensions: Folder,
|
||||
workflows: Workflow,
|
||||
guides: FileText,
|
||||
};
|
||||
|
||||
const PANE_TYPE_LABELS: Record<string, string> = {
|
||||
views: 'Feed',
|
||||
map: 'Map',
|
||||
dimensions: 'Dimensions',
|
||||
workflows: 'Workflows',
|
||||
guides: 'Guides',
|
||||
};
|
||||
|
||||
// Pane types shown in the toolbar (excludes 'node' which is opened via Feed, chat removed in rah-light)
|
||||
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'workflows'];
|
||||
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'guides'];
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: typeof Search;
|
||||
|
||||
@@ -26,7 +26,7 @@ import LeftToolbar from './LeftToolbar';
|
||||
import SplitHandle from './SplitHandle';
|
||||
|
||||
// Pane components (ChatPane removed in rah-light)
|
||||
import { NodePane, WorkflowsPane, DimensionsPane, MapPane, ViewsPane } from '../panes';
|
||||
import { NodePane, GuidesPane, DimensionsPane, MapPane, ViewsPane } from '../panes';
|
||||
import QuickAddInput from '../agents/QuickAddInput';
|
||||
import type { PaneType, SlotState, PaneAction } from '../panes/types';
|
||||
|
||||
@@ -258,9 +258,9 @@ export default function ThreePanelLayout() {
|
||||
// Delegation events ignored (delegation system removed in rah-light)
|
||||
break;
|
||||
|
||||
case 'WORKFLOW_PROGRESS':
|
||||
case 'GUIDE_UPDATED':
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('workflow:progress', { detail: data.data }));
|
||||
window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data }));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -807,22 +807,14 @@ export default function ThreePanelLayout() {
|
||||
|
||||
// case 'chat' removed in rah-light
|
||||
|
||||
case 'workflows':
|
||||
case 'guides':
|
||||
return (
|
||||
<WorkflowsPane
|
||||
<GuidesPane
|
||||
slot={slot}
|
||||
isActive={isActive}
|
||||
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={slotB ? handleSwapPanes : undefined}
|
||||
delegations={delegations}
|
||||
onNodeClick={(nodeId) => {
|
||||
handleNodeSelect(nodeId, false);
|
||||
setActivePane(slot);
|
||||
}}
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTab}
|
||||
activeDimension={activeDimension}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { BasePaneProps } from './types';
|
||||
|
||||
interface GuideMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Guide extends GuideMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function GuidesPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
}: BasePaneProps) {
|
||||
const [guides, setGuides] = useState<GuideMeta[]>([]);
|
||||
const [selectedGuide, setSelectedGuide] = useState<Guide | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGuides();
|
||||
|
||||
const handleGuideUpdated = () => { fetchGuides(); };
|
||||
window.addEventListener('guides:updated', handleGuideUpdated);
|
||||
return () => window.removeEventListener('guides:updated', handleGuideUpdated);
|
||||
}, []);
|
||||
|
||||
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('[GuidesPane] Failed to fetch guides:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGuide = async (name: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSelectedGuide(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GuidesPane] Failed to fetch guide:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedGuide(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
||||
{selectedGuide && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#ccc'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#888'; }}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<span style={{ color: '#ccc', fontSize: '13px', fontWeight: 500 }}>
|
||||
{selectedGuide.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</PaneHeader>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
|
||||
{loading ? (
|
||||
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : selectedGuide ? (
|
||||
<div className="guide-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600, color: '#eee', margin: '0 0 16px 0' }}>{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 style={{ fontSize: '15px', fontWeight: 600, color: '#ddd', margin: '20px 0 8px 0' }}>{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 600, color: '#ccc', margin: '16px 0 6px 0' }}>{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li style={{ margin: '0 0 4px 0' }}>{children}</li>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code style={{
|
||||
background: '#1a1a1a',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#22c55e',
|
||||
}} {...props}>{children}</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code style={{
|
||||
display: 'block',
|
||||
background: '#0d0d0d',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
overflowX: 'auto',
|
||||
margin: '0 0 12px 0',
|
||||
color: '#aaa',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}} {...props}>{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong style={{ color: '#eee', fontWeight: 600 }}>{children}</strong>
|
||||
),
|
||||
hr: () => (
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2a2a', margin: '16px 0' }} />
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderLeft: '3px solid #333',
|
||||
paddingLeft: '12px',
|
||||
margin: '0 0 12px 0',
|
||||
color: '#999',
|
||||
}}>{children}</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selectedGuide.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{guides.length === 0 ? (
|
||||
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
||||
No guides found
|
||||
</div>
|
||||
) : (
|
||||
guides.map((guide) => (
|
||||
<button
|
||||
key={guide.name}
|
||||
onClick={() => handleSelectGuide(guide.name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
padding: '12px',
|
||||
background: '#161616',
|
||||
border: '1px solid #222',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = '#161616';
|
||||
e.currentTarget.style.borderColor = '#222';
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 500 }}>
|
||||
{guide.name}
|
||||
</span>
|
||||
<span style={{ color: '#777', fontSize: '12px', lineHeight: '1.4' }}>
|
||||
{guide.description}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { WorkflowsPaneProps, AgentDelegation } from './types';
|
||||
|
||||
export default function WorkflowsPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
delegations,
|
||||
onNodeClick,
|
||||
openTabsData = [],
|
||||
activeTabId = null,
|
||||
activeDimension,
|
||||
}: WorkflowsPaneProps) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<WorkflowsListView />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Workflows list view - simplified for rah-light (delegation system removed)
|
||||
function WorkflowsListView() {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
Workflows
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
maxWidth: '300px'
|
||||
}}>
|
||||
<p style={{ marginBottom: '16px' }}>
|
||||
Workflows are available via MCP server integration.
|
||||
</p>
|
||||
<p style={{ color: '#555', fontSize: '11px' }}>
|
||||
Connect your coding agent (Claude Code, Cursor, etc.) via MCP to run workflows like Integrate, Extract, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as WorkflowsPane } from './WorkflowsPane';
|
||||
export { default as GuidesPane } from './GuidesPane';
|
||||
export { default as DimensionsPane } from './DimensionsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
|
||||
@@ -15,7 +15,7 @@ export type AgentDelegation = {
|
||||
};
|
||||
|
||||
// The five pane types (chat removed in rah-light)
|
||||
export type PaneType = 'node' | 'workflows' | 'dimensions' | 'map' | 'views';
|
||||
export type PaneType = 'node' | 'guides' | 'dimensions' | 'map' | 'views';
|
||||
|
||||
// State for each slot
|
||||
export interface SlotState {
|
||||
@@ -68,14 +68,7 @@ export interface HighlightedPassage {
|
||||
|
||||
// ChatPaneProps removed in rah-light
|
||||
|
||||
// WorkflowsPane specific props
|
||||
export interface WorkflowsPaneProps extends BasePaneProps {
|
||||
delegations: AgentDelegation[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
openTabsData?: Node[];
|
||||
activeTabId?: number | null;
|
||||
activeDimension?: string | null;
|
||||
}
|
||||
// GuidesPaneProps - just uses BasePaneProps (guides are self-contained)
|
||||
|
||||
// DimensionsPane specific props
|
||||
export interface DimensionsPaneProps extends BasePaneProps {
|
||||
@@ -110,7 +103,7 @@ export interface PaneHeaderProps {
|
||||
// Labels for pane types
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
workflows: 'Workflows',
|
||||
guides: 'Guides',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
@@ -124,5 +117,5 @@ export const DEFAULT_SLOT_A: SlotState = {
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOT_B: SlotState = {
|
||||
type: 'workflows',
|
||||
type: 'guides',
|
||||
};
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Connect
|
||||
description: Quick link — find explicitly related nodes and create edges between them.
|
||||
---
|
||||
|
||||
# Connect
|
||||
|
||||
Quick link: find explicitly related nodes and create edges.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read Node**
|
||||
Call `getNodesById` for the focused node. Extract the main topic/subject from the title.
|
||||
|
||||
2. **Quick Search**
|
||||
Call `queryNodes` with the main topic from the node title.
|
||||
- `search`: the key term from the title (e.g., if title mentions "Nietzsche", search "Nietzsche")
|
||||
- `limit`: 10
|
||||
- Do NOT add dimensions filter — search across all nodes
|
||||
|
||||
3. **Create Edges**
|
||||
From results, pick 2-4 clearly related nodes.
|
||||
Call `createEdge` for each:
|
||||
- `from_node_id`: focused node ID
|
||||
- `to_node_id`: related node ID
|
||||
- `explanation`: "brief reason for this connection"
|
||||
|
||||
Direction rule: write the explanation so it reads FROM → TO.
|
||||
Examples:
|
||||
- Episode → Podcast: "Episode of this podcast"
|
||||
- Book → Author: "Written by"
|
||||
|
||||
4. **Done**
|
||||
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
|
||||
|
||||
## Rules
|
||||
|
||||
- Total tool calls ≤ 5
|
||||
- Search the MAIN TOPIC from the title, not random names from content
|
||||
- NO dimensions filter in `queryNodes` — search everything
|
||||
- Only link nodes with clear relationships
|
||||
- Skip if no matches found
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: Integrate
|
||||
description: Full analysis — find connections across the knowledge graph, create edges, and document an integration analysis.
|
||||
---
|
||||
|
||||
# Integrate
|
||||
|
||||
Find meaningful connections across the knowledge graph, create edges, and append an Integration Analysis.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Retrieve & Understand**
|
||||
- Call `getNodesById` for the focused node
|
||||
- Identify: what type of thing is this? (person, project, paper, idea, video, etc.)
|
||||
- Extract key entities: names, projects, concepts, techniques
|
||||
- Note the core insight in one sentence
|
||||
|
||||
2. **Search for Connections**
|
||||
Search the database using entities from step 1:
|
||||
|
||||
a) Structural connections:
|
||||
- Names mentioned → `queryNodes` to find nodes about those people
|
||||
- Projects/tools mentioned → `queryNodes` to find those nodes
|
||||
|
||||
b) Thematic connections:
|
||||
- Use `searchContentEmbeddings` with key concepts
|
||||
- Look for shared themes, complementary ideas, contradictions
|
||||
|
||||
Target: 3-5 strong connections (quality over quantity)
|
||||
|
||||
3. **Create Edges**
|
||||
For each connection found, call `createEdge`:
|
||||
- `from_node_id`: the focused node ID
|
||||
- `to_node_id`: the connected node ID
|
||||
- `explanation`: "why this connection matters"
|
||||
|
||||
Direction rule: write the explanation so it reads FROM → TO.
|
||||
Examples:
|
||||
- Insight → Source: "Came from / inspired by"
|
||||
- Episode → Podcast: "Episode of this podcast"
|
||||
|
||||
The tool handles duplicates gracefully — if edge exists, it returns an error and you continue.
|
||||
|
||||
Create 3-5 edges total.
|
||||
|
||||
4. **Document in Content**
|
||||
Call `updateNode` ONCE using the `content` field with ONLY this new section:
|
||||
|
||||
```
|
||||
---
|
||||
## Integration Analysis
|
||||
|
||||
[2-3 sentences: what this is, why it matters, core insight]
|
||||
|
||||
**Connections:**
|
||||
- [NODE:123:"Title"] — [why connected]
|
||||
- [NODE:456:"Title"] — [why connected]
|
||||
```
|
||||
|
||||
Use `updates.content` (NOT chunk). Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
5. **Return Summary**
|
||||
Reply with: Task / Actions / Result / Nodes / Follow-up (≤100 words)
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep total tool calls ≤ 12
|
||||
- Create edges BEFORE documenting (step 3 before step 4)
|
||||
- Call `updateNode` exactly once
|
||||
- Adapt to any node type
|
||||
- Use `think` at any point if you need to plan your approach
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Prep
|
||||
description: Quick summary brief — extract the gist to help decide if content is worth deeper engagement.
|
||||
---
|
||||
|
||||
# Prep
|
||||
|
||||
Quick summary to help the user decide if content is worth deeper engagement.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read the Node**
|
||||
- Call `getNodesById` for the focused node
|
||||
- Understand what this is and extract the core message
|
||||
|
||||
2. **Append Brief**
|
||||
Call `updateNode` ONCE using the `content` field with ONLY this section:
|
||||
|
||||
```
|
||||
---
|
||||
## Brief
|
||||
|
||||
**What:** [One sentence — what is this?]
|
||||
|
||||
**Gist:** [2-3 sentences — the core message or takeaway]
|
||||
|
||||
**Why it matters:** [1-2 sentences — relevance or implications]
|
||||
```
|
||||
|
||||
Use `updates.content` (NOT chunk). Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
3. **Return Summary**
|
||||
Reply with a one-line confirmation: "Prepped [title] — [gist in <10 words]"
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep total tool calls ≤ 3
|
||||
- Call `updateNode` exactly once
|
||||
- Be concise — this is a quick prep, not deep analysis
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: Research
|
||||
description: Deep research — conduct background web research on a topic and append findings to the node.
|
||||
---
|
||||
|
||||
# Research
|
||||
|
||||
Conduct background research on the topic/person/concept and append findings to the node.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read & Identify**
|
||||
- Call `getNodesById` for the focused node
|
||||
- Identify: what needs researching? (person's background, concept origins, recent developments, etc.)
|
||||
|
||||
2. **Web Research**
|
||||
- Call `webSearch` with targeted queries (1-2 searches)
|
||||
- Focus on: background context, recent news, authoritative sources
|
||||
- Extract the most relevant findings
|
||||
|
||||
3. **Append Research**
|
||||
Call `updateNode` ONCE using the `content` field with ONLY this section:
|
||||
|
||||
```
|
||||
---
|
||||
## Research Notes
|
||||
|
||||
**Background:** [2-3 sentences of context]
|
||||
|
||||
**Key Findings:**
|
||||
- [Finding 1]
|
||||
- [Finding 2]
|
||||
- [Finding 3]
|
||||
|
||||
**Sources:** [Brief attribution]
|
||||
```
|
||||
|
||||
Use `updates.content` (NOT chunk). Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
4. **Return Summary**
|
||||
Reply with: "Researched [topic] — [key insight in <15 words]"
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep total tool calls ≤ 5
|
||||
- Call `updateNode` exactly once
|
||||
- Focus on factual background, not opinion
|
||||
- Cite sources when possible
|
||||
@@ -1,47 +1,53 @@
|
||||
export const SURVEY_WORKFLOW_INSTRUCTIONS = `You are executing the SURVEY workflow for the currently active dimension.
|
||||
---
|
||||
name: Survey
|
||||
description: Survey a dimension — analyze patterns, themes, gaps, and insights across all nodes within it.
|
||||
---
|
||||
|
||||
# Survey
|
||||
|
||||
MISSION
|
||||
Analyze the active dimension to identify patterns, themes, gaps, and insights across all nodes within it.
|
||||
|
||||
PREREQUISITE: This workflow requires an active dimension. Check ACTIVE DIMENSION section in context.
|
||||
**Prerequisite:** This guide requires an active dimension. Check the ACTIVE DIMENSION section in context.
|
||||
|
||||
WORKFLOW STEPS
|
||||
## Steps
|
||||
|
||||
1. RETRIEVE DIMENSION NODES
|
||||
- Call queryDimensionNodes with the active dimension name
|
||||
1. **Retrieve Dimension Nodes**
|
||||
- Call `queryDimensionNodes` with the active dimension name
|
||||
- Set limit to 50 to get a comprehensive view
|
||||
- Note the total count and most connected nodes
|
||||
|
||||
2. ANALYZE THEMES
|
||||
- Call searchContentEmbeddings with key concepts from the dimension
|
||||
2. **Analyze Themes**
|
||||
- Call `searchContentEmbeddings` with key concepts from the dimension
|
||||
- Identify recurring themes, patterns, and relationships
|
||||
- Note any gaps or underrepresented areas
|
||||
|
||||
3. DOCUMENT FINDINGS
|
||||
Call updateDimension to update the dimension description with:
|
||||
3. **Document Findings**
|
||||
Call `updateDimension` to update the dimension description with:
|
||||
|
||||
**Survey Summary** (append to existing description)
|
||||
```
|
||||
**Survey Summary**
|
||||
|
||||
**Node Count:** [X nodes]
|
||||
**Top Hubs:** [List 3-5 most connected nodes with [NODE:id:"title"]]
|
||||
|
||||
**Themes:**
|
||||
- [Theme 1 - brief description]
|
||||
- [Theme 2 - brief description]
|
||||
- [Theme 1 — brief description]
|
||||
- [Theme 2 — brief description]
|
||||
|
||||
**Gaps/Opportunities:**
|
||||
- [Identified gap or area for expansion]
|
||||
|
||||
**Connections to Other Dimensions:**
|
||||
- [Any cross-dimension patterns observed]
|
||||
```
|
||||
|
||||
4. RETURN SUMMARY
|
||||
4. **Return Summary**
|
||||
Reply with: "Surveyed [dimension] — [X nodes, key insight in <15 words]"
|
||||
|
||||
RULES
|
||||
## Rules
|
||||
|
||||
- Keep total tool calls ≤ 5
|
||||
- Focus on patterns across the dimension, not individual node details
|
||||
- Identify both strengths (dense areas) and gaps (sparse areas)
|
||||
- Reference specific nodes using [NODE:id:"title"] format
|
||||
|
||||
If no active dimension is set, inform the user to open a dimension folder first.`;
|
||||
- Reference specific nodes using `[NODE:id:"title"]` format
|
||||
- If no active dimension is set, inform the user to open a dimension folder first
|
||||
@@ -6,9 +6,8 @@ Mission:
|
||||
3. Ask for clarification only when tool usage would fail without it.
|
||||
|
||||
Operating principles:
|
||||
- Handle analysis, planning, and writes yourself; do not delegate.
|
||||
- Handle analysis, planning, and writes yourself.
|
||||
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
|
||||
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
|
||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
|
||||
- When referencing stored content, quote verbatim text in quotes and include the node citation.
|
||||
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
|
||||
|
||||
@@ -10,18 +10,13 @@ When to ask the user:
|
||||
- If the request is ambiguous and guessing would waste effort or cause errors.
|
||||
|
||||
Execution approach:
|
||||
- Handle planning, analysis, and writes directly—do not delegate to mini ra-h during normal conversations.
|
||||
- Handle planning, analysis, and writes directly.
|
||||
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
|
||||
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
|
||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
|
||||
- Treat "this conversation/paper/video" as the active focused node.
|
||||
- When creating synthesis nodes, createEdge to all source nodes.
|
||||
- Before running an extraction tool, call getNodesById on the target node; if chunk_status is 'chunked' (or embeddings are available) reuse the stored content instead of re-extracting.
|
||||
|
||||
Workflows:
|
||||
- User can trigger predefined workflows (e.g., "run integrate workflow").
|
||||
- executeWorkflow hands coordination to wise ra-h; any mini ra-h work happens inside that workflow only.
|
||||
|
||||
Tool strategy:
|
||||
- Use tools directly—you already have everything you need.
|
||||
- queryNodes for titles, searchContentEmbeddings for content, queryEdge for connections.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Minimal system prompt for workflow execution.
|
||||
* All specific instructions come from the workflow definition itself.
|
||||
*/
|
||||
export const WORKFLOW_EXECUTOR_SYSTEM_PROMPT = `You are a workflow executor. Follow the workflow instructions exactly as written.
|
||||
|
||||
RULES:
|
||||
- Use only the tools provided
|
||||
- Do not deviate from the instructions
|
||||
- Complete the workflow efficiently
|
||||
- Reference nodes as [NODE:id:"title"] (e.g., [NODE:123:"My Node Title"])
|
||||
- Return a brief summary when done`;
|
||||
@@ -1,36 +0,0 @@
|
||||
export const CONNECT_WORKFLOW_INSTRUCTIONS = `You are executing the CONNECT workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Quick link: find explicitly related nodes and create edges.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. READ NODE
|
||||
Call getNodesById for the focused node. Extract the main topic/subject from the title.
|
||||
|
||||
2. QUICK SEARCH
|
||||
Call queryNodes with the main topic from the node title.
|
||||
- search: the key term from the title (e.g., if title mentions "Nietzsche", search "Nietzsche")
|
||||
- limit: 10
|
||||
- DO NOT add dimensions filter - search across all nodes
|
||||
|
||||
3. CREATE EDGES
|
||||
From results, pick 2-4 clearly related nodes.
|
||||
Call createEdge for each:
|
||||
- from_node_id: focused node ID
|
||||
- to_node_id: related node ID
|
||||
- explanation: "brief reason for this connection"
|
||||
Direction rule: write the explanation so it reads FROM → TO.
|
||||
Examples:
|
||||
- Episode → Podcast: "Episode of this podcast"
|
||||
- Book → Author: "Written by"
|
||||
|
||||
4. DONE
|
||||
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
|
||||
|
||||
RULES
|
||||
- Total tool calls ≤ 5
|
||||
- Search the MAIN TOPIC from the title, not random names from content
|
||||
- NO dimensions filter in queryNodes - search everything
|
||||
- Only link nodes with clear relationships
|
||||
- Skip if no matches found`;
|
||||
@@ -1,67 +0,0 @@
|
||||
export const INTEGRATE_WORKFLOW_INSTRUCTIONS = `You are executing the INTEGRATE workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Find meaningful connections across the user's knowledge graph, create edges, and append an Integration Analysis.
|
||||
|
||||
YOU HAVE DIRECT WRITE ACCESS via updateNode (appends only) and createEdge (creates graph connections).
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. RETRIEVE & UNDERSTAND
|
||||
- Call getNodesById for the focused node
|
||||
- Identify: what type of thing is this? (person, project, paper, idea, video, etc.)
|
||||
- Extract key entities: names, projects, concepts, techniques
|
||||
- Note the core insight in one sentence
|
||||
|
||||
2. SEARCH FOR CONNECTIONS
|
||||
Search the database using entities from step 1:
|
||||
|
||||
a) Structural connections:
|
||||
- Names mentioned → queryNodes to find nodes about those people
|
||||
- Projects/tools mentioned → queryNodes to find those nodes
|
||||
|
||||
b) Thematic connections:
|
||||
- Use searchContentEmbeddings with key concepts
|
||||
- Look for shared themes, complementary ideas, contradictions
|
||||
|
||||
Target: 3-5 strong connections (quality over quantity)
|
||||
|
||||
3. CREATE EDGES
|
||||
For each connection found, call createEdge:
|
||||
- from_node_id: the focused node ID
|
||||
- to_node_id: the connected node ID
|
||||
- explanation: "why this connection matters"
|
||||
Direction rule: write the explanation so it reads FROM → TO.
|
||||
Examples:
|
||||
- Insight → Source: "Came from / inspired by"
|
||||
- Episode → Podcast: "Episode of this podcast"
|
||||
|
||||
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
|
||||
|
||||
Create 3-5 edges total.
|
||||
|
||||
4. DOCUMENT IN CONTENT
|
||||
Call updateNode ONCE with ONLY this new section:
|
||||
|
||||
---
|
||||
## Integration Analysis
|
||||
|
||||
[2-3 sentences: what this is, why it matters, core insight]
|
||||
|
||||
**Connections:**
|
||||
- [NODE:123:"Title"] — [why connected]
|
||||
- [NODE:456:"Title"] — [why connected]
|
||||
[list all edges created in step 3]
|
||||
|
||||
CRITICAL: Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
5. RETURN SUMMARY
|
||||
Reply with: Task / Actions / Result / Nodes / Follow-up (≤100 words)
|
||||
|
||||
RULES
|
||||
- Keep total tool calls ≤ 12
|
||||
- Create edges BEFORE documenting (step 3 before step 4)
|
||||
- Call updateNode exactly once
|
||||
- Adapt to any node type
|
||||
|
||||
OPTIONAL: Call think at any point if you need to plan your approach.`;
|
||||
@@ -1,32 +0,0 @@
|
||||
export const PREP_WORKFLOW_INSTRUCTIONS = `You are executing the PREP workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Quick summary to help the user decide if this content is worth deeper engagement.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. READ THE NODE
|
||||
- Call getNodesById for the focused node
|
||||
- Understand what this is and extract the core message
|
||||
|
||||
2. APPEND BRIEF
|
||||
Call updateNode ONCE with ONLY this section:
|
||||
|
||||
---
|
||||
## Brief
|
||||
|
||||
**What:** [One sentence - what is this?]
|
||||
|
||||
**Gist:** [2-3 sentences - the core message or takeaway]
|
||||
|
||||
**Why it matters:** [1-2 sentences - relevance or implications]
|
||||
|
||||
CRITICAL: Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
3. RETURN SUMMARY
|
||||
Reply with a one-line confirmation: "Prepped [title] - [gist in <10 words]"
|
||||
|
||||
RULES
|
||||
- Keep total tool calls ≤ 3
|
||||
- Call updateNode exactly once
|
||||
- Be concise - this is a quick prep, not deep analysis`;
|
||||
@@ -1,41 +0,0 @@
|
||||
export const RESEARCH_WORKFLOW_INSTRUCTIONS = `You are executing the RESEARCH workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Conduct background research on the topic/person/concept and append findings to the node.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. READ & IDENTIFY
|
||||
- Call getNodesById for the focused node
|
||||
- Identify: what needs researching? (person's background, concept origins, recent developments, etc.)
|
||||
|
||||
2. WEB RESEARCH
|
||||
- Call webSearch with targeted queries (1-2 searches)
|
||||
- Focus on: background context, recent news, authoritative sources
|
||||
- Extract the most relevant findings
|
||||
|
||||
3. APPEND RESEARCH
|
||||
Call updateNode ONCE with ONLY this section:
|
||||
|
||||
---
|
||||
## Research Notes
|
||||
|
||||
**Background:** [2-3 sentences of context]
|
||||
|
||||
**Key Findings:**
|
||||
- [Finding 1]
|
||||
- [Finding 2]
|
||||
- [Finding 3]
|
||||
|
||||
**Sources:** [Brief attribution]
|
||||
|
||||
CRITICAL: Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
4. RETURN SUMMARY
|
||||
Reply with: "Researched [topic] - [key insight in <15 words]"
|
||||
|
||||
RULES
|
||||
- Keep total tool calls ≤ 5
|
||||
- Call updateNode exactly once
|
||||
- Focus on factual background, not opinion
|
||||
- Cite sources when possible`;
|
||||
@@ -1,471 +0,0 @@
|
||||
import { streamText, ModelMessage } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
|
||||
import { WORKFLOW_EXECUTOR_SYSTEM_PROMPT } from '@/config/prompts/workflow-executor';
|
||||
import { getToolsByNames } from '@/tools/infrastructure/registry';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { ChatLoggingMiddleware } from '@/services/chat/middleware';
|
||||
import { calculateCost } from '@/services/analytics/pricing';
|
||||
import { UsageData } from '@/types/analytics';
|
||||
import { summarizeToolExecution } from '@/services/agents/toolResultUtils';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
|
||||
export interface WorkflowExecutionInput {
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
expectedOutcome?: string | null;
|
||||
traceId?: string;
|
||||
parentChatId?: number;
|
||||
workflowKey?: string;
|
||||
workflowNodeId?: number;
|
||||
}
|
||||
|
||||
export class WorkflowExecutor {
|
||||
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WorkflowExecutionInput) {
|
||||
console.log('🧙 [WorkflowExecutor] Starting execution', { sessionId, task: task.substring(0, 100) });
|
||||
try {
|
||||
const requestContext = RequestContext.get();
|
||||
const workflowApiKey =
|
||||
requestContext.apiKeys?.openai ||
|
||||
process.env.RAH_WISE_RAH_OPENAI_API_KEY ||
|
||||
process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!workflowApiKey) {
|
||||
throw new Error('OPENAI_API_KEY is not set for workflow execution.');
|
||||
}
|
||||
|
||||
console.log('✅ [WorkflowExecutor] Starting workflow execution');
|
||||
|
||||
// Get workflow definition if available
|
||||
const workflow = workflowKey ? await WorkflowRegistry.getWorkflowByKey(workflowKey) : null;
|
||||
const maxIterationsLimit = workflow?.maxIterations ?? 10;
|
||||
|
||||
// Build the user prompt - just the task (which includes workflow instructions)
|
||||
const promptSections = [
|
||||
task,
|
||||
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
|
||||
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
const openaiProvider = createOpenAI({ apiKey: workflowApiKey });
|
||||
console.log('🔧 [WorkflowExecutor] OpenAI provider created');
|
||||
|
||||
// Use workflow-specified tools if available, otherwise fall back to safe default set
|
||||
// IMPORTANT: Workflows should NEVER have access to delegateToMiniRAH - they are one-shot executors
|
||||
const workflowTools = workflow?.tools;
|
||||
const SAFE_WORKFLOW_DEFAULT_TOOLS = [
|
||||
'getNodesById', 'queryNodes', 'queryDimensionNodes', 'searchContentEmbeddings',
|
||||
'webSearch', 'updateNode', 'createEdge'
|
||||
];
|
||||
const tools = workflowTools?.length
|
||||
? getToolsByNames(workflowTools)
|
||||
: getToolsByNames(SAFE_WORKFLOW_DEFAULT_TOOLS);
|
||||
|
||||
console.log('🛠️ [WorkflowExecutor] Tools for workflow:', Object.keys(tools));
|
||||
|
||||
const toolsUsedInSession: string[] = [];
|
||||
const delegatedEdgeKeys = new Set<string>();
|
||||
|
||||
// Workflow progress is now streamed directly to delegation tabs via delegationStreamBroadcaster
|
||||
const wrappedTools = Object.fromEntries(
|
||||
Object.entries(tools).map(([name, tool]) => {
|
||||
const wrapped = {
|
||||
...tool,
|
||||
async execute(params: any, context: any) {
|
||||
if (!toolsUsedInSession.includes(name)) {
|
||||
toolsUsedInSession.push(name);
|
||||
}
|
||||
if (name === 'delegateToMiniRAH') {
|
||||
const extractEdgeKey = () => {
|
||||
if (!params) return null;
|
||||
const tryFromTask = () => {
|
||||
if (typeof params.task !== 'string') return null;
|
||||
const matches = [...params.task.matchAll(/\[NODE:(\d+)/g)];
|
||||
if (matches.length >= 2) {
|
||||
const fromId = Number(matches[0][1]);
|
||||
const toId = Number(matches[1][1]);
|
||||
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
|
||||
return `${fromId}->${toId}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryFromContext = () => {
|
||||
if (!Array.isArray(params.context)) return null;
|
||||
let fromId: number | null = null;
|
||||
let toId: number | null = null;
|
||||
for (const entry of params.context) {
|
||||
if (typeof entry === 'string') {
|
||||
const fromMatch = entry.match(/from_node_id\D+(\d+)/i);
|
||||
const toMatch = entry.match(/to_node_id\D+(\d+)/i);
|
||||
if (fromMatch && Number.isFinite(Number(fromMatch[1]))) {
|
||||
fromId = Number(fromMatch[1]);
|
||||
}
|
||||
if (toMatch && Number.isFinite(Number(toMatch[1]))) {
|
||||
toId = Number(toMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(fromId as number) && Number.isFinite(toId as number)) {
|
||||
return `${fromId}->${toId}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return tryFromTask() || tryFromContext();
|
||||
};
|
||||
|
||||
const edgeKey = extractEdgeKey();
|
||||
if (edgeKey) {
|
||||
if (delegatedEdgeKeys.has(edgeKey)) {
|
||||
const [from, to] = edgeKey.split('->');
|
||||
const message = `Skipped duplicate edge delegation for nodes ${from}→${to}.`;
|
||||
workerSummaries.push(message);
|
||||
return message;
|
||||
}
|
||||
delegatedEdgeKeys.add(edgeKey);
|
||||
const [from, to] = edgeKey.split('->').map(Number);
|
||||
if (Number.isFinite(from) && Number.isFinite(to)) {
|
||||
const exists = await edgeService.edgeExists(from, to);
|
||||
if (exists) {
|
||||
const message = `Edge ${from}→${to} already exists; delegation skipped.`;
|
||||
workerSummaries.push(message);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await tool.execute(params, context);
|
||||
}
|
||||
};
|
||||
return [name, wrapped];
|
||||
})
|
||||
);
|
||||
|
||||
console.log('📝 [WorkflowExecutor] Starting execution loop...');
|
||||
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'system', content: WORKFLOW_EXECUTOR_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: promptSections.join('\n\n') }
|
||||
];
|
||||
|
||||
let finalText = '';
|
||||
const totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const maxIterations = maxIterationsLimit;
|
||||
|
||||
const seenToolResults = new Map<string, { output: LanguageModelV2ToolResultOutput; summary: string }>();
|
||||
const workerSummaries: string[] = [];
|
||||
|
||||
const ensureString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
// Logging helpers (no-op for lite version without delegation streaming)
|
||||
const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => {
|
||||
console.log(`🔧 [WorkflowExecutor] Tool start: ${toolName}`);
|
||||
};
|
||||
|
||||
const emitToolCompletion = (
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
rawResult: unknown,
|
||||
summary: string,
|
||||
status: 'complete' | 'error' = 'complete',
|
||||
errorMessage?: string
|
||||
) => {
|
||||
console.log(`✅ [WorkflowExecutor] Tool complete: ${toolName} - ${status}`);
|
||||
};
|
||||
|
||||
const buildToolOutput = (toolName: string, summary: string, rawResult: any): LanguageModelV2ToolResultOutput => {
|
||||
const trimmedSummary = summary.trim();
|
||||
|
||||
if (rawResult && typeof rawResult === 'object' && rawResult.success === false) {
|
||||
const message = trimmedSummary || ensureString(rawResult.error) || `${toolName} failed.`;
|
||||
return { type: 'error-text', value: message };
|
||||
}
|
||||
|
||||
if (typeof rawResult === 'string') {
|
||||
const value = rawResult.trim() || trimmedSummary || `${toolName} completed.`;
|
||||
return { type: 'text', value };
|
||||
}
|
||||
|
||||
if (trimmedSummary) {
|
||||
return { type: 'text', value: trimmedSummary };
|
||||
}
|
||||
|
||||
return { type: 'text', value: `${toolName} completed.` };
|
||||
};
|
||||
|
||||
const requestFinalSummary = async (instruction: string) => {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: instruction,
|
||||
});
|
||||
|
||||
const finalStreamResult = await streamText({
|
||||
model: openaiProvider('gpt-5-mini'),
|
||||
messages,
|
||||
tools: {},
|
||||
maxOutputTokens: 500,
|
||||
});
|
||||
|
||||
// Collect the complete response
|
||||
const finalChunks: string[] = [];
|
||||
for await (const chunk of finalStreamResult.textStream) {
|
||||
finalChunks.push(chunk);
|
||||
}
|
||||
|
||||
const finalResponse = {
|
||||
text: finalChunks.join(''),
|
||||
usage: await finalStreamResult.usage,
|
||||
};
|
||||
|
||||
totalUsage.inputTokens += finalResponse.usage?.inputTokens || 0;
|
||||
totalUsage.outputTokens += finalResponse.usage?.outputTokens || 0;
|
||||
totalUsage.totalTokens += finalResponse.usage?.totalTokens || 0;
|
||||
|
||||
return finalResponse.text ?? '';
|
||||
};
|
||||
|
||||
const normaliseForSignature = (toolName: string, input: any) => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (toolName === 'webSearch' && 'query' in input) {
|
||||
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return { ...input, query };
|
||||
}
|
||||
|
||||
if (toolName === 'searchContentEmbeddings' && 'query' in input) {
|
||||
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return { ...input, query };
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
console.log(`🔄 [WorkflowExecutor] Iteration ${i + 1}/${maxIterations}`);
|
||||
|
||||
const streamResult = await streamText({
|
||||
model: openaiProvider('gpt-5-mini'),
|
||||
messages,
|
||||
tools: wrappedTools,
|
||||
});
|
||||
|
||||
// Collect the complete response
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of streamResult.textStream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const response = {
|
||||
text: chunks.join(''),
|
||||
finishReason: await streamResult.finishReason,
|
||||
usage: await streamResult.usage,
|
||||
toolCalls: await streamResult.toolCalls,
|
||||
};
|
||||
|
||||
totalUsage.inputTokens += response.usage?.inputTokens || 0;
|
||||
totalUsage.outputTokens += response.usage?.outputTokens || 0;
|
||||
totalUsage.totalTokens += response.usage?.totalTokens || 0;
|
||||
|
||||
console.log(`📊 [WorkflowExecutor] Step ${i + 1} finishReason:`, response.finishReason);
|
||||
|
||||
// Log text response
|
||||
if (response.text && response.text.trim()) {
|
||||
console.log(`📝 [WorkflowExecutor] Response text: ${response.text.substring(0, 100)}...`);
|
||||
}
|
||||
|
||||
if (response.finishReason !== 'tool-calls') {
|
||||
finalText = response.text;
|
||||
console.log('✅ [WorkflowExecutor] Got final text');
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCalls = response.toolCalls || [];
|
||||
console.log(`🔧 [WorkflowExecutor] Executing ${toolCalls.length} tool calls`);
|
||||
|
||||
// Log tool calls
|
||||
if (toolCalls.length > 0) {
|
||||
console.log(`🔧 [WorkflowExecutor] Processing ${toolCalls.length} tool calls`);
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: toolCalls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
input: (call as any).input ?? (call as any).args,
|
||||
})),
|
||||
});
|
||||
|
||||
const toolResults: Array<{
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: LanguageModelV2ToolResultOutput;
|
||||
}> = [];
|
||||
|
||||
for (const call of toolCalls) {
|
||||
let callInputRaw = (call as any).input ?? (call as any).args;
|
||||
|
||||
const signatureInput = normaliseForSignature(call.toolName, callInputRaw);
|
||||
const signature = JSON.stringify({ tool: call.toolName, input: signatureInput });
|
||||
|
||||
// Broadcast tool call to delegation stream
|
||||
emitToolStart(call.toolCallId, call.toolName, callInputRaw);
|
||||
|
||||
// Skip duplicate tool calls (except think which can be called multiple times)
|
||||
if (call.toolName !== 'think' && seenToolResults.has(signature)) {
|
||||
const cached = seenToolResults.get(signature)!;
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: cached.output,
|
||||
});
|
||||
|
||||
// Broadcast cached result
|
||||
emitToolCompletion(call.toolCallId, call.toolName, cached.summary || 'Cached result', cached.summary || 'Cached result');
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = wrappedTools[call.toolName];
|
||||
if (!tool) {
|
||||
const warning = `Tool ${call.toolName} is not available for this workflow.`;
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: warning },
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, warning, 'error', warning);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawResult = await tool.execute(callInputRaw, {});
|
||||
const summary = summarizeToolExecution(call.toolName, callInputRaw, rawResult);
|
||||
const output = buildToolOutput(call.toolName, summary, rawResult);
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output,
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, rawResult, summary, 'complete');
|
||||
|
||||
// Cache result (except think which can be called multiple times)
|
||||
if (call.toolName !== 'think') {
|
||||
seenToolResults.set(signature, { output, summary });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Tool execution failed';
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: message },
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, message, 'error', message);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: toolResults,
|
||||
});
|
||||
}
|
||||
|
||||
// If we hit max iterations without a final response, request one
|
||||
if (!finalText) {
|
||||
console.warn('⚠️ [WorkflowExecutor] Max iterations hit with no summary. Requesting final response without tools.');
|
||||
finalText = await requestFinalSummary('Provide a brief summary of what was accomplished. Do not call any tools.');
|
||||
console.log('✅ [WorkflowExecutor] Final summary obtained after tool cutoff.');
|
||||
}
|
||||
|
||||
const usage = totalUsage;
|
||||
let summary = typeof finalText === 'string' ? finalText.trim() : '';
|
||||
|
||||
if (summary.length > 2000) {
|
||||
console.log('⚠️ [WorkflowExecutor] Summary too long, requesting concise version.');
|
||||
summary = (await requestFinalSummary('Condense the findings into ≤300 tokens using the Task/Actions/Result/Nodes/Follow-up format. Focus on the most salient insights and reference key nodes. Do not call any tools.')).trim();
|
||||
}
|
||||
if (summary.length > 1000) {
|
||||
summary = `${summary.slice(0, 997)}…`;
|
||||
}
|
||||
console.log('📄 [WorkflowExecutor] Summary after trim:', summary);
|
||||
console.log('📏 [WorkflowExecutor] Summary length:', summary.length);
|
||||
|
||||
if (!summary) {
|
||||
console.warn('[WorkflowExecutor] Empty summary received');
|
||||
throw new Error('Workflow executor returned empty summary');
|
||||
}
|
||||
|
||||
console.log('[WorkflowExecutor] summary:', summary);
|
||||
|
||||
// Calculate cost and log to chats table
|
||||
if (usage) {
|
||||
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
|
||||
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
const costResult = calculateCost({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
modelId: 'gpt-5-mini',
|
||||
});
|
||||
|
||||
const usageData: UsageData = {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd: costResult.totalCostUsd,
|
||||
modelUsed: 'gpt-5-mini',
|
||||
provider: 'openai',
|
||||
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
|
||||
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
};
|
||||
|
||||
await ChatLoggingMiddleware.logChatInteraction(
|
||||
task,
|
||||
summary,
|
||||
{
|
||||
helperName: 'workflow-agent',
|
||||
agentType: 'planner',
|
||||
delegationId: null,
|
||||
sessionId,
|
||||
usageData,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
systemMessage: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
console.log(`💰 [WorkflowExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`);
|
||||
}
|
||||
|
||||
console.log('✅ [WorkflowExecutor] Workflow execution complete');
|
||||
return { sessionId, summary, status: 'completed' as const };
|
||||
} catch (error) {
|
||||
console.error('❌ [WorkflowExecutor] Error during execution:', error);
|
||||
console.error('❌ [WorkflowExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||
const message = error instanceof Error ? error.message : 'Unknown workflow error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface DatabaseEvent {
|
||||
| 'AGENT_UPDATED'
|
||||
| 'AGENT_DELEGATION_CREATED'
|
||||
| 'AGENT_DELEGATION_UPDATED'
|
||||
| 'WORKFLOW_PROGRESS'
|
||||
| 'GUIDE_UPDATED'
|
||||
| 'CONNECTION_ESTABLISHED';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
export interface GuideMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Guide extends GuideMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const GUIDES_DIR = path.join(
|
||||
os.homedir(),
|
||||
'Library/Application Support/RA-H/guides'
|
||||
);
|
||||
|
||||
const BUNDLED_GUIDES_DIR = path.join(
|
||||
process.cwd(),
|
||||
'src/config/guides'
|
||||
);
|
||||
|
||||
function ensureGuidesDir(): void {
|
||||
if (!fs.existsSync(GUIDES_DIR)) {
|
||||
fs.mkdirSync(GUIDES_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedDefaultGuides(): void {
|
||||
if (!fs.existsSync(BUNDLED_GUIDES_DIR)) return;
|
||||
|
||||
const bundled = fs.readdirSync(BUNDLED_GUIDES_DIR).filter(f => f.endsWith('.md'));
|
||||
for (const file of bundled) {
|
||||
const dest = path.join(GUIDES_DIR, file);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.copyFileSync(path.join(BUNDLED_GUIDES_DIR, file), dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
ensureGuidesDir();
|
||||
const existing = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
|
||||
if (existing.length === 0) {
|
||||
seedDefaultGuides();
|
||||
}
|
||||
}
|
||||
|
||||
export function listGuides(): GuideMeta[] {
|
||||
init();
|
||||
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
|
||||
return files.map(file => {
|
||||
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
|
||||
const { data } = matter(raw);
|
||||
return {
|
||||
name: data.name || file.replace('.md', ''),
|
||||
description: data.description || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function readGuide(name: string): Guide | null {
|
||||
init();
|
||||
// Try exact filename first, then lowercase
|
||||
const candidates = [
|
||||
`${name}.md`,
|
||||
`${name.toLowerCase()}.md`,
|
||||
];
|
||||
|
||||
for (const filename of candidates) {
|
||||
const filepath = path.join(GUIDES_DIR, filename);
|
||||
if (fs.existsSync(filepath)) {
|
||||
const raw = fs.readFileSync(filepath, 'utf-8');
|
||||
const { data, content } = matter(raw);
|
||||
return {
|
||||
name: data.name || name,
|
||||
description: data.description || '',
|
||||
content: content.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeGuide(name: string, content: string): void {
|
||||
init();
|
||||
const filename = `${name.toLowerCase()}.md`;
|
||||
const filepath = path.join(GUIDES_DIR, filename);
|
||||
fs.writeFileSync(filepath, content, 'utf-8');
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate';
|
||||
import { PREP_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/prep';
|
||||
import { RESEARCH_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/research';
|
||||
import { CONNECT_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/connect';
|
||||
import { SURVEY_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/survey';
|
||||
import type { WorkflowDefinition } from './types';
|
||||
import { listUserWorkflows, loadUserWorkflow } from './workflowFileService';
|
||||
|
||||
// Bundled default workflows (always available as fallback)
|
||||
const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
|
||||
'prep': {
|
||||
id: 1,
|
||||
key: 'prep',
|
||||
displayName: 'Prep',
|
||||
description: 'Quick summary to decide if content is worth deeper engagement',
|
||||
instructions: PREP_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Brief section appended with what/gist/why it matters',
|
||||
tools: ['getNodesById', 'updateNode'],
|
||||
maxIterations: 3,
|
||||
},
|
||||
'research': {
|
||||
id: 2,
|
||||
key: 'research',
|
||||
displayName: 'Research',
|
||||
description: 'Background research on topic, person, or concept',
|
||||
instructions: RESEARCH_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Research notes appended with background and key findings',
|
||||
tools: ['getNodesById', 'webSearch', 'updateNode'],
|
||||
maxIterations: 5,
|
||||
},
|
||||
'connect': {
|
||||
id: 3,
|
||||
key: 'connect',
|
||||
displayName: 'Connect',
|
||||
description: 'Find and create edges to related nodes',
|
||||
instructions: CONNECT_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: '3-5 edges created to related nodes',
|
||||
tools: ['getNodesById', 'queryNodes', 'createEdge'],
|
||||
maxIterations: 6,
|
||||
},
|
||||
'integrate': {
|
||||
id: 4,
|
||||
key: 'integrate',
|
||||
displayName: 'Integrate',
|
||||
description: 'Full analysis, connection discovery, and documentation',
|
||||
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Integration analysis appended; 3-5 edges created',
|
||||
tools: ['getNodesById', 'queryNodes', 'searchContentEmbeddings', 'createEdge', 'updateNode'],
|
||||
maxIterations: 12,
|
||||
},
|
||||
'survey': {
|
||||
id: 5,
|
||||
key: 'survey',
|
||||
displayName: 'Survey',
|
||||
description: 'Analyze dimension patterns, themes, and gaps',
|
||||
instructions: SURVEY_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: false, // Requires active dimension, not focused node
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Dimension description updated with survey findings',
|
||||
tools: ['queryDimensionNodes', 'searchContentEmbeddings', 'updateDimension'],
|
||||
maxIterations: 5,
|
||||
},
|
||||
};
|
||||
|
||||
// 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,
|
||||
tools: uw.tools,
|
||||
maxIterations: uw.maxIterations,
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkflowRegistry {
|
||||
static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | 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[]> {
|
||||
const all = await this.getAllWorkflows();
|
||||
return all.filter(w => w.enabled);
|
||||
}
|
||||
|
||||
static async getAllWorkflows(): Promise<WorkflowDefinition[]> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export interface WorkflowDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
requiresFocusedNode: boolean;
|
||||
primaryActor: 'oracle' | 'main';
|
||||
expectedOutcome?: string;
|
||||
/** Tools this workflow is allowed to use. If not specified, uses default set. */
|
||||
tools?: string[];
|
||||
/** Maximum iterations for this workflow. Defaults to 10. */
|
||||
maxIterations?: number;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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;
|
||||
tools?: string[];
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
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,
|
||||
tools: Array.isArray(parsed.tools) ? parsed.tools : undefined,
|
||||
maxIterations: typeof parsed.maxIterations === 'number' ? parsed.maxIterations : undefined,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
tools: Array.isArray(parsed.tools) ? parsed.tools : undefined,
|
||||
maxIterations: typeof parsed.maxIterations === 'number' ? parsed.maxIterations : undefined,
|
||||
};
|
||||
} 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: Record<string, unknown> = {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled,
|
||||
requiresFocusedNode: workflow.requiresFocusedNode,
|
||||
};
|
||||
|
||||
// Only include tools/maxIterations if defined
|
||||
if (workflow.tools) data.tools = workflow.tools;
|
||||
if (workflow.maxIterations) data.maxIterations = workflow.maxIterations;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { listGuides } from '@/services/guides/guideService';
|
||||
|
||||
export const listGuidesTool = tool({
|
||||
description: 'List all available guides with their names and descriptions.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
const guides = listGuides();
|
||||
return {
|
||||
success: true,
|
||||
data: guides,
|
||||
message: `Found ${guides.length} guides`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[listGuides] error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list guides',
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { readGuide } from '@/services/guides/guideService';
|
||||
|
||||
export const readGuideTool = tool({
|
||||
description: 'Read a guide by name. Returns the full markdown content with instructions.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The name of the guide to read'),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
try {
|
||||
const guide = readGuide(name);
|
||||
if (!guide) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Guide "${name}" not found`,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: guide,
|
||||
message: `Loaded guide: ${guide.name}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[readGuide] error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read guide',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { writeGuide } from '@/services/guides/guideService';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
|
||||
export const writeGuideTool = tool({
|
||||
description: 'Write or update a guide. Content should be full markdown with YAML frontmatter (name, description).',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The name of the guide to write'),
|
||||
content: z.string().describe('Full markdown content including YAML frontmatter'),
|
||||
}),
|
||||
execute: async ({ name, content }) => {
|
||||
try {
|
||||
writeGuide(name, content);
|
||||
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
|
||||
return {
|
||||
success: true,
|
||||
data: { name },
|
||||
message: `Guide "${name}" saved`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[writeGuide] error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to write guide',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -18,7 +18,7 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
||||
orchestration: {
|
||||
id: 'orchestration',
|
||||
name: 'Orchestration',
|
||||
description: 'Workflows, web search, and reasoning (orchestrator only)',
|
||||
description: 'Web search and reasoning tools',
|
||||
icon: '●',
|
||||
color: '#8b5cf6'
|
||||
},
|
||||
@@ -42,13 +42,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
queryDimensionNodes: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
|
||||
// Orchestration: Workflows and reasoning (orchestrator only)
|
||||
// Orchestration: Web search and reasoning
|
||||
webSearch: 'orchestration',
|
||||
think: 'orchestration',
|
||||
executeWorkflow: 'orchestration',
|
||||
listWorkflows: 'orchestration',
|
||||
getWorkflow: 'orchestration',
|
||||
editWorkflow: 'orchestration',
|
||||
|
||||
// Execution: Write operations and extraction (workers only)
|
||||
createNode: 'execution',
|
||||
|
||||
@@ -16,10 +16,6 @@ import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||
import { webSearchTool } from '../other/webSearch';
|
||||
import { thinkTool } from '../other/think';
|
||||
import { executeWorkflowTool } from '../orchestration/executeWorkflow';
|
||||
import { listWorkflowsTool } from '../orchestration/listWorkflows';
|
||||
import { getWorkflowTool } from '../orchestration/getWorkflow';
|
||||
import { editWorkflowTool } from '../orchestration/editWorkflow';
|
||||
import { youtubeExtractTool } from '../other/youtubeExtract';
|
||||
import { websiteExtractTool } from '../other/websiteExtract';
|
||||
import { paperExtractTool } from '../other/paperExtract';
|
||||
@@ -41,10 +37,6 @@ const CORE_TOOLS: Record<string, any> = {
|
||||
const ORCHESTRATION_TOOLS: Record<string, any> = {
|
||||
webSearch: webSearchTool,
|
||||
think: thinkTool,
|
||||
executeWorkflow: executeWorkflowTool,
|
||||
listWorkflows: listWorkflowsTool,
|
||||
getWorkflow: getWorkflowTool,
|
||||
editWorkflow: editWorkflowTool,
|
||||
};
|
||||
|
||||
// Execution tools for worker agents (includes write operations)
|
||||
@@ -77,10 +69,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
||||
...Object.keys(CORE_TOOLS),
|
||||
'webSearch',
|
||||
'think',
|
||||
'executeWorkflow',
|
||||
'listWorkflows',
|
||||
'getWorkflow',
|
||||
'editWorkflow',
|
||||
'createNode',
|
||||
'updateNode',
|
||||
'createEdge',
|
||||
@@ -95,9 +83,7 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
||||
|
||||
const EXECUTOR_TOOL_NAMES = [
|
||||
...Object.keys(CORE_TOOLS),
|
||||
...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
|
||||
name !== 'executeWorkflow'
|
||||
),
|
||||
...Object.keys(ORCHESTRATION_TOOLS),
|
||||
...Object.keys(EXECUTION_TOOLS),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { saveWorkflow, loadUserWorkflow } from '@/services/workflows/workflowFileService';
|
||||
|
||||
export const editWorkflowTool = tool({
|
||||
description: 'Update a workflow definition. Use to refine instructions, update description, or toggle enabled state.',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('The key of the workflow to edit'),
|
||||
updates: z.object({
|
||||
displayName: z.string().optional().describe('New display name'),
|
||||
description: z.string().optional().describe('New description'),
|
||||
instructions: z.string().optional().describe('New instructions (full replacement)'),
|
||||
enabled: z.boolean().optional().describe('Enable or disable the workflow'),
|
||||
requiresFocusedNode: z.boolean().optional().describe('Whether workflow requires a focused node'),
|
||||
}).describe('Fields to update'),
|
||||
}),
|
||||
execute: async ({ workflowKey, updates }) => {
|
||||
try {
|
||||
// Get existing workflow (from user file or bundled default)
|
||||
const existing = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' not found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge updates with existing values
|
||||
const updated = {
|
||||
key: workflowKey,
|
||||
displayName: updates.displayName ?? existing.displayName,
|
||||
description: updates.description ?? existing.description,
|
||||
instructions: updates.instructions ?? existing.instructions,
|
||||
enabled: updates.enabled ?? existing.enabled,
|
||||
requiresFocusedNode: updates.requiresFocusedNode ?? existing.requiresFocusedNode,
|
||||
};
|
||||
|
||||
// Save to user workflow file (this will override bundled if same key)
|
||||
saveWorkflow(updated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Workflow '${workflowKey}' updated`,
|
||||
workflow: {
|
||||
key: updated.key,
|
||||
displayName: updated.displayName,
|
||||
description: updated.description,
|
||||
enabled: updated.enabled,
|
||||
requiresFocusedNode: updated.requiresFocusedNode,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('editWorkflow error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to edit workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
import { getAutoContextSummaries } from '@/services/context/autoContext';
|
||||
|
||||
export const executeWorkflowTool = tool({
|
||||
description: 'Execute predefined workflow via wise ra-h',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('Key of workflow to execute (e.g., "integrate")'),
|
||||
nodeId: z.number().describe('ID of node to run workflow on (usually focused node)'),
|
||||
userContext: z.string().optional().describe('Optional: Additional context or instructions from user'),
|
||||
}),
|
||||
execute: async ({ workflowKey, nodeId, userContext }) => {
|
||||
// 1. Fetch workflow definition
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
if (!workflow) {
|
||||
return { success: false, error: `Workflow '${workflowKey}' not found` };
|
||||
}
|
||||
if (!workflow.enabled) {
|
||||
return { success: false, error: `Workflow '${workflowKey}' is disabled` };
|
||||
}
|
||||
|
||||
// 2. Validate node requirement
|
||||
if (workflow.requiresFocusedNode && !nodeId) {
|
||||
return { success: false, error: `Workflow '${workflowKey}' requires a focused node` };
|
||||
}
|
||||
|
||||
// 2.5. Prevent re-running same workflow on same node within 1 hour
|
||||
if (nodeId) {
|
||||
const db = getSQLiteClient();
|
||||
const recentRuns = db.query<{ id: number }>(
|
||||
`SELECT id FROM chats
|
||||
WHERE json_extract(metadata, '$.workflow_key') = ?
|
||||
AND json_extract(metadata, '$.workflow_node_id') = ?
|
||||
AND datetime(created_at) > datetime('now', '-1 hour')
|
||||
LIMIT 1`,
|
||||
[workflowKey, nodeId]
|
||||
).rows;
|
||||
|
||||
if (recentRuns.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' already ran on node ${nodeId} within the last hour. Check the node content - the Integration Analysis section should already be there.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate node exists and fetch full context (if node provided)
|
||||
let contextLines: string[] = [];
|
||||
if (nodeId) {
|
||||
const db = getSQLiteClient();
|
||||
const stmt = db.prepare('SELECT * FROM nodes WHERE id = ?');
|
||||
const node = stmt.get(nodeId) as any;
|
||||
if (!node) {
|
||||
return { success: false, error: `Node ${nodeId} not found` };
|
||||
}
|
||||
|
||||
contextLines = [
|
||||
`Focused Node: [NODE:${node.id}:"${node.title}"]`,
|
||||
node.description ? `Description: ${node.description}` : null,
|
||||
node.content ? `Content: ${node.content}` : null,
|
||||
node.link ? `Link: ${node.link}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
// Removed conflicting guardrail - wise-rah has updateNode access and should use it
|
||||
|
||||
if (userContext) {
|
||||
contextLines.push(`User Context: ${userContext}`);
|
||||
}
|
||||
|
||||
// 4. Build task with workflow instructions
|
||||
RequestContext.set({ workflowKey, workflowNodeId: nodeId });
|
||||
|
||||
const autoContextSummaries = getAutoContextSummaries(6);
|
||||
if (autoContextSummaries.length > 0) {
|
||||
contextLines.push('Background Context (Top Hubs):');
|
||||
autoContextSummaries.forEach((summary) => {
|
||||
contextLines.push(
|
||||
`[NODE:${summary.id}:"${summary.title}"] (${summary.edgeCount} edges)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const task = `Execute workflow: ${workflow.displayName}
|
||||
|
||||
${workflow.instructions}
|
||||
|
||||
${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general workflow)'}`;
|
||||
|
||||
// 5. Execute workflow directly
|
||||
const requestContext = RequestContext.get();
|
||||
console.log('[executeWorkflowTool] Current traceId:', requestContext.traceId);
|
||||
|
||||
const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
try {
|
||||
const result = await WorkflowExecutor.execute({
|
||||
sessionId,
|
||||
task,
|
||||
context: contextLines,
|
||||
expectedOutcome: workflow.expectedOutcome,
|
||||
traceId: requestContext.traceId,
|
||||
parentChatId: requestContext.parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId: nodeId,
|
||||
});
|
||||
|
||||
RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined });
|
||||
|
||||
const workflowLabel = workflow.displayName || workflowKey;
|
||||
return {
|
||||
success: true,
|
||||
message: `Workflow **${workflowLabel}** completed.`,
|
||||
summary: result.summary,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[executeWorkflowTool] Workflow execution failed', error);
|
||||
RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined });
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: `Workflow execution failed: ${errorMessage}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
import { userWorkflowExists } from '@/services/workflows/workflowFileService';
|
||||
|
||||
export const getWorkflowTool = tool({
|
||||
description: 'Retrieve a workflow definition including its full instructions',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('The key of the workflow to retrieve (e.g., "integrate", "prep")'),
|
||||
}),
|
||||
execute: async ({ workflowKey }) => {
|
||||
try {
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
|
||||
if (!workflow) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflow: {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled,
|
||||
requiresFocusedNode: workflow.requiresFocusedNode,
|
||||
isBundled: BUNDLED_WORKFLOW_KEYS.has(workflow.key),
|
||||
hasUserOverride: userWorkflowExists(workflow.key),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getWorkflow error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
|
||||
export const listWorkflowsTool = tool({
|
||||
description: 'List all available workflows',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
const workflows = await WorkflowRegistry.getAllWorkflows();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflows: workflows.map(w => ({
|
||||
key: w.key,
|
||||
displayName: w.displayName,
|
||||
description: w.description,
|
||||
enabled: w.enabled,
|
||||
requiresFocusedNode: w.requiresFocusedNode,
|
||||
isBundled: BUNDLED_WORKFLOW_KEYS.has(w.key),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('listWorkflows error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list workflows',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { scenario as simpleQuery } from './simple-query';
|
||||
import { scenario as searchEmbeddings } from './search-embeddings';
|
||||
import { scenario as createNode } from './create-node';
|
||||
import { scenario as hardModeQuery } from './hard-mode-query';
|
||||
import { scenario as workflowIntegrate } from './workflow-integrate';
|
||||
import { scenario as updateNode } from './update-node';
|
||||
import { scenario as createEdge } from './create-edge';
|
||||
import { scenario as queryDimensions } from './query-dimensions';
|
||||
@@ -22,7 +21,6 @@ export const scenarios = [
|
||||
getDimension,
|
||||
dimensionLifecycle,
|
||||
hardModeQuery,
|
||||
workflowIntegrate,
|
||||
youtubeExtract,
|
||||
websiteExtract,
|
||||
paperExtract,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Scenario } from '../types';
|
||||
|
||||
export const scenario: Scenario = {
|
||||
id: 'workflow-integrate',
|
||||
name: 'Integrate workflow on focused node',
|
||||
description: 'Execute integrate workflow on a real node.',
|
||||
tools: ['executeWorkflow'],
|
||||
input: {
|
||||
message: 'Integrate this.',
|
||||
focusedNodeQuery: { titleContains: 'Markdown vs database backends for PKM' },
|
||||
mode: 'hard',
|
||||
},
|
||||
expect: {
|
||||
toolsCalledSoft: ['executeWorkflow'],
|
||||
responseContainsSoft: ['integrate', 'updated'],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user