Merge pull request #1 from bradwmorris/feature/rah-light
Feature/rah light
This commit is contained in:
@@ -1,17 +1,26 @@
|
|||||||
# RA-H Open Source
|
# RA-H Light
|
||||||
|
|
||||||
A local-first AI research workspace. Full 3-panel interface, vector search, content ingestion, workflows, and conversation agents. BYO API keys, no cloud dependencies.
|
A lightweight local knowledge graph UI with MCP server. Connect your AI coding agents to a personal knowledge base. BYO API keys, no cloud dependencies.
|
||||||
|
|
||||||
**Full Documentation:** [ra-h.app/docs](https://ra-h.app/docs)
|
## What is RA-H Light?
|
||||||
|
|
||||||
|
RA-H Light is a stripped-down version of RA-H focused on being a **knowledge management backend for AI agents**. It provides:
|
||||||
|
|
||||||
|
- **2-panel UI** – Nodes list + focus panel for viewing/editing knowledge
|
||||||
|
- **SQLite + sqlite-vec** – Local vector database with semantic search
|
||||||
|
- **MCP Server** – Connect Claude Code, Cursor, or any MCP-compatible AI assistant
|
||||||
|
- **Workflows** – Editable JSON workflows for multi-step operations
|
||||||
|
|
||||||
|
**What's removed:** Built-in chat agents, voice features, delegation system. RA-H Light is designed for technical users who want to bring their own AI agents via MCP.
|
||||||
|
|
||||||
## Platform Support
|
## Platform Support
|
||||||
|
|
||||||
| Platform | Status |
|
| Platform | Status |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| macOS (Apple Silicon) | ✅ Supported |
|
| macOS (Apple Silicon) | Supported |
|
||||||
| macOS (Intel) | ✅ Supported |
|
| macOS (Intel) | Supported |
|
||||||
| Linux | 🚧 Coming (requires manual sqlite-vec build) |
|
| Linux | Requires manual sqlite-vec build |
|
||||||
| Windows | 🚧 Coming (requires manual sqlite-vec build) |
|
| Windows | Requires manual sqlite-vec build |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -24,30 +33,65 @@ scripts/dev/bootstrap-local.sh
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3000 → **Settings → API Keys** → add your OpenAI/Anthropic keys.
|
Open http://localhost:3000 → **Settings → API Keys** → add your OpenAI key (for embeddings).
|
||||||
|
|
||||||
## Features
|
## Connecting AI Agents via MCP
|
||||||
|
|
||||||
- **3-Panel interface** – Nodes, focus, and chat in one view
|
RA-H Light exposes an MCP server that external AI assistants can use to read/write your knowledge graph.
|
||||||
- **BYO keys** – Your Anthropic/OpenAI keys only; nothing sent to RA-H
|
|
||||||
- **Local SQLite + sqlite-vec** – Semantic search and embeddings on your machine
|
### Claude Code Integration
|
||||||
- **Content extraction** – YouTube, PDF, web pipelines included
|
|
||||||
- **Workflows** – Editable JSON workflows for common tasks
|
Add to your Claude Code settings:
|
||||||
- **MCP Server** – Connect Claude Code, ChatGPT, or any MCP-compatible assistant
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"rah": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/ra-h_os/apps/mcp-server/stdio-server.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available MCP Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `rah_add_node` | Create a new knowledge node |
|
||||||
|
| `rah_search_nodes` | Search nodes by text |
|
||||||
|
| `rah_update_node` | Update an existing node |
|
||||||
|
| `rah_get_nodes` | Get nodes by ID |
|
||||||
|
| `rah_create_edge` | Connect two nodes |
|
||||||
|
| `rah_query_edges` | Find connections |
|
||||||
|
| `rah_update_edge` | Update a connection |
|
||||||
|
| `rah_create_dimension` | Create a tag/category |
|
||||||
|
| `rah_update_dimension` | Update a dimension |
|
||||||
|
| `rah_delete_dimension` | Delete a dimension |
|
||||||
|
| `rah_search_embeddings` | Semantic vector search |
|
||||||
|
|
||||||
|
### HTTP MCP Server
|
||||||
|
|
||||||
|
For non-stdio clients, start the HTTP server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apps/mcp-server/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Listens on `http://127.0.0.1:44145/mcp` by default.
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ Next.js App Router
|
app/ Next.js App Router
|
||||||
src/
|
src/
|
||||||
components/ UI
|
components/ UI components
|
||||||
services/ Agents, embeddings, ingestion, storage
|
services/ Database, embeddings, workflows
|
||||||
tools/ Agent tools
|
tools/ Available tools for workflows
|
||||||
config/ Prompts, workflows
|
apps/mcp-server/ MCP server (stdio + HTTP)
|
||||||
apps/mcp-server/ MCP server for external AI assistants
|
docs/ Local documentation
|
||||||
docs/ Local docs (mirrors ra-h.app/docs)
|
|
||||||
scripts/ Dev helpers
|
scripts/ Dev helpers
|
||||||
vendor/ Pre-built binaries (sqlite-vec, yt-dlp)
|
vendor/ Pre-built binaries (sqlite-vec)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -62,31 +106,20 @@ vendor/ Pre-built binaries (sqlite-vec, yt-dlp)
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
**Primary:** [ra-h.app/docs](https://ra-h.app/docs)
|
- [docs/0_overview.md](docs/0_overview.md) – System overview
|
||||||
|
|
||||||
Local reference:
|
|
||||||
- [docs/0_overview.md](docs/0_overview.md) – Overview
|
|
||||||
- [docs/1_architecture.md](docs/1_architecture.md) – Architecture
|
|
||||||
- [docs/2_schema.md](docs/2_schema.md) – Database schema
|
- [docs/2_schema.md](docs/2_schema.md) – Database schema
|
||||||
- [docs/8_mcp.md](docs/8_mcp.md) – MCP server setup
|
- [docs/8_mcp.md](docs/8_mcp.md) – MCP server details
|
||||||
- [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) – Common issues
|
|
||||||
|
|
||||||
## Linux/Windows Setup
|
## Linux/Windows Setup
|
||||||
|
|
||||||
The bundled `sqlite-vec` and `yt-dlp` binaries are macOS-only. For other platforms:
|
The bundled `sqlite-vec` binary is macOS-only. For other platforms:
|
||||||
|
|
||||||
**sqlite-vec** (required for vector search):
|
|
||||||
1. Clone https://github.com/asg017/sqlite-vec
|
1. Clone https://github.com/asg017/sqlite-vec
|
||||||
2. Build for your platform
|
2. Build for your platform
|
||||||
3. Place at `vendor/sqlite-extensions/vec0.so` (Linux) or `vec0.dll` (Windows)
|
3. Place at `vendor/sqlite-extensions/vec0.so` (Linux) or `vec0.dll` (Windows)
|
||||||
4. Set `SQLITE_VEC_EXTENSION_PATH` in `.env.local`
|
4. Set `SQLITE_VEC_EXTENSION_PATH` in `.env.local`
|
||||||
|
|
||||||
**yt-dlp** (required for YouTube extraction):
|
Without sqlite-vec: UI, node CRUD, and basic search still work. Vector/semantic search requires it.
|
||||||
1. Download from https://github.com/yt-dlp/yt-dlp/releases
|
|
||||||
2. Place at `vendor/bin/yt-dlp`
|
|
||||||
3. `chmod +x vendor/bin/yt-dlp` (Linux)
|
|
||||||
|
|
||||||
Without sqlite-vec: UI, node CRUD, basic search, chat, and content extraction still work. Vector/semantic search requires it.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -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,465 +0,0 @@
|
|||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { streamText, convertToModelMessages } from 'ai';
|
|
||||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
|
||||||
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
|
||||||
import { buildSystemPromptBlocks } from '@/services/helpers/contextBuilder';
|
|
||||||
import { helperLogger } from '@/services/helpers/logger';
|
|
||||||
import { withChatLogging } from '@/services/chat/middleware';
|
|
||||||
import { AgentRegistry } from '@/services/agents/registry';
|
|
||||||
import { calculateCost } from '@/services/analytics/pricing';
|
|
||||||
import { UsageData } from '@/types/analytics';
|
|
||||||
import type { CacheStats } from '@/types/prompts';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { RequestContext } from '@/services/context/requestContext';
|
|
||||||
import { isLocalMode } from '@/config/runtime';
|
|
||||||
|
|
||||||
export const maxDuration = 900; // 15 minutes (for workflows)
|
|
||||||
|
|
||||||
if (isLocalMode()) {
|
|
||||||
// TODO: add any special local-mode setup if needed later
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANTHROPIC_MODEL_MAP: Record<string, string> = {
|
|
||||||
'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929',
|
|
||||||
'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApiKeyOverrides = {
|
|
||||||
openai?: string;
|
|
||||||
anthropic?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveModel(modelId: string, apiKeys?: ApiKeyOverrides) {
|
|
||||||
if (modelId.startsWith('anthropic/')) {
|
|
||||||
const rawName = modelId.split('/')[1];
|
|
||||||
const mapped = ANTHROPIC_MODEL_MAP[rawName] || rawName;
|
|
||||||
|
|
||||||
const orchestratorKey =
|
|
||||||
apiKeys?.anthropic ||
|
|
||||||
process.env.RAH_ORCHESTRATOR_ANTHROPIC_API_KEY ||
|
|
||||||
process.env.ANTHROPIC_API_KEY;
|
|
||||||
if (!orchestratorKey) {
|
|
||||||
throw new Error('RAH_ORCHESTRATOR_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY) is not set.');
|
|
||||||
}
|
|
||||||
const provider = createAnthropic({
|
|
||||||
apiKey: orchestratorKey,
|
|
||||||
headers: {
|
|
||||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return provider(mapped);
|
|
||||||
}
|
|
||||||
if (modelId.startsWith('openai/')) {
|
|
||||||
const name = modelId.split('/')[1];
|
|
||||||
|
|
||||||
const delegateKey =
|
|
||||||
apiKeys?.openai ||
|
|
||||||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
|
|
||||||
process.env.OPENAI_API_KEY;
|
|
||||||
if (!delegateKey) {
|
|
||||||
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
|
|
||||||
}
|
|
||||||
const provider = createOpenAI({ apiKey: delegateKey });
|
|
||||||
return provider(name);
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported model id: ${modelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global cache stats storage for monitoring
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var lastCacheStats: CacheStats | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnthropicUsageLike = {
|
|
||||||
inputTokens?: number;
|
|
||||||
outputTokens?: number;
|
|
||||||
promptTokens?: number;
|
|
||||||
completionTokens?: number;
|
|
||||||
cacheCreationInputTokens?: number;
|
|
||||||
cachedInputTokens?: number;
|
|
||||||
totalTokens?: number;
|
|
||||||
cache_write_input_tokens?: number;
|
|
||||||
cache_read_input_tokens?: number;
|
|
||||||
cacheWriteInputTokens?: number;
|
|
||||||
cacheReadInputTokens?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normaliseUsage(entry: AnthropicUsageLike | undefined | null) {
|
|
||||||
if (!entry) {
|
|
||||||
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const toNumber = (value: unknown): number => {
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
||||||
if (typeof value === 'string' && value.trim().length > 0) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const input = toNumber(entry.inputTokens ?? entry.promptTokens ?? (entry as any).input_tokens ?? 0);
|
|
||||||
const output = toNumber(entry.outputTokens ?? entry.completionTokens ?? (entry as any).output_tokens ?? 0);
|
|
||||||
const cacheWrite = toNumber(
|
|
||||||
entry.cacheCreationInputTokens ??
|
|
||||||
(entry as any).cache_creation_input_tokens ??
|
|
||||||
entry.cacheWriteInputTokens ??
|
|
||||||
entry.cache_write_input_tokens ??
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const cacheRead = toNumber(
|
|
||||||
entry.cachedInputTokens ??
|
|
||||||
entry.cacheReadInputTokens ??
|
|
||||||
(entry as any).cache_read_input_tokens ??
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return { input, output, cacheWrite, cacheRead };
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateAnthropicUsage(usage: AnthropicUsageLike | undefined, providerMetadata: any) {
|
|
||||||
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
|
|
||||||
let dataSources = 0;
|
|
||||||
|
|
||||||
const add = (entry: AnthropicUsageLike | undefined | null) => {
|
|
||||||
if (!entry) return;
|
|
||||||
const { input, output, cacheWrite, cacheRead } = normaliseUsage(entry);
|
|
||||||
if (input || output || cacheWrite || cacheRead) {
|
|
||||||
totals.inputTokens += input;
|
|
||||||
totals.outputTokens += output;
|
|
||||||
totals.cacheWriteTokens += cacheWrite;
|
|
||||||
totals.cacheReadTokens += cacheRead;
|
|
||||||
dataSources += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const anthropicMeta = providerMetadata?.anthropic;
|
|
||||||
|
|
||||||
if (anthropicMeta) {
|
|
||||||
if (Array.isArray(anthropicMeta.requests)) {
|
|
||||||
anthropicMeta.requests.forEach((req: any) => {
|
|
||||||
add(req?.usage ?? req);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicMeta.response?.usage) {
|
|
||||||
add(anthropicMeta.response.usage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicMeta.usage && dataSources === 0) {
|
|
||||||
add(anthropicMeta.usage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSources === 0) {
|
|
||||||
add(usage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to usage totals if metadata did not provide cache info
|
|
||||||
if (totals.cacheReadTokens === 0 && usage?.cachedInputTokens) {
|
|
||||||
totals.cacheReadTokens = normaliseUsage(usage).cacheRead;
|
|
||||||
}
|
|
||||||
if (totals.cacheWriteTokens === 0 && usage?.cacheCreationInputTokens) {
|
|
||||||
totals.cacheWriteTokens = normaliseUsage(usage).cacheWrite;
|
|
||||||
}
|
|
||||||
|
|
||||||
return totals;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
let helperKey = 'ra-h';
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
messages = [],
|
|
||||||
openTabs = [],
|
|
||||||
activeTabId = null,
|
|
||||||
currentView = 'nodes',
|
|
||||||
sessionId,
|
|
||||||
traceId,
|
|
||||||
mode: requestedMode = 'easy',
|
|
||||||
apiKeys: rawApiKeys
|
|
||||||
} = await request.json();
|
|
||||||
const apiKeys: ApiKeyOverrides | undefined = rawApiKeys
|
|
||||||
? {
|
|
||||||
openai: typeof rawApiKeys.openai === 'string' && rawApiKeys.openai.trim().length > 0
|
|
||||||
? rawApiKeys.openai.trim()
|
|
||||||
: undefined,
|
|
||||||
anthropic: typeof rawApiKeys.anthropic === 'string' && rawApiKeys.anthropic.trim().length > 0
|
|
||||||
? rawApiKeys.anthropic.trim()
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const mode: 'easy' | 'hard' = requestedMode === 'hard' ? 'hard' : 'easy';
|
|
||||||
helperKey = mode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
|
||||||
|
|
||||||
const conversationTraceId = traceId || randomUUID();
|
|
||||||
|
|
||||||
RequestContext.set({
|
|
||||||
traceId: conversationTraceId,
|
|
||||||
openTabs,
|
|
||||||
activeTabId,
|
|
||||||
mode,
|
|
||||||
apiKeys,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter messages to only valid roles
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const sanitizedMessages = messages.filter((msg: any) =>
|
|
||||||
msg && ['user', 'assistant', 'system'].includes(msg.role)
|
|
||||||
);
|
|
||||||
|
|
||||||
const helperConfig = await AgentRegistry.orchestratorForMode(mode);
|
|
||||||
if (!helperConfig) {
|
|
||||||
throw new Error(`No orchestrator definition found for mode '${mode}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
helperKey = helperConfig.key || helperKey;
|
|
||||||
|
|
||||||
helperLogger.logUserMessage(helperKey, messages, openTabs, activeTabId);
|
|
||||||
|
|
||||||
const { blocks: systemBlocks, cacheHit } = await buildSystemPromptBlocks(
|
|
||||||
{ nodes: openTabs, activeNodeId: activeTabId },
|
|
||||||
helperKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const systemPromptPreview = systemBlocks.map(b => b.text).join('\n').substring(0, 200) + '...';
|
|
||||||
helperLogger.logSystemPrompt(helperKey, systemPromptPreview, cacheHit);
|
|
||||||
|
|
||||||
console.log('🔧 [Prompt Caching] System blocks structure:', {
|
|
||||||
helperKey,
|
|
||||||
totalBlocks: systemBlocks.length,
|
|
||||||
cachedBlocks: systemBlocks.filter(b => b.cache_control).length,
|
|
||||||
blockLengths: systemBlocks.map((b, i) => ({
|
|
||||||
index: i,
|
|
||||||
length: b.text.length,
|
|
||||||
cached: !!b.cache_control
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolNames = helperConfig.availableTools?.length
|
|
||||||
? helperConfig.availableTools
|
|
||||||
: getDefaultToolNamesForRole(helperConfig.role);
|
|
||||||
const tools = getHelperTools(toolNames);
|
|
||||||
const modelId = helperConfig.model || 'anthropic/claude-sonnet-4.5';
|
|
||||||
const model = resolveModel(modelId, apiKeys);
|
|
||||||
|
|
||||||
const rawModelId = modelId.split('/')[1] || modelId;
|
|
||||||
const fullModelId = ANTHROPIC_MODEL_MAP[rawModelId] || rawModelId;
|
|
||||||
|
|
||||||
const isAnthropicModel = modelId.startsWith('anthropic/');
|
|
||||||
const isOpenAIModel = modelId.startsWith('openai/');
|
|
||||||
|
|
||||||
const toolsUsedInSession: string[] = [];
|
|
||||||
|
|
||||||
// Convert system blocks to messages with providerOptions for caching
|
|
||||||
const systemMessages = systemBlocks.map((block) => ({
|
|
||||||
role: 'system' as const,
|
|
||||||
content: block.text,
|
|
||||||
...(isAnthropicModel && block.cache_control ? {
|
|
||||||
providerOptions: {
|
|
||||||
anthropic: { cacheControl: block.cache_control }
|
|
||||||
}
|
|
||||||
} : {})
|
|
||||||
}));
|
|
||||||
|
|
||||||
const coreMessages = await convertToModelMessages(sanitizedMessages);
|
|
||||||
const allMessages = [...systemMessages, ...coreMessages];
|
|
||||||
|
|
||||||
// Debug logging (can be removed in production)
|
|
||||||
if (process.env.DEBUG_CACHE === 'true') {
|
|
||||||
console.log('🔍 [Debug] System messages with cache control:');
|
|
||||||
systemMessages.forEach((msg, i) => {
|
|
||||||
console.log(` Block ${i}:`, {
|
|
||||||
hasContent: !!msg.content,
|
|
||||||
contentLength: msg.content?.length || 0,
|
|
||||||
hasProviderOptions: !!msg.providerOptions,
|
|
||||||
cacheControl: msg.providerOptions?.anthropic?.cacheControl
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamConfig = {
|
|
||||||
model,
|
|
||||||
messages: allMessages,
|
|
||||||
tools,
|
|
||||||
stopWhen: [],
|
|
||||||
maxSteps: 10,
|
|
||||||
...(isOpenAIModel ? { reasoning: { effort: 'light' as const } } : {}),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onToolCall: ({ toolCall }: any) => {
|
|
||||||
helperLogger.logToolCall(helperKey, toolCall.toolName, toolCall.args);
|
|
||||||
if (!toolsUsedInSession.includes(toolCall.toolName)) {
|
|
||||||
toolsUsedInSession.push(toolCall.toolName);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onToolResult: ({ toolCall, result }: any) => {
|
|
||||||
helperLogger.logToolResult(helperKey, toolCall.toolName, result);
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onFinish: (result: any) => {
|
|
||||||
helperLogger.logAssistantResponse(helperKey, result.text);
|
|
||||||
|
|
||||||
// Debug logging (can be removed in production)
|
|
||||||
if (process.env.DEBUG_CACHE === 'true') {
|
|
||||||
console.log('🔍 [Debug] Full onFinish result keys:', Object.keys(result));
|
|
||||||
console.log('🔍 [Debug] Raw usage object:', JSON.stringify(result.usage, null, 2));
|
|
||||||
console.log('🔍 [Debug] Provider metadata:', JSON.stringify(result.providerMetadata, null, 2));
|
|
||||||
console.log('🔍 [Debug] Steps array length:', result.steps?.length || 0);
|
|
||||||
if (result.steps && result.steps.length > 0) {
|
|
||||||
result.steps.forEach((step: any, i: number) => {
|
|
||||||
console.log(`🔍 [Debug] Step ${i} usage:`, JSON.stringify(step.usage, null, 2));
|
|
||||||
console.log(`🔍 [Debug] Step ${i} providerMetadata:`, JSON.stringify(step.providerMetadata, null, 2));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregatedUsage = isAnthropicModel
|
|
||||||
? (() => {
|
|
||||||
// Aggregate across ALL steps, not just final result
|
|
||||||
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
|
|
||||||
|
|
||||||
if (result.steps && Array.isArray(result.steps)) {
|
|
||||||
result.steps.forEach((step: any) => {
|
|
||||||
const stepUsage = aggregateAnthropicUsage(step.usage, step.providerMetadata);
|
|
||||||
totals.inputTokens += stepUsage.inputTokens;
|
|
||||||
totals.outputTokens += stepUsage.outputTokens;
|
|
||||||
// Cache write only happens once (in first step), so take MAX not SUM
|
|
||||||
totals.cacheWriteTokens = Math.max(totals.cacheWriteTokens, stepUsage.cacheWriteTokens);
|
|
||||||
totals.cacheReadTokens += stepUsage.cacheReadTokens;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback to result-level usage if no steps
|
|
||||||
const resultUsage = aggregateAnthropicUsage(result.usage, result.providerMetadata);
|
|
||||||
totals.inputTokens = resultUsage.inputTokens;
|
|
||||||
totals.outputTokens = resultUsage.outputTokens;
|
|
||||||
totals.cacheWriteTokens = resultUsage.cacheWriteTokens;
|
|
||||||
totals.cacheReadTokens = resultUsage.cacheReadTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
return totals;
|
|
||||||
})()
|
|
||||||
: {
|
|
||||||
inputTokens: Number(result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0),
|
|
||||||
outputTokens: Number(result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0),
|
|
||||||
cacheWriteTokens: 0,
|
|
||||||
cacheReadTokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const regular = aggregatedUsage.inputTokens || 0;
|
|
||||||
const cacheWrite = aggregatedUsage.cacheWriteTokens || 0;
|
|
||||||
const cacheRead = aggregatedUsage.cacheReadTokens || 0;
|
|
||||||
const outputTokens = aggregatedUsage.outputTokens || 0;
|
|
||||||
const total = regular + cacheWrite + cacheRead;
|
|
||||||
|
|
||||||
if (regular || cacheWrite || cacheRead || outputTokens) {
|
|
||||||
const savingsPercentage = total > 0 && cacheRead > 0 ? Math.round((cacheRead / total) * 100) : 0;
|
|
||||||
|
|
||||||
if (isAnthropicModel) {
|
|
||||||
const cacheStats: CacheStats = {
|
|
||||||
cacheCreationInputTokens: cacheWrite,
|
|
||||||
cacheReadInputTokens: cacheRead,
|
|
||||||
inputTokens: regular,
|
|
||||||
outputTokens,
|
|
||||||
savingsPercentage
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('\n📦 ═══════════════════════════════════════');
|
|
||||||
console.log('📦 ANTHROPIC PROMPT CACHE STATISTICS');
|
|
||||||
console.log('📦 ═══════════════════════════════════════');
|
|
||||||
console.log(`📦 Cache Write: ${cacheWrite.toLocaleString()} tokens ${cacheWrite > 0 ? '(NEW CACHE CREATED ✨)' : ''}`);
|
|
||||||
console.log(`📦 Cache Read: ${cacheRead.toLocaleString()} tokens ${cacheRead > 0 ? '(CACHE HIT 🎯)' : '(CACHE MISS ❌)'}`);
|
|
||||||
console.log(`📦 Regular: ${regular.toLocaleString()} tokens`);
|
|
||||||
console.log(`📦 Total Input: ${total.toLocaleString()} tokens`);
|
|
||||||
console.log(`📦 Output: ${cacheStats.outputTokens.toLocaleString()} tokens`);
|
|
||||||
if (cacheRead > 0) {
|
|
||||||
const costSavings = Math.round(((cacheWrite * 1.25 + regular * 1.0 + cacheRead * 0.1) / (total * 1.0)) * 100);
|
|
||||||
console.log(`📦 💰 Savings: ${savingsPercentage}% tokens, ~${100 - costSavings}% cost`);
|
|
||||||
}
|
|
||||||
console.log('📦 ═══════════════════════════════════════\n');
|
|
||||||
|
|
||||||
global.lastCacheStats = cacheStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
const costResult = calculateCost({
|
|
||||||
inputTokens: regular,
|
|
||||||
outputTokens,
|
|
||||||
cacheWriteTokens: cacheWrite,
|
|
||||||
cacheReadTokens: cacheRead,
|
|
||||||
modelId: fullModelId,
|
|
||||||
});
|
|
||||||
|
|
||||||
usageData = {
|
|
||||||
inputTokens: regular,
|
|
||||||
outputTokens,
|
|
||||||
totalTokens: costResult.totalTokens,
|
|
||||||
cacheWriteTokens: cacheWrite,
|
|
||||||
cacheReadTokens: cacheRead,
|
|
||||||
cacheHit: cacheRead > 0,
|
|
||||||
cacheSavingsPct: savingsPercentage,
|
|
||||||
estimatedCostUsd: costResult.totalCostUsd,
|
|
||||||
modelUsed: fullModelId,
|
|
||||||
provider: isAnthropicModel ? 'anthropic' : 'openai',
|
|
||||||
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
|
|
||||||
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
|
|
||||||
traceId: conversationTraceId,
|
|
||||||
workflowKey: currentContext.workflowKey,
|
|
||||||
workflowNodeId: currentContext.workflowNodeId,
|
|
||||||
mode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let usageData: UsageData | undefined;
|
|
||||||
|
|
||||||
const systemMessageText = systemBlocks.map(b => b.text).join('\n\n');
|
|
||||||
const currentContext = RequestContext.get();
|
|
||||||
|
|
||||||
const chatMetadata = {
|
|
||||||
helperName: helperKey,
|
|
||||||
openTabs,
|
|
||||||
activeTabId,
|
|
||||||
currentView,
|
|
||||||
sessionId: sessionId || `session_${Date.now()}`,
|
|
||||||
agentType: 'orchestrator' as const,
|
|
||||||
traceId: conversationTraceId,
|
|
||||||
mode,
|
|
||||||
modelUsed: fullModelId,
|
|
||||||
systemMessage: systemMessageText,
|
|
||||||
workflowKey: currentContext.workflowKey,
|
|
||||||
workflowNodeId: currentContext.workflowNodeId,
|
|
||||||
get usageData() {
|
|
||||||
return usageData;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await streamText(withChatLogging(streamConfig, chatMetadata, messages));
|
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse();
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
||||||
|
|
||||||
console.error(`❌ [${helperKey}] Route error:`, {
|
|
||||||
error: errorMessage,
|
|
||||||
stack: errorStack,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
helperLogger.logError(helperKey, error instanceof Error ? error : String(error));
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: errorMessage,
|
|
||||||
details: 'Check server logs for full error details'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ sessionId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { sessionId } = await params;
|
|
||||||
const delegation = AgentDelegationService.getBySessionId(sessionId);
|
|
||||||
if (!delegation) {
|
|
||||||
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ delegation });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch delegation:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to fetch delegation' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ sessionId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { sessionId } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const summary: string | undefined = body?.summary;
|
|
||||||
const status: string | undefined = body?.status;
|
|
||||||
|
|
||||||
if (!summary && !status) {
|
|
||||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
? (status as any)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const delegation = summary
|
|
||||||
? AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus ?? 'completed')
|
|
||||||
: AgentDelegationService.markInProgress(sessionId);
|
|
||||||
|
|
||||||
if (!delegation) {
|
|
||||||
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ delegation });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update delegation:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to update delegation' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ sessionId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { sessionId } = await params;
|
|
||||||
const deleted = AgentDelegationService.deleteDelegation(sessionId);
|
|
||||||
if (!deleted) {
|
|
||||||
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete delegation:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to delete delegation' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ sessionId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { sessionId } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const summary: string | undefined = body?.summary;
|
|
||||||
const status: string | undefined = body?.status;
|
|
||||||
|
|
||||||
if (!summary) {
|
|
||||||
return NextResponse.json({ error: 'Summary is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
? (status as any)
|
|
||||||
: 'completed';
|
|
||||||
|
|
||||||
const delegation = AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus);
|
|
||||||
if (!delegation) {
|
|
||||||
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ delegation });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to store delegation summary:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to store delegation summary' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const statusFilter = searchParams.get('status');
|
|
||||||
|
|
||||||
const includeCompleted = searchParams.get('includeCompleted') === 'true'
|
|
||||||
|| statusFilter !== 'active';
|
|
||||||
const delegations = statusFilter === 'active'
|
|
||||||
? AgentDelegationService.listActive({ includeCompleted })
|
|
||||||
: AgentDelegationService.listRecent();
|
|
||||||
|
|
||||||
return NextResponse.json({ delegations });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to list delegations:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to load delegations' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
export const maxDuration = 900;
|
|
||||||
|
|
||||||
class DelegationStreamBroadcaster {
|
|
||||||
private connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
|
||||||
private pendingMessages = new Map<string, any[]>();
|
|
||||||
private encoder = new TextEncoder();
|
|
||||||
|
|
||||||
private encode(message: any) {
|
|
||||||
return `data: ${JSON.stringify({ ...message, timestamp: Date.now() })}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(controller: ReadableStreamDefaultController, encoded: string) {
|
|
||||||
try {
|
|
||||||
controller.enqueue(this.encoder.encode(encoded));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.log('[DelegationStream] Removing dead connection', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addConnection(sessionId: string, controller: ReadableStreamDefaultController) {
|
|
||||||
if (!this.connections.has(sessionId)) {
|
|
||||||
this.connections.set(sessionId, new Set());
|
|
||||||
}
|
|
||||||
this.connections.get(sessionId)!.add(controller);
|
|
||||||
console.log(`[DelegationStream] Connection added for ${sessionId}, total: ${this.connections.get(sessionId)!.size}`);
|
|
||||||
|
|
||||||
const backlog = this.pendingMessages.get(sessionId);
|
|
||||||
if (backlog && backlog.length > 0) {
|
|
||||||
console.log(`[DelegationStream] Flushing ${backlog.length} queued events for ${sessionId}`);
|
|
||||||
for (const message of backlog) {
|
|
||||||
const encoded = this.encode(message);
|
|
||||||
const delivered = this.send(controller, encoded);
|
|
||||||
if (!delivered) {
|
|
||||||
this.removeConnection(sessionId, controller);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((this.connections.get(sessionId)?.size || 0) > 0) {
|
|
||||||
this.pendingMessages.delete(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeConnection(sessionId: string, controller: ReadableStreamDefaultController) {
|
|
||||||
const sessionConns = this.connections.get(sessionId);
|
|
||||||
if (sessionConns) {
|
|
||||||
sessionConns.delete(controller);
|
|
||||||
console.log(`[DelegationStream] Connection removed from ${sessionId}, remaining: ${sessionConns.size}`);
|
|
||||||
if (sessionConns.size === 0) {
|
|
||||||
this.connections.delete(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcast(sessionId: string, message: any) {
|
|
||||||
const sessionConns = this.connections.get(sessionId);
|
|
||||||
if (!sessionConns || sessionConns.size === 0) {
|
|
||||||
const queue = this.pendingMessages.get(sessionId) ?? [];
|
|
||||||
queue.push(message);
|
|
||||||
// Prevent unbounded growth by keeping the latest 200 events
|
|
||||||
if (queue.length > 200) {
|
|
||||||
queue.splice(0, queue.length - 200);
|
|
||||||
}
|
|
||||||
this.pendingMessages.set(sessionId, queue);
|
|
||||||
console.log(`[DelegationStream] Queued event for ${sessionId}, pending=${queue.length}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const encoded = this.encode(message);
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
const staleControllers: ReadableStreamDefaultController[] = [];
|
|
||||||
for (const controller of sessionConns) {
|
|
||||||
if (this.send(controller, encoded)) {
|
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
staleControllers.push(controller);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (staleControllers.length > 0) {
|
|
||||||
staleControllers.forEach((controller) => sessionConns.delete(controller));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[DelegationStream] Broadcasted to ${successCount}/${sessionConns.size} connections for ${sessionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendKeepAlive(sessionId: string) {
|
|
||||||
const sessionConns = this.connections.get(sessionId);
|
|
||||||
if (!sessionConns) return;
|
|
||||||
|
|
||||||
const ping = this.encoder.encode(`: keep-alive\n\n`);
|
|
||||||
|
|
||||||
for (const controller of sessionConns) {
|
|
||||||
try {
|
|
||||||
controller.enqueue(ping);
|
|
||||||
} catch {
|
|
||||||
sessionConns.delete(controller);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var delegationStreamBroadcaster: DelegationStreamBroadcaster | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const delegationStreamBroadcaster =
|
|
||||||
globalThis.delegationStreamBroadcaster ?? new DelegationStreamBroadcaster();
|
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
globalThis.delegationStreamBroadcaster = delegationStreamBroadcaster;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const sessionId = searchParams.get('sessionId');
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return new Response('Missing sessionId', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
|
|
||||||
delegationStreamBroadcaster.addConnection(sessionId, controller);
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'CONNECTION_ESTABLISHED', timestamp: Date.now() })}\n\n`));
|
|
||||||
|
|
||||||
const keepAliveInterval = setInterval(() => {
|
|
||||||
delegationStreamBroadcaster.sendKeepAlive(sessionId);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearInterval(keepAliveInterval);
|
|
||||||
delegationStreamBroadcaster.removeConnection(sessionId, controller);
|
|
||||||
state.cleanup = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const abortHandler = () => {
|
|
||||||
cleanup();
|
|
||||||
request.signal.removeEventListener('abort', abortHandler);
|
|
||||||
state.abortHandler = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
request.signal.addEventListener('abort', abortHandler);
|
|
||||||
|
|
||||||
state.cleanup = cleanup;
|
|
||||||
state.abortHandler = abortHandler;
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
console.log(`[DelegationStream] Stream cancelled for ${sessionId}`);
|
|
||||||
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
|
|
||||||
if (state.abortHandler) {
|
|
||||||
request.signal.removeEventListener('abort', state.abortHandler);
|
|
||||||
state.abortHandler = undefined;
|
|
||||||
}
|
|
||||||
if (state.cleanup) {
|
|
||||||
state.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const sessionId = request.nextUrl.searchParams.get('sessionId');
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return Response.json({ inputTokens: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sqlite = getSQLiteClient();
|
|
||||||
const row = sqlite.prepare(`
|
|
||||||
SELECT json_extract(metadata, '$.input_tokens') as input_tokens
|
|
||||||
FROM chats
|
|
||||||
WHERE thread_id LIKE ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`).get(`%${sessionId}%`) as { input_tokens: number | null } | undefined;
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
inputTokens: row?.input_tokens ?? 0
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Usage fetch error:', error);
|
|
||||||
return Response.json({ inputTokens: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview-2024-12-17';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authHeader = request.headers.get('authorization');
|
|
||||||
if (!authHeader) {
|
|
||||||
return NextResponse.json({ error: 'Missing authorization header' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey =
|
|
||||||
process.env.RAH_REALTIME_OPENAI_API_KEY ||
|
|
||||||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
|
|
||||||
process.env.OPENAI_API_KEY;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
return NextResponse.json({ error: 'Realtime OpenAI API key is not configured' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('https://api.openai.com/v1/realtime/sessions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: REALTIME_MODEL,
|
|
||||||
modalities: ['text'],
|
|
||||||
instructions: 'Provide high-accuracy streaming transcription only. Never speak responses.',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorPayload = await response.json().catch(() => null);
|
|
||||||
const message = errorPayload?.error?.message || response.statusText || 'Failed to create realtime session';
|
|
||||||
return NextResponse.json({ error: message }, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json({
|
|
||||||
client_secret: data.client_secret,
|
|
||||||
expires_at: data.expires_at,
|
|
||||||
model: data.model ?? REALTIME_MODEL,
|
|
||||||
voice: data.voice,
|
|
||||||
id: data.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error('[realtime] Failed to mint ephemeral token:', message);
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { recordVoiceUsage } from '@/services/voice/usageLogger';
|
|
||||||
|
|
||||||
const OPENAI_TTS_MODEL = process.env.RAH_TTS_MODEL || 'gpt-4o-mini-tts';
|
|
||||||
const DEFAULT_TTS_VOICE = process.env.RAH_TTS_VOICE || 'ash';
|
|
||||||
const DEFAULT_TTS_COST_PER_1K_CHAR_USD = 0.015;
|
|
||||||
const TTS_COST_PER_1K_CHAR_USD = (() => {
|
|
||||||
const raw = process.env.RAH_TTS_COST_PER_1K_CHAR_USD;
|
|
||||||
if (!raw) return DEFAULT_TTS_COST_PER_1K_CHAR_USD;
|
|
||||||
const parsed = Number(raw);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTS_COST_PER_1K_CHAR_USD;
|
|
||||||
})();
|
|
||||||
|
|
||||||
function estimateTtsCost(charCount: number) {
|
|
||||||
const cost = (charCount / 1000) * TTS_COST_PER_1K_CHAR_USD;
|
|
||||||
return Number.isFinite(cost) ? parseFloat(cost.toFixed(6)) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const apiKey = process.env.RAH_VOICE_OPENAI_API_KEY || process.env.RAH_DELEGATE_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return NextResponse.json({ error: 'OpenAI API key is not configured' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const text = typeof body?.text === 'string' ? body.text.trim() : '';
|
|
||||||
const voice = typeof body?.voice === 'string' && body.voice.trim().length > 0 ? body.voice.trim() : DEFAULT_TTS_VOICE;
|
|
||||||
const helperName = typeof body?.helper === 'string' && body.helper.trim().length > 0 ? body.helper.trim() : null;
|
|
||||||
const sessionId = typeof body?.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : null;
|
|
||||||
const messageId = typeof body?.messageId === 'string' && body.messageId.trim().length > 0 ? body.messageId.trim() : null;
|
|
||||||
const providedRequestId = typeof body?.requestId === 'string' && body.requestId.trim().length > 0 ? body.requestId.trim() : null;
|
|
||||||
const voiceRequestId = providedRequestId || randomUUID();
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
return NextResponse.json({ error: 'Text is required for TTS' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestStartedAt = Date.now();
|
|
||||||
const response = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OPENAI_TTS_MODEL,
|
|
||||||
voice,
|
|
||||||
input: text,
|
|
||||||
format: 'mp3',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
const errorPayload = await response.json().catch(() => null);
|
|
||||||
const message = errorPayload?.error?.message || response.statusText || 'Failed to synthesize audio';
|
|
||||||
return NextResponse.json({ error: message }, { status: response.status || 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationMs = Date.now() - requestStartedAt;
|
|
||||||
const charCount = [...text].length;
|
|
||||||
const estimatedCostUsd = estimateTtsCost(charCount);
|
|
||||||
const textPreview =
|
|
||||||
text.length > 240 ? `${text.slice(0, 237)}...` : text;
|
|
||||||
|
|
||||||
try {
|
|
||||||
recordVoiceUsage({
|
|
||||||
sessionId,
|
|
||||||
helperName,
|
|
||||||
requestId: voiceRequestId,
|
|
||||||
messageId,
|
|
||||||
voice,
|
|
||||||
model: OPENAI_TTS_MODEL,
|
|
||||||
charCount,
|
|
||||||
costUsd: estimatedCostUsd,
|
|
||||||
durationMs,
|
|
||||||
textPreview,
|
|
||||||
});
|
|
||||||
} catch (loggingError) {
|
|
||||||
console.error('[voice/tts] failed to record usage', loggingError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'audio/mpeg');
|
|
||||||
headers.set('Cache-Control', 'no-cache');
|
|
||||||
headers.set('X-Voice-Request-Id', voiceRequestId);
|
|
||||||
|
|
||||||
return new Response(response.body, {
|
|
||||||
status: 200,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error('[voice/tts] failed to synthesize:', message);
|
|
||||||
return NextResponse.json({ error: message }, { 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 { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
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)'}`;
|
|
||||||
|
|
||||||
// Create delegation
|
|
||||||
const delegation = AgentDelegationService.createDelegation({
|
|
||||||
task,
|
|
||||||
context: contextLines,
|
|
||||||
expectedOutcome: workflow.expectedOutcome,
|
|
||||||
agentType: 'workflow',
|
|
||||||
supabaseToken: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire-and-forget execution
|
|
||||||
void WorkflowExecutor.execute({
|
|
||||||
sessionId: delegation.sessionId,
|
|
||||||
task,
|
|
||||||
context: contextLines,
|
|
||||||
expectedOutcome: workflow.expectedOutcome,
|
|
||||||
workflowKey,
|
|
||||||
workflowNodeId: nodeId,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('[/api/workflows/execute] Execution failed:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
delegationId: delegation.sessionId,
|
|
||||||
workflowKey,
|
|
||||||
nodeId: nodeId || null,
|
|
||||||
status: 'executing',
|
|
||||||
message: `Workflow '${workflow.displayName}' started`,
|
|
||||||
});
|
|
||||||
} 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -253,45 +253,6 @@ const searchEmbeddingsOutputSchema = {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// rah_extract_url schemas
|
|
||||||
const extractUrlInputSchema = {
|
|
||||||
url: z.string().url().describe('URL of the webpage to extract content from')
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractUrlOutputSchema = {
|
|
||||||
success: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
|
||||||
};
|
|
||||||
|
|
||||||
// rah_extract_youtube schemas
|
|
||||||
const extractYoutubeInputSchema = {
|
|
||||||
url: z.string().describe('YouTube video URL to extract transcript from')
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractYoutubeOutputSchema = {
|
|
||||||
success: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
channel: z.string(),
|
|
||||||
transcript: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
|
||||||
};
|
|
||||||
|
|
||||||
// rah_extract_pdf schemas
|
|
||||||
const extractPdfInputSchema = {
|
|
||||||
url: z.string().url().describe('URL of the PDF file to extract content from')
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractPdfOutputSchema = {
|
|
||||||
success: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
|
||||||
};
|
|
||||||
|
|
||||||
async function resolveBaseUrl() {
|
async function resolveBaseUrl() {
|
||||||
try {
|
try {
|
||||||
const value = await baseUrlResolver();
|
const value = await baseUrlResolver();
|
||||||
@@ -722,90 +683,6 @@ mcpServer.registerTool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
mcpServer.registerTool(
|
|
||||||
'rah_extract_url',
|
|
||||||
{
|
|
||||||
title: 'Extract URL content',
|
|
||||||
description: 'Extract content from a webpage URL. Returns title, content, and metadata for creating nodes.',
|
|
||||||
inputSchema: extractUrlInputSchema,
|
|
||||||
outputSchema: extractUrlOutputSchema
|
|
||||||
},
|
|
||||||
async ({ url }) => {
|
|
||||||
const result = await callRaHApi('/api/extract/url', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = `Extracted content from: ${result.title || 'webpage'}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: summary }],
|
|
||||||
structuredContent: {
|
|
||||||
success: true,
|
|
||||||
title: result.title || 'Untitled',
|
|
||||||
content: result.content || '',
|
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
mcpServer.registerTool(
|
|
||||||
'rah_extract_youtube',
|
|
||||||
{
|
|
||||||
title: 'Extract YouTube transcript',
|
|
||||||
description: 'Extract transcript from a YouTube video. Returns title, channel, transcript, and metadata.',
|
|
||||||
inputSchema: extractYoutubeInputSchema,
|
|
||||||
outputSchema: extractYoutubeOutputSchema
|
|
||||||
},
|
|
||||||
async ({ url }) => {
|
|
||||||
const result = await callRaHApi('/api/extract/youtube', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = `Extracted transcript from: ${result.title || 'YouTube video'}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: summary }],
|
|
||||||
structuredContent: {
|
|
||||||
success: true,
|
|
||||||
title: result.title || 'Untitled',
|
|
||||||
channel: result.channel || 'Unknown',
|
|
||||||
transcript: result.transcript || '',
|
|
||||||
metadata: result.metadata || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
mcpServer.registerTool(
|
|
||||||
'rah_extract_pdf',
|
|
||||||
{
|
|
||||||
title: 'Extract PDF content',
|
|
||||||
description: 'Extract content from a PDF file URL. Returns title, content, and metadata for creating nodes.',
|
|
||||||
inputSchema: extractPdfInputSchema,
|
|
||||||
outputSchema: extractPdfOutputSchema
|
|
||||||
},
|
|
||||||
async ({ url }) => {
|
|
||||||
const result = await callRaHApi('/api/extract/pdf', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = `Extracted content from: ${result.title || 'PDF document'}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: summary }],
|
|
||||||
structuredContent: {
|
|
||||||
success: true,
|
|
||||||
title: result.title || 'Untitled PDF',
|
|
||||||
content: result.content || '',
|
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function readRequestBody(req) {
|
async function readRequestBody(req) {
|
||||||
if (req.method !== 'POST') return undefined;
|
if (req.method !== 'POST') return undefined;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -202,45 +202,6 @@ const searchEmbeddingsOutputSchema = {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// rah_extract_url schemas
|
|
||||||
const extractUrlInputSchema = {
|
|
||||||
url: z.string().url().describe('URL of the webpage to extract content from')
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractUrlOutputSchema = {
|
|
||||||
success: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
|
||||||
};
|
|
||||||
|
|
||||||
// rah_extract_youtube schemas
|
|
||||||
const extractYoutubeInputSchema = {
|
|
||||||
url: z.string().describe('YouTube video URL to extract transcript from')
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractYoutubeOutputSchema = {
|
|
||||||
success: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
channel: z.string(),
|
|
||||||
transcript: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
|
||||||
};
|
|
||||||
|
|
||||||
// rah_extract_pdf schemas
|
|
||||||
const extractPdfInputSchema = {
|
|
||||||
url: z.string().url().describe('URL of the PDF file to extract content from')
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractPdfOutputSchema = {
|
|
||||||
success: z.boolean(),
|
|
||||||
title: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
|
||||||
};
|
|
||||||
|
|
||||||
const server = new McpServer(serverInfo, { instructions });
|
const server = new McpServer(serverInfo, { instructions });
|
||||||
|
|
||||||
function logError(...args) {
|
function logError(...args) {
|
||||||
@@ -694,90 +655,6 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'rah_extract_url',
|
|
||||||
{
|
|
||||||
title: 'Extract URL content',
|
|
||||||
description: 'Extract content from a webpage URL. Returns title, content, and metadata for creating nodes.',
|
|
||||||
inputSchema: extractUrlInputSchema,
|
|
||||||
outputSchema: extractUrlOutputSchema
|
|
||||||
},
|
|
||||||
async ({ url }) => {
|
|
||||||
const result = await callRaHApi('/api/extract/url', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = `Extracted content from: ${result.title || 'webpage'}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: summary }],
|
|
||||||
structuredContent: {
|
|
||||||
success: true,
|
|
||||||
title: result.title || 'Untitled',
|
|
||||||
content: result.content || '',
|
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'rah_extract_youtube',
|
|
||||||
{
|
|
||||||
title: 'Extract YouTube transcript',
|
|
||||||
description: 'Extract transcript from a YouTube video. Returns title, channel, transcript, and metadata.',
|
|
||||||
inputSchema: extractYoutubeInputSchema,
|
|
||||||
outputSchema: extractYoutubeOutputSchema
|
|
||||||
},
|
|
||||||
async ({ url }) => {
|
|
||||||
const result = await callRaHApi('/api/extract/youtube', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = `Extracted transcript from: ${result.title || 'YouTube video'}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: summary }],
|
|
||||||
structuredContent: {
|
|
||||||
success: true,
|
|
||||||
title: result.title || 'Untitled',
|
|
||||||
channel: result.channel || 'Unknown',
|
|
||||||
transcript: result.transcript || '',
|
|
||||||
metadata: result.metadata || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'rah_extract_pdf',
|
|
||||||
{
|
|
||||||
title: 'Extract PDF content',
|
|
||||||
description: 'Extract content from a PDF file URL. Returns title, content, and metadata for creating nodes.',
|
|
||||||
inputSchema: extractPdfInputSchema,
|
|
||||||
outputSchema: extractPdfOutputSchema
|
|
||||||
},
|
|
||||||
async ({ url }) => {
|
|
||||||
const result = await callRaHApi('/api/extract/pdf', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
const summary = `Extracted content from: ${result.title || 'PDF document'}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: summary }],
|
|
||||||
structuredContent: {
|
|
||||||
success: true,
|
|
||||||
title: result.title || 'Untitled PDF',
|
|
||||||
content: result.content || '',
|
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|||||||
+52
-36
@@ -1,66 +1,82 @@
|
|||||||
# RA-H Overview
|
# RA-H Light Overview
|
||||||
|
|
||||||
## What is RA-H?
|
## What is RA-H Light?
|
||||||
|
|
||||||
RA-H is an AI-powered knowledge management system designed for researchers, thinkers, and anyone who works with ideas. It learns how you think and helps connect ideas across your knowledge base.
|
RA-H Light is a minimal knowledge graph UI with MCP server integration. It provides a local-first knowledge management system designed to be extended by external AI agents via the Model Context Protocol.
|
||||||
|
|
||||||
**Website:** [ra-h.app](https://ra-h.app)
|
|
||||||
**Open Source:** [github.com/bradwmorris/ra-h_os](https://github.com/bradwmorris/ra-h_os)
|
**Open Source:** [github.com/bradwmorris/ra-h_os](https://github.com/bradwmorris/ra-h_os)
|
||||||
|
|
||||||
## Design Philosophy
|
## Design Philosophy
|
||||||
|
|
||||||
**Non-prescriptive & emergent** — The system doesn't force you into folders or predefined categories. Organization emerges naturally from your actual content. The structure adapts to how you think, not the other way around.
|
**Local-first** — Your knowledge network belongs to you. Everything runs locally in a SQLite database you control.
|
||||||
|
|
||||||
**Everything is connected** — Every piece of knowledge can potentially connect to any other. Connections aren't just links — they carry context, explanation, and meaning.
|
**Agent-agnostic** — No built-in AI chat. Instead, RA-H Light exposes an MCP server that any AI agent (Claude Code, custom agents) can connect to.
|
||||||
|
|
||||||
**Local-first** — Your knowledge network belongs to you, not a platform. Your thinking, research, and connections all belong to you in a portable format you control.
|
**Simple & focused** — 2-panel UI for browsing and editing your knowledge graph. No bloat.
|
||||||
|
|
||||||
**Human + AI** — You guide, AI assists. Create custom workflows. Always in control of your knowledge.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
|
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
|
||||||
- **Database:** SQLite + sqlite-vec (vector search)
|
- **Database:** SQLite + sqlite-vec (vector search)
|
||||||
- **AI Models:** Anthropic Claude + OpenAI GPT via Vercel AI SDK
|
- **Embeddings:** OpenAI (BYO API key)
|
||||||
- **Desktop:** Tauri (Mac app)
|
|
||||||
- **MCP Server:** Local connector for Claude Code and external agents
|
- **MCP Server:** Local connector for Claude Code and external agents
|
||||||
|
|
||||||
## Current Status
|
## What's Included
|
||||||
|
|
||||||
- **Version:** v0.1.21 (January 2026)
|
- 2-panel UI (nodes list + focus panel)
|
||||||
- **Platforms:**
|
- Node/Edge/Dimension CRUD
|
||||||
- Mac app (download at [ra-h.app/download](https://ra-h.app/download))
|
- Full-text and semantic search
|
||||||
- Open source self-hosted (BYO API keys)
|
- MCP server with 11 tools
|
||||||
- **License:** MIT (open source version)
|
- Workflows system
|
||||||
|
- PDF extraction
|
||||||
|
- Graph visualization (Map view)
|
||||||
|
- BYO API keys
|
||||||
|
|
||||||
## Two Ways to Use RA-H
|
## What's NOT Included
|
||||||
|
|
||||||
| Version | Best For | Get It |
|
- Chat interface (use external agents via MCP)
|
||||||
|---------|----------|--------|
|
- Voice features
|
||||||
| **Mac App** | Most users. One-click install, auto-updates, optional subscription features | [ra-h.app/download](https://ra-h.app/download) |
|
- Built-in AI agents
|
||||||
| **Open Source** | Developers, self-hosters, contributors. BYO API keys, full control | [GitHub](https://github.com/bradwmorris/ra-h_os) |
|
- Auth/subscription system
|
||||||
|
- Desktop packaging
|
||||||
|
|
||||||
Both versions use the same core codebase. The Mac app adds packaging, auth, and subscription features. The open source version is fully functional with your own API keys.
|
## Two-Panel Layout
|
||||||
|
|
||||||
## Key Features
|
```
|
||||||
|
┌─────────────┬─────────────────────────┐
|
||||||
|
│ NODES │ FOCUS │
|
||||||
|
│ Panel │ Panel │
|
||||||
|
│ │ │
|
||||||
|
│ • Search │ • Node content │
|
||||||
|
│ • Filters │ • Connections │
|
||||||
|
│ • List │ • Dimensions │
|
||||||
|
│ │ │
|
||||||
|
└─────────────┴─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
- **3-panel interface:** Nodes list, Focus view, Helpers panel
|
## MCP Integration
|
||||||
- **AI agents:** Orchestrator for chat, workflows for deep analysis
|
|
||||||
- **Graph database:** Nodes and edges with semantic search
|
RA-H Light is designed to be the knowledge backend for your AI workflows:
|
||||||
- **MCP server:** Connect Claude Code and other external agents
|
|
||||||
- **Workflows:** Code-first automation (Integrate, custom workflows)
|
```json
|
||||||
- **Extraction tools:** YouTube, websites, PDFs
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ra-h": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/ra-h_os/apps/mcp-server/stdio-server.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tools: `rah_add_node`, `rah_search_nodes`, `rah_update_node`, `rah_get_nodes`, `rah_create_edge`, `rah_query_edges`, `rah_update_edge`, `rah_create_dimension`, `rah_update_dimension`, `rah_delete_dimension`, `rah_search_embeddings`
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Doc | Description |
|
| Doc | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| [Architecture](./1_architecture.md) | Agent hierarchy, system design |
|
|
||||||
| [Schema](./2_schema.md) | Database schema, node/edge structure |
|
| [Schema](./2_schema.md) | Database schema, node/edge structure |
|
||||||
| [Context & Memory](./3_context-and-memory.md) | How context flows through the system |
|
| [Tools & Workflows](./4_tools-and-workflows.md) | Available MCP tools, workflow system |
|
||||||
| [Tools & Workflows](./4_tools-and-workflows.md) | Available tools, workflow system |
|
|
||||||
| [Logging & Evals](./5_logging-and-evals.md) | Debugging, evaluation framework |
|
|
||||||
| [UI](./6_ui.md) | Component structure, panels, views |
|
| [UI](./6_ui.md) | Component structure, panels, views |
|
||||||
| [Voice](./7_voice.md) | Voice interface (STT/TTS) |
|
|
||||||
| [MCP](./8_mcp.md) | External agent connector setup |
|
| [MCP](./8_mcp.md) | External agent connector setup |
|
||||||
| [Open Source](./9_open-source.md) | Sync strategy between repos |
|
| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
# System Architecture
|
|
||||||
|
|
||||||
> How RA-H's AI agents work together to manage your knowledge.
|
|
||||||
|
|
||||||
**How it works:** RA-H has three AI agents: orchestrators (ra-h/ra-h-easy) handle your conversations and delegate work, wise-rah executes multi-step workflows, and mini-rah handles background tasks. All agents can read your knowledge graph; only some can write.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
RA-H uses a multi-agent architecture with three specialized AI agents that collaborate to manage your knowledge base. The system is built around **nodes** (knowledge items), **edges** (relationships), and **dimensions** (categories).
|
|
||||||
|
|
||||||
## Core Concepts
|
|
||||||
|
|
||||||
### Nodes
|
|
||||||
Knowledge items stored in the database (papers, ideas, people, projects, videos, tweets, etc). Each node has:
|
|
||||||
- **Title** and **content**
|
|
||||||
- **Dimensions** (multi-tag categorization)
|
|
||||||
- **Metadata** (structured JSON)
|
|
||||||
- **Embeddings** (for semantic search)
|
|
||||||
- **Links** (for external sources)
|
|
||||||
|
|
||||||
### Edges
|
|
||||||
Directed relationships between nodes. Edges capture how nodes connect ("relates to", "inspired by", etc).
|
|
||||||
|
|
||||||
### Dimensions
|
|
||||||
Multi-select categorization tags. Nodes can have multiple dimensions. Some dimensions can be marked as "priority" for focused context.
|
|
||||||
|
|
||||||
## Agent Architecture
|
|
||||||
|
|
||||||
### Orchestrator Agents (Easy/Hard Mode)
|
|
||||||
|
|
||||||
**ra-h-easy (Easy Mode - Default)**
|
|
||||||
- **Model:** GPT-5 Mini (`openai/gpt-5-mini`)
|
|
||||||
- **Purpose:** Fast, low-latency orchestration for everyday tasks
|
|
||||||
- **Caching:** OpenAI implicit caching
|
|
||||||
- **Reasoning:** `reasoning_effort: light` for speed
|
|
||||||
|
|
||||||
**ra-h (Hard Mode)**
|
|
||||||
- **Model:** Claude Sonnet 4.5 (`anthropic/claude-sonnet-4.5`)
|
|
||||||
- **Purpose:** Deep reasoning for complex tasks
|
|
||||||
- **Caching:** Anthropic explicit prompt caching
|
|
||||||
- **Reasoning:** Stronger analytical capabilities
|
|
||||||
|
|
||||||
**Tools Available:**
|
|
||||||
- `queryNodes`, `queryEdge`, `searchContentEmbeddings`
|
|
||||||
- `webSearch`, `think`
|
|
||||||
- `executeWorkflow` (delegates to wise-rah)
|
|
||||||
- `createNode`, `updateNode`, `createEdge`, `updateEdge`
|
|
||||||
- `youtubeExtract`, `websiteExtract`, `paperExtract`
|
|
||||||
|
|
||||||
**Mode Switching:**
|
|
||||||
Users toggle via UI (⚡ Easy / 🔥 Hard). Choice persists in localStorage. **Seamless mid-conversation switching** - context maintained across mode changes.
|
|
||||||
|
|
||||||
### Wise RA-H (Workflow Executor)
|
|
||||||
|
|
||||||
**wise-rah**
|
|
||||||
- **Model:** GPT-5 (`openai/gpt-5`)
|
|
||||||
- **Purpose:** Executes predefined workflows (integrate, deep analysis)
|
|
||||||
- **Direct write access:** Calls `updateNode` directly (no delegation)
|
|
||||||
- **Context isolation:** Returns summaries only to orchestrator
|
|
||||||
|
|
||||||
**Tools Available:**
|
|
||||||
- `queryNodes`, `getNodesById`, `queryEdge`, `searchContentEmbeddings`
|
|
||||||
- `webSearch`, `think`
|
|
||||||
- `updateNode` (append-only, enforced at tool level)
|
|
||||||
|
|
||||||
**Key Workflows:**
|
|
||||||
- **Integrate:** Database-wide connection discovery (5-step: plan → ground → search → contextualize → append)
|
|
||||||
|
|
||||||
### Mini RA-H (Delegate Workers)
|
|
||||||
|
|
||||||
**mini-rah**
|
|
||||||
- **Model:** GPT-4o Mini (`openai/gpt-4o-mini`)
|
|
||||||
- **Purpose:** Spawned for write operations, extraction, batch tasks
|
|
||||||
- **Execution:** Isolated context, returns summaries only
|
|
||||||
|
|
||||||
**Tools Available:**
|
|
||||||
- All read tools + `createNode`, `updateNode`, `createEdge`, `updateEdge`
|
|
||||||
- Extraction tools (`youtubeExtract`, `websiteExtract`, `paperExtract`)
|
|
||||||
|
|
||||||
## Prompt Caching
|
|
||||||
|
|
||||||
**Anthropic (Claude):**
|
|
||||||
- Explicit cache control blocks in system prompts
|
|
||||||
- Caches tool definitions, workflows, base context
|
|
||||||
|
|
||||||
**OpenAI (GPT-5/4o):**
|
|
||||||
- Implicit caching based on prefix matching
|
|
||||||
- Optimized prompts for cache reuse
|
|
||||||
- `reasoning_effort` parameter for speed/quality tradeoff
|
|
||||||
|
|
||||||
## Context Hygiene
|
|
||||||
|
|
||||||
**Orchestrator:**
|
|
||||||
- Maintains full conversation history
|
|
||||||
- Sees auto-context (top 10 connected nodes) + focused node
|
|
||||||
- Delegates isolation ensures clean context
|
|
||||||
|
|
||||||
**Workers (wise-rah/mini-rah):**
|
|
||||||
- Execute in isolated sessions
|
|
||||||
- Return structured summaries only
|
|
||||||
- Do NOT pollute orchestrator context with tool execution details
|
|
||||||
|
|
||||||
## UI Integration
|
|
||||||
|
|
||||||
Users interact with a single interface that automatically routes requests to the appropriate agent based on:
|
|
||||||
- **Mode selection** (Easy/Hard)
|
|
||||||
- **Workflow triggers** (executeWorkflow → wise-rah)
|
|
||||||
- **Delegation needs** (mini-rah spawned in background)
|
|
||||||
|
|
||||||
Orchestrators see **auto-context** (your 10 most-connected nodes) plus the **focused node** for consistent knowledge access. See [Context & Memory](./3_context-and-memory.md) for details.
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# Context & Memory
|
|
||||||
|
|
||||||
> How RA-H decides what information to show the AI during conversations.
|
|
||||||
|
|
||||||
**How it works:** RA-H automatically identifies your 10 most-connected knowledge nodes (by edge count) and shares their titles with the AI as background context. When you focus on a specific node, the AI also sees a preview of that content. This means the AI always knows about your most important ideas without you having to manually select them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context System Overview
|
|
||||||
|
|
||||||
Every conversation includes context assembled by the **context builder**. This context tells the AI about your knowledge base, available tools, and what you're currently working on.
|
|
||||||
|
|
||||||
### What Gets Included
|
|
||||||
|
|
||||||
| Block | Contents | Cached? |
|
|
||||||
|-------|----------|---------|
|
|
||||||
| **Base Context** | How nodes, edges, dimensions work; formatting rules | ✅ Yes |
|
|
||||||
| **Agent Instructions** | Role-specific prompts (ra-h, ra-h-easy, wise-rah, mini-rah) | ✅ Yes |
|
|
||||||
| **Tool Definitions** | Available tools and their parameters | ✅ Yes |
|
|
||||||
| **Workflow Definitions** | Available workflows (orchestrators only) | ✅ Yes |
|
|
||||||
| **Background Context** | Top 10 most-connected nodes (if enabled) | ✅ Yes |
|
|
||||||
| **Focused Nodes** | Currently open node(s) with content previews | ❌ No |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Auto-Context System
|
|
||||||
|
|
||||||
Auto-context automatically includes your most important knowledge in every conversation. It replaces the old manual "pinning" system.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **Toggle:** Enable in Settings → Context tab
|
|
||||||
2. **Query:** Finds top 10 nodes by edge count (most connections = most important)
|
|
||||||
3. **Format:** Shows `[NODE:id:"title"] (edges: X)` for each hub node
|
|
||||||
4. **Agent behavior:** Agents see titles only; they call `queryNodes` or `getNodesById` when they need full content
|
|
||||||
|
|
||||||
### The Query
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT n.id, n.title, COUNT(DISTINCT e.id) AS edge_count
|
|
||||||
FROM nodes n
|
|
||||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
|
||||||
WHERE n.type IS NULL OR n.type != 'memory'
|
|
||||||
GROUP BY n.id
|
|
||||||
ORDER BY edge_count DESC, n.updated_at DESC
|
|
||||||
LIMIT 10
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tie-breaking:** When nodes have equal edge counts, most recently updated wins.
|
|
||||||
|
|
||||||
### Settings Storage
|
|
||||||
|
|
||||||
**Location:** `~/Library/Application Support/RA-H/config/settings.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"autoContextEnabled": true,
|
|
||||||
"lastPinnedMigration": "2025-12-09T00:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Legacy migration:** If you had pinned nodes before the auto-context update, the system automatically enabled auto-context on first run.
|
|
||||||
|
|
||||||
### Context Block Format
|
|
||||||
|
|
||||||
When enabled, agents see this in their system prompt:
|
|
||||||
|
|
||||||
```
|
|
||||||
=== BACKGROUND CONTEXT ===
|
|
||||||
Top 10 most-connected nodes (important knowledge hubs). Use queryNodes/getNodesById if relevant.
|
|
||||||
|
|
||||||
[NODE:1573:"building ra-h - knowledge management system"] (edges: 47)
|
|
||||||
[NODE:4436:"Continual learning explains some interesting phenomena"] (edges: 32)
|
|
||||||
[NODE:3014:"Multi-Agent Research Systems: Insights from Simon Willison"] (edges: 28)
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Focused Nodes
|
|
||||||
|
|
||||||
Focused nodes are the node(s) you currently have open in the Focus panel.
|
|
||||||
|
|
||||||
### What Agents See
|
|
||||||
|
|
||||||
- **Primary focused node:** The active tab
|
|
||||||
- **Additional focused nodes:** Other open tabs
|
|
||||||
- **Content preview:** First ~25 words
|
|
||||||
- **Metadata:** Title, ID, link, dimensions, chunk status
|
|
||||||
|
|
||||||
### Example Format
|
|
||||||
|
|
||||||
```
|
|
||||||
=== FOCUSED NODES ===
|
|
||||||
|
|
||||||
### Primary: [NODE:4523:"How RAG systems work"]
|
|
||||||
Preview: Retrieval-augmented generation (RAG) combines information retrieval with language model generation to produce more accurate and grounded responses...
|
|
||||||
Link: https://example.com/rag-systems
|
|
||||||
Dimensions: research, ai, papers
|
|
||||||
Chunk status: chunked (embeddings available)
|
|
||||||
|
|
||||||
### Also Open:
|
|
||||||
- [NODE:4520:"Vector databases explained"] (25 words preview...)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context Caching
|
|
||||||
|
|
||||||
RA-H uses provider-specific caching to reduce costs and latency.
|
|
||||||
|
|
||||||
### Anthropic (Claude)
|
|
||||||
|
|
||||||
- **Explicit cache control:** Blocks marked with `cache_control: { type: 'ephemeral' }`
|
|
||||||
- **What's cached:** Base context, instructions, tools, workflows, background context
|
|
||||||
- **What's NOT cached:** Focused nodes (change too frequently)
|
|
||||||
|
|
||||||
### OpenAI (GPT)
|
|
||||||
|
|
||||||
- **Implicit caching:** Based on prefix matching
|
|
||||||
- **Same structure:** Identical blocks, just no explicit markers
|
|
||||||
- **Optimization:** Prompts structured for maximum cache reuse
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent-Specific Context
|
|
||||||
|
|
||||||
Different agents receive different context based on their role:
|
|
||||||
|
|
||||||
| Agent | Background Context | Workflows | Tools |
|
|
||||||
|-------|-------------------|-----------|-------|
|
|
||||||
| **ra-h / ra-h-easy** (orchestrators) | ✅ Yes | ✅ Yes | All |
|
|
||||||
| **wise-rah** (workflow executor) | ❌ No | ❌ No | Planner tools only |
|
|
||||||
| **mini-rah** (worker) | ❌ No | ❌ No | Executor tools only |
|
|
||||||
|
|
||||||
**Why orchestrators only:** Background context helps with general conversation. Workers execute specific tasks and don't need the full picture.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/services/helpers/contextBuilder.ts` | Assembles system prompts with caching |
|
|
||||||
| `src/services/context/autoContext.ts` | Auto-context query and formatting |
|
|
||||||
| `src/services/settings/autoContextSettings.ts` | Settings read/write helpers |
|
|
||||||
| `src/components/settings/ContextViewer.tsx` | Settings UI for auto-context toggle |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Legacy: Memory System (Removed)
|
|
||||||
|
|
||||||
The automatic memory extraction pipeline has been **removed**. Previously, RA-H would analyze conversations and create "memory" nodes automatically. This system was removed because:
|
|
||||||
|
|
||||||
1. Memory nodes cluttered the database
|
|
||||||
2. Quality was inconsistent
|
|
||||||
3. Users preferred explicit knowledge capture
|
|
||||||
|
|
||||||
**Existing memory nodes:** Still in the database but excluded from auto-context (filtered by `type != 'memory'`).
|
|
||||||
|
|
||||||
**Future approach:** Store long-term knowledge as regular nodes with explicit dimensions rather than automatic extraction.
|
|
||||||
+118
-244
@@ -1,217 +1,132 @@
|
|||||||
# Tools & Workflows
|
# Tools & Workflows
|
||||||
|
|
||||||
> What actions the AI can take and how workflows automate multi-step processes.
|
> MCP tools available for external agents and the workflow execution system.
|
||||||
|
|
||||||
**How it works:** AI agents have access to tools — functions they can call to read data, create nodes, search content, and more. Workflows are pre-written instruction sets that guide the AI through complex multi-step processes like finding connections across your knowledge base.
|
**How it works:** RA-H Light exposes tools via MCP that external AI agents can call to read, create, and update your knowledge graph. Workflows are pre-written instruction sets that can be executed via the `/api/workflows/execute` endpoint.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tools Overview
|
## MCP Tools
|
||||||
|
|
||||||
Tools are organized into three categories:
|
RA-H Light provides 11 MCP tools for external agents:
|
||||||
|
|
||||||
| Category | Purpose | Examples |
|
### Node Operations
|
||||||
|----------|---------|----------|
|
|
||||||
| **Core** | Read-only graph operations | queryNodes, searchContentEmbeddings |
|
| Tool | Description |
|
||||||
| **Orchestration** | Delegation and reasoning | executeWorkflow, think, webSearch |
|
|------|-------------|
|
||||||
| **Execution** | Write operations and extraction | createNode, updateNode, youtubeExtract |
|
| `rah_add_node` | Create a new knowledge node |
|
||||||
|
| `rah_search_nodes` | Search nodes by title, content, or dimensions |
|
||||||
|
| `rah_update_node` | Update an existing node |
|
||||||
|
| `rah_get_nodes` | Get nodes by ID array |
|
||||||
|
|
||||||
|
### Edge Operations
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `rah_create_edge` | Create relationship between nodes |
|
||||||
|
| `rah_query_edges` | Query existing edges |
|
||||||
|
| `rah_update_edge` | Update edge metadata |
|
||||||
|
|
||||||
|
### Dimension Operations
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `rah_create_dimension` | Create a new dimension tag |
|
||||||
|
| `rah_update_dimension` | Update dimension description |
|
||||||
|
| `rah_delete_dimension` | Delete a dimension |
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `rah_search_embeddings` | Semantic search across chunk embeddings |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Tools (All Agents)
|
## Tool Schemas
|
||||||
|
|
||||||
Read-only operations available to all agents:
|
### rah_add_node
|
||||||
|
|
||||||
### queryNodes
|
|
||||||
Search nodes by title, content, or dimensions.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
queryNodes({
|
{
|
||||||
search?: string, // Full-text search
|
title: string, // Required
|
||||||
dimensions?: string[],// Filter by dimensions
|
|
||||||
limit?: number // Max results (default: 20)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### getNodesById
|
|
||||||
Retrieve full node data by ID array.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getNodesById({
|
|
||||||
ids: number[] // Array of node IDs
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### queryEdge
|
|
||||||
Inspect existing edges between nodes.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
queryEdge({
|
|
||||||
from_node_id?: number,
|
|
||||||
to_node_id?: number,
|
|
||||||
limit?: number
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### searchContentEmbeddings
|
|
||||||
Semantic search across chunk embeddings.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
searchContentEmbeddings({
|
|
||||||
query: string, // Search query
|
|
||||||
node_id?: number, // Scope to specific node
|
|
||||||
limit?: number, // Max results
|
|
||||||
threshold?: number // Similarity threshold (0-1)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### queryDimensions
|
|
||||||
Query and filter dimensions.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
queryDimensions({
|
|
||||||
search?: string, // Filter by name
|
|
||||||
isPriority?: boolean, // Filter locked dimensions only
|
|
||||||
limit?: number
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### getDimension
|
|
||||||
Get a single dimension by exact name.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getDimension({
|
|
||||||
name: string // Exact dimension name
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Orchestration Tools (Orchestrators Only)
|
|
||||||
|
|
||||||
Tools for reasoning and delegation:
|
|
||||||
|
|
||||||
### webSearch
|
|
||||||
External web search via Tavily.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
webSearch({
|
|
||||||
query: string,
|
|
||||||
max_results?: number
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### think
|
|
||||||
Internal reasoning/planning (logged to metadata, not shown to user).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
think({
|
|
||||||
thought: string // Reasoning to log
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### executeWorkflow
|
|
||||||
Delegate to wise-rah for predefined workflows.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
executeWorkflow({
|
|
||||||
workflow_key: string // e.g., "integrate"
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Tools (Writers)
|
|
||||||
|
|
||||||
Write operations and content extraction:
|
|
||||||
|
|
||||||
### createNode
|
|
||||||
Create a new knowledge node.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
createNode({
|
|
||||||
title: string,
|
|
||||||
content?: string,
|
content?: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
dimensions?: string[],
|
dimensions?: string[],
|
||||||
link?: string,
|
link?: string,
|
||||||
metadata?: object
|
metadata?: object
|
||||||
})
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### updateNode
|
### rah_search_nodes
|
||||||
Append content to existing nodes. **Append-only** — cannot overwrite.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
updateNode({
|
{
|
||||||
id: number,
|
search?: string, // Full-text search
|
||||||
content: string // Appended to existing content
|
dimensions?: string[],// Filter by dimensions
|
||||||
})
|
limit?: number // Max results (default: 20)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### createEdge
|
### rah_update_node
|
||||||
Create relationship between nodes.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
createEdge({
|
{
|
||||||
|
id: number, // Node ID
|
||||||
|
title?: string,
|
||||||
|
content?: string, // Replaces existing content
|
||||||
|
description?: string,
|
||||||
|
dimensions?: string[],
|
||||||
|
link?: string,
|
||||||
|
metadata?: object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### rah_create_edge
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
from_node_id: number,
|
from_node_id: number,
|
||||||
to_node_id: number,
|
to_node_id: number,
|
||||||
context?: string // Relationship description
|
context?: string // Relationship description
|
||||||
})
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### updateEdge
|
### rah_search_embeddings
|
||||||
Modify edge metadata.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
updateEdge({
|
{
|
||||||
id: number,
|
query: string, // Search query
|
||||||
context?: string,
|
node_id?: number, // Scope to specific node
|
||||||
user_feedback?: number
|
limit?: number, // Max results
|
||||||
})
|
threshold?: number // Similarity threshold (0-1)
|
||||||
```
|
}
|
||||||
|
|
||||||
### Dimension Tools
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
createDimension({ name: string, description?: string })
|
|
||||||
updateDimension({ name: string, description?: string })
|
|
||||||
lockDimension({ name: string }) // Make priority dimension
|
|
||||||
unlockDimension({ name: string }) // Remove priority status
|
|
||||||
deleteDimension({ name: string })
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extraction Tools
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
youtubeExtract({ url: string }) // Extract transcript
|
|
||||||
websiteExtract({ url: string }) // Extract page content
|
|
||||||
paperExtract({ url: string }) // Extract PDF text
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tool Access by Agent
|
## API Routes
|
||||||
|
|
||||||
| Agent | Core | Orchestration | Execution |
|
RA-H Light exposes REST APIs that MCP tools call internally:
|
||||||
|-------|------|---------------|-----------|
|
|
||||||
| **ra-h / ra-h-easy** | ✅ All | webSearch, think, executeWorkflow | ✅ All |
|
| Route | Method | Purpose |
|
||||||
| **wise-rah** | ✅ All | webSearch, think, delegateToMiniRAH | updateNode, createEdge |
|
|-------|--------|---------|
|
||||||
| **mini-rah** | ✅ All | webSearch, think | ✅ All |
|
| `/api/nodes` | GET/POST | List/create nodes |
|
||||||
|
| `/api/nodes/[id]` | GET/PUT/DELETE | Node CRUD |
|
||||||
|
| `/api/nodes/search` | POST | Search nodes |
|
||||||
|
| `/api/edges` | GET/POST | List/create edges |
|
||||||
|
| `/api/edges/[id]` | GET/PUT/DELETE | Edge CRUD |
|
||||||
|
| `/api/dimensions` | GET/POST | List/create dimensions |
|
||||||
|
| `/api/dimensions/search` | GET | Search dimensions |
|
||||||
|
| `/api/workflows/execute` | POST | Execute workflow |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
Workflows are multi-step instruction sets executed by wise-rah.
|
Workflows are pre-written instruction sets stored in `~/Library/Application Support/RA-H/workflows/`.
|
||||||
|
|
||||||
### User-Editable Workflows
|
### Workflow Format
|
||||||
|
|
||||||
**Users can create, edit, and delete workflows** from Settings → Workflows tab.
|
|
||||||
|
|
||||||
**Storage:** `~/Library/Application Support/RA-H/workflows/`
|
|
||||||
|
|
||||||
**Format:** JSON files with this structure:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -224,93 +139,52 @@ Workflows are multi-step instruction sets executed by wise-rah.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### How Workflows Work
|
### Executing Workflows
|
||||||
|
|
||||||
1. User says "run integrate workflow" (or similar)
|
Via API:
|
||||||
2. Orchestrator calls `executeWorkflow({ workflow_key: 'integrate' })`
|
```bash
|
||||||
3. System spawns wise-rah session with workflow instructions
|
curl -X POST http://localhost:3000/api/workflows/execute \
|
||||||
4. wise-rah executes steps autonomously (30-60+ seconds)
|
-H "Content-Type: application/json" \
|
||||||
5. wise-rah returns summary to orchestrator
|
-d '{"workflow_key": "integrate", "focused_node_id": 123}'
|
||||||
6. Orchestrator shows result to user
|
```
|
||||||
|
|
||||||
### Built-in Workflows
|
### Built-in Workflows
|
||||||
|
|
||||||
#### Integrate
|
**Integrate** — Database-wide connection discovery. Finds related nodes and suggests connections.
|
||||||
|
|
||||||
**Key:** `integrate`
|
|
||||||
**Purpose:** Database-wide connection discovery
|
|
||||||
**Cost:** ~$0.18/execution
|
|
||||||
|
|
||||||
**5-Step Process:**
|
|
||||||
|
|
||||||
1. **Plan** — Call `think` to outline approach
|
|
||||||
2. **Ground** — Extract entities (names, projects, concepts) from focused node
|
|
||||||
3. **Search** — Database-wide search using extracted entities
|
|
||||||
4. **Contextualize** — Brief relevance explanation
|
|
||||||
5. **Append** — Call `updateNode` ONCE with Integration Analysis section
|
|
||||||
|
|
||||||
**Output Format:**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Integration Analysis
|
|
||||||
|
|
||||||
[2-3 sentences: what this node is, why it matters]
|
|
||||||
|
|
||||||
**Database Connections:**
|
|
||||||
- [NODE:123:"Title"] — [why relevant]
|
|
||||||
- [NODE:456:"Title"] — [why relevant]
|
|
||||||
...
|
|
||||||
|
|
||||||
**Relevance:** [1-2 sentences connecting to your context]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating Custom Workflows
|
### Creating Custom Workflows
|
||||||
|
|
||||||
1. Go to Settings → Workflows
|
1. Create a JSON file in `~/Library/Application Support/RA-H/workflows/`
|
||||||
2. Click "New Workflow"
|
2. Define key, displayName, description, instructions
|
||||||
3. Fill in:
|
3. Set `enabled: true`
|
||||||
- **Key** — unique identifier (lowercase, no spaces)
|
4. Execute via API
|
||||||
- **Display Name** — shown in UI
|
|
||||||
- **Description** — what it does
|
|
||||||
- **Instructions** — the prompt for wise-rah
|
|
||||||
- **Requires Focused Node** — whether a node must be selected
|
|
||||||
4. Save
|
|
||||||
|
|
||||||
### Workflow Instructions Tips
|
|
||||||
|
|
||||||
- Be explicit about steps and expected output
|
|
||||||
- Reference tools by name (wise-rah has: queryNodes, searchContentEmbeddings, updateNode, createEdge, webSearch, think)
|
|
||||||
- Specify output format precisely
|
|
||||||
- Include guardrails ("call updateNode exactly once")
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tool Registry
|
## Database Tools (Internal)
|
||||||
|
|
||||||
**Location:** `src/tools/infrastructure/registry.ts`
|
These tools are used by the workflow executor and API routes:
|
||||||
|
|
||||||
**Structure:**
|
| Tool | File | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
```typescript
|
| `queryNodes` | `src/tools/database/queryNodes.ts` | Search nodes |
|
||||||
TOOL_SETS = {
|
| `createNode` | `src/tools/database/createNode.ts` | Create node |
|
||||||
core: { queryNodes, getNodesById, queryEdge, queryDimensions, getDimension, searchContentEmbeddings },
|
| `updateNode` | `src/tools/database/updateNode.ts` | Update node |
|
||||||
orchestration: { webSearch, think, delegateToMiniRAH, executeWorkflow, ... },
|
| `deleteNode` | `src/tools/database/deleteNode.ts` | Delete node |
|
||||||
execution: { createNode, updateNode, createEdge, updateEdge, youtubeExtract, websiteExtract, paperExtract, ... }
|
| `getNodesById` | `src/tools/database/getNodesById.ts` | Get by ID |
|
||||||
}
|
| `createEdge` | `src/tools/database/createEdge.ts` | Create edge |
|
||||||
```
|
| `updateEdge` | `src/tools/database/updateEdge.ts` | Update edge |
|
||||||
|
| `queryEdge` | `src/tools/database/queryEdge.ts` | Query edges |
|
||||||
|
| `queryDimensions` | `src/tools/database/queryDimensions.ts` | Query dimensions |
|
||||||
|
| `searchContentEmbeddings` | `src/tools/other/searchContentEmbeddings.ts` | Semantic search |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Design Decisions
|
## Key Files
|
||||||
|
|
||||||
### Append-Only Updates
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
`updateNode` is **append-only** — it cannot overwrite existing content. This prevents AI from accidentally destroying knowledge. The tool-level enforcement means workflows can't bypass this restriction.
|
| `apps/mcp-server/server.js` | HTTP MCP server |
|
||||||
|
| `apps/mcp-server/stdio-server.js` | STDIO MCP server |
|
||||||
### No Workflow Delegation Loops
|
| `src/tools/infrastructure/registry.ts` | Tool registry |
|
||||||
|
| `src/services/agents/workflowExecutor.ts` | Workflow execution |
|
||||||
wise-rah cannot call `executeWorkflow` or delegate to other wise-rah instances. This prevents infinite loops and keeps execution bounded.
|
|
||||||
|
|
||||||
### Isolated Execution
|
|
||||||
|
|
||||||
Workers (wise-rah, mini-rah) execute in isolated sessions. They return structured summaries only — they don't pollute orchestrator context with execution details.
|
|
||||||
|
|||||||
+20
-83
@@ -1,23 +1,23 @@
|
|||||||
# User Interface
|
# User Interface
|
||||||
|
|
||||||
> How to navigate and use RA-H's interface.
|
> How to navigate and use RA-H Light's interface.
|
||||||
|
|
||||||
**How it works:** RA-H uses a 3-panel layout: browse nodes on the left, work with focused content in the middle, and chat with AI on the right. The chat panel is collapsible (Cmd+\\). Settings give you access to workflows, database views, a knowledge map, and more.
|
**How it works:** RA-H Light uses a 2-panel layout: browse nodes on the left, work with focused content on the right. Settings give you access to workflows, database views, a knowledge map, and more.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3-Panel Layout
|
## 2-Panel Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┬─────────────────────────┬─────────────────┐
|
┌─────────────┬─────────────────────────┐
|
||||||
│ NODES │ FOCUS │ HELPERS │
|
│ NODES │ FOCUS │
|
||||||
│ Panel │ Panel │ Panel │
|
│ Panel │ Panel │
|
||||||
│ │ │ │
|
│ │ │
|
||||||
│ • Search │ • Tabbed workspace │ • AI chat │
|
│ • Search │ • Tabbed workspace │
|
||||||
│ • Filters │ • Node content │ • Easy/Hard │
|
│ • Filters │ • Node content │
|
||||||
│ • Folders │ • Connections │ • Delegations │
|
│ • Folders │ • Connections │
|
||||||
│ │ │ │
|
│ │ │
|
||||||
└─────────────┴─────────────────────────┴─────────────────┘
|
└─────────────┴─────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -95,14 +95,14 @@ Save filter + view combinations:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Middle Panel: Focus
|
## Right Panel: Focus
|
||||||
|
|
||||||
Active workspace for the node(s) you're working with.
|
Active workspace for the node(s) you're working with.
|
||||||
|
|
||||||
### Tabbed Interface
|
### Tabbed Interface
|
||||||
|
|
||||||
- **Primary tab** — Main focused node
|
- **Primary tab** — Main focused node
|
||||||
- **Additional tabs** — Related nodes opened from chat
|
- **Additional tabs** — Related nodes opened from links
|
||||||
- **Tab controls** — Close (×), reorder, switch
|
- **Tab controls** — Close (×), reorder, switch
|
||||||
|
|
||||||
### Node Detail View
|
### Node Detail View
|
||||||
@@ -124,54 +124,6 @@ Active workspace for the node(s) you're working with.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Right Panel: Helpers
|
|
||||||
|
|
||||||
AI conversation interface.
|
|
||||||
|
|
||||||
### Chat Interface
|
|
||||||
|
|
||||||
- **Message history** — User + assistant messages
|
|
||||||
- **Tool call visibility** — Collapsed by default, expandable
|
|
||||||
- **Token/cost tracking** — Per-message usage
|
|
||||||
- **Node references** — Auto-linked `[NODE:id:"title"]`
|
|
||||||
|
|
||||||
### Mode Toggle
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐
|
|
||||||
│ ⚡ Easy │ 🔥 Hard │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
| Mode | Model | Use Case |
|
|
||||||
|------|-------|----------|
|
|
||||||
| **Easy** | GPT-5 Mini | Fast, everyday tasks |
|
|
||||||
| **Hard** | Claude Sonnet 4.5 | Deep reasoning, complex analysis |
|
|
||||||
|
|
||||||
Mode persists in localStorage. Switch mid-conversation seamlessly.
|
|
||||||
|
|
||||||
### Collapsible Panel
|
|
||||||
|
|
||||||
- **Toggle:** Cmd+\\ (Mac) / Ctrl+\\ (Windows)
|
|
||||||
- **Collapsed state:** 48px rail with expand button
|
|
||||||
- **State persists:** Remembers your preference
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Add
|
|
||||||
|
|
||||||
Bottom of the Helpers panel. Three modes:
|
|
||||||
|
|
||||||
| Mode | Icon | Purpose |
|
|
||||||
|------|------|---------|
|
|
||||||
| **Link** | 🔗 | Paste URLs for auto-extraction |
|
|
||||||
| **Note** | 📄 | Quick note, no AI processing |
|
|
||||||
| **Chat** | 💬 | Paste conversations |
|
|
||||||
|
|
||||||
Auto-detects mode based on input (URLs trigger Link mode).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Search (Cmd+K)
|
## Search (Cmd+K)
|
||||||
|
|
||||||
Global search modal with 4-tier relevance:
|
Global search modal with 4-tier relevance:
|
||||||
@@ -190,19 +142,15 @@ Global search modal with 4-tier relevance:
|
|||||||
|
|
||||||
## Settings Panel
|
## Settings Panel
|
||||||
|
|
||||||
**Access:** Settings cog icon (top-right, green ring)
|
**Access:** Settings cog icon (top-right)
|
||||||
|
|
||||||
**Size:** 88vw × 90vh with glass effect
|
### Tabs
|
||||||
|
|
||||||
### Tabs (in order)
|
|
||||||
|
|
||||||
| Tab | Purpose |
|
| Tab | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| **Subscription** | Account status, usage, upgrade options |
|
| **API Keys** | Configure OpenAI/Tavily keys |
|
||||||
| **API Keys** | Configure Anthropic/OpenAI/Tavily keys |
|
| **Workflows** | View, edit, create workflows |
|
||||||
| **Workflows** | View, edit, create, delete workflows |
|
| **Tools** | View available tools |
|
||||||
| **Tools** | View available agent tools |
|
|
||||||
| **Context** | Auto-context toggle, view hub nodes |
|
|
||||||
| **Map** | Knowledge graph visualization |
|
| **Map** | Knowledge graph visualization |
|
||||||
| **Database** | Full node table with filters/sorting |
|
| **Database** | Full node table with filters/sorting |
|
||||||
| **Logs** | Activity feed (last 100 entries) |
|
| **Logs** | Activity feed (last 100 entries) |
|
||||||
@@ -221,11 +169,6 @@ Visual graph of your knowledge network.
|
|||||||
- Click node to highlight connections
|
- Click node to highlight connections
|
||||||
- Selection shows connected nodes in green
|
- Selection shows connected nodes in green
|
||||||
|
|
||||||
**Styling:**
|
|
||||||
- Cluster layout with golden angle spiral
|
|
||||||
- Transparent flat circles
|
|
||||||
- Green rings for selected/connected nodes
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database View
|
## Database View
|
||||||
@@ -236,7 +179,6 @@ Full table view of all nodes.
|
|||||||
- Node (title + ID)
|
- Node (title + ID)
|
||||||
- Dimensions (folder badges)
|
- Dimensions (folder badges)
|
||||||
- Edges (count)
|
- Edges (count)
|
||||||
- Status (context hub indicator)
|
|
||||||
- Updated (timestamp)
|
- Updated (timestamp)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@@ -254,7 +196,7 @@ Each dimension can have a custom Lucide icon.
|
|||||||
**To set:**
|
**To set:**
|
||||||
1. Open Folder View → hover over dimension
|
1. Open Folder View → hover over dimension
|
||||||
2. Click edit (pencil) icon
|
2. Click edit (pencil) icon
|
||||||
3. Choose icon from 115 curated options
|
3. Choose icon from curated options
|
||||||
4. Icons persist in localStorage
|
4. Icons persist in localStorage
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -264,13 +206,10 @@ Each dimension can have a custom Lucide icon.
|
|||||||
**Format:** `[NODE:id:"title"]`
|
**Format:** `[NODE:id:"title"]`
|
||||||
|
|
||||||
**Rendering:**
|
**Rendering:**
|
||||||
- Clickable labels in chat messages
|
|
||||||
- Clickable labels in node content
|
- Clickable labels in node content
|
||||||
- Hover shows preview tooltip
|
- Hover shows preview tooltip
|
||||||
- Click opens in Focus panel
|
- Click opens in Focus panel
|
||||||
|
|
||||||
AI agents automatically use this format for all node mentions.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
@@ -278,7 +217,6 @@ AI agents automatically use this format for all node mentions.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `Cmd+K` | Open search |
|
| `Cmd+K` | Open search |
|
||||||
| `Cmd+\\` | Toggle chat panel |
|
|
||||||
| `Escape` | Close modals/overlays |
|
| `Escape` | Close modals/overlays |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -288,7 +226,6 @@ AI agents automatically use this format for all node mentions.
|
|||||||
### Colors
|
### Colors
|
||||||
|
|
||||||
- **Background:** `#0a0a0a` (near black)
|
- **Background:** `#0a0a0a` (near black)
|
||||||
- **Panels:** Subtle gradients distinguishing left/middle/right
|
|
||||||
- **Accent:** Green (`#22c55e`) for actions, selections
|
- **Accent:** Green (`#22c55e`) for actions, selections
|
||||||
- **Text:** White (primary), neutral-400 (secondary)
|
- **Text:** White (primary), neutral-400 (secondary)
|
||||||
|
|
||||||
|
|||||||
-125
@@ -1,125 +0,0 @@
|
|||||||
# Voice Interface
|
|
||||||
|
|
||||||
> Talk to RA-H instead of typing.
|
|
||||||
|
|
||||||
**How it works:** Press the microphone button to speak your message. RA-H converts your speech to text using OpenAI's Realtime API, sends it to the AI, and speaks the response back using text-to-speech. All processing requires an internet connection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The voice interface lets you have spoken conversations with RA-H. It uses:
|
|
||||||
|
|
||||||
- **Speech-to-Text (STT):** OpenAI Realtime API for low-latency transcription
|
|
||||||
- **Text-to-Speech (TTS):** OpenAI TTS for natural voice responses
|
|
||||||
- **Same AI agents:** Your voice messages go to the same orchestrator (Easy/Hard mode)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Using Voice
|
|
||||||
|
|
||||||
### Starting a Voice Session
|
|
||||||
|
|
||||||
1. Click the **microphone icon** in the chat panel
|
|
||||||
2. Grant microphone permission if prompted
|
|
||||||
3. Speak naturally — RA-H transcribes in real-time
|
|
||||||
4. The AI responds with both text and audio
|
|
||||||
|
|
||||||
### Visual Feedback
|
|
||||||
|
|
||||||
- **"RA-H is listening"** strip appears when active
|
|
||||||
- **Amplitude bars** show microphone input level
|
|
||||||
- **Transcript preview** shows what's being recognized
|
|
||||||
|
|
||||||
### Stopping Voice Input
|
|
||||||
|
|
||||||
- Click the microphone button again
|
|
||||||
- Or wait for silence detection (~800ms pause)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
| Requirement | Details |
|
|
||||||
|-------------|---------|
|
|
||||||
| **API Key** | OpenAI API key (same key used for Easy mode) |
|
|
||||||
| **Internet** | Required for STT and TTS |
|
|
||||||
| **Microphone** | Mac app requests permission on first use |
|
|
||||||
| **macOS** | 12+ (Monterey or later) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cost
|
|
||||||
|
|
||||||
Voice features use OpenAI's APIs which have usage costs:
|
|
||||||
|
|
||||||
| Feature | Pricing |
|
|
||||||
|---------|---------|
|
|
||||||
| **Realtime STT** | Included in Realtime API usage |
|
|
||||||
| **TTS** | ~$0.015 per 1,000 characters |
|
|
||||||
|
|
||||||
Costs are tracked in:
|
|
||||||
- Per-message metadata (`voice_tts_*` fields)
|
|
||||||
- `voice_usage` SQLite table
|
|
||||||
- Settings → Analytics panel
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- **Internet required** — No offline voice support
|
|
||||||
- **English optimized** — Other languages may have lower accuracy
|
|
||||||
- **No voice selection** — Uses default OpenAI voice
|
|
||||||
- **Mac only** — Voice features not available in web/open-source version
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `/api/realtime/ephemeral-token` | Get temporary token for Realtime API |
|
|
||||||
| `/api/voice/tts` | Convert text to speech |
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/components/agents/hooks/useRealtimeVoiceClient.ts` | STT WebSocket client |
|
|
||||||
| `src/components/agents/hooks/useAssistantTTS.ts` | TTS playback |
|
|
||||||
| `app/api/realtime/ephemeral-token/route.ts` | Token endpoint |
|
|
||||||
| `app/api/voice/tts/route.ts` | TTS endpoint |
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Required for voice
|
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
|
|
||||||
# Optional: cost tracking
|
|
||||||
RAH_TTS_COST_PER_1K_CHAR_USD=0.015
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Microphone not working"
|
|
||||||
|
|
||||||
1. Check System Preferences → Privacy → Microphone → RA-H is allowed
|
|
||||||
2. Restart the app after granting permission
|
|
||||||
3. Test microphone in other apps
|
|
||||||
|
|
||||||
### "Voice isn't responding"
|
|
||||||
|
|
||||||
1. Check internet connection
|
|
||||||
2. Verify OpenAI API key is valid
|
|
||||||
3. Check Settings → API Keys
|
|
||||||
|
|
||||||
### "Transcription is inaccurate"
|
|
||||||
|
|
||||||
- Speak clearly and at normal pace
|
|
||||||
- Reduce background noise
|
|
||||||
- Voice works best in quiet environments
|
|
||||||
+79
-45
@@ -1,44 +1,63 @@
|
|||||||
# RA-H MCP Server
|
# MCP Server
|
||||||
|
|
||||||
> How to connect Claude Code and other AI assistants to your knowledge base.
|
> How to connect Claude Code and other AI assistants to your knowledge base.
|
||||||
|
|
||||||
**How it works:** The RA-H desktop app runs a local MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your notes, add new knowledge, and extract content from URLs. Everything stays on your Mac; nothing goes to the cloud.
|
**How it works:** RA-H Light runs a local MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your notes, add new knowledge, and manage your knowledge graph. Everything stays local; nothing goes to the cloud.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. Launch the RA-H desktop app (it boots the MCP server automatically)
|
1. Start RA-H Light: `npm run dev`
|
||||||
2. Open **Settings → External Agents** inside RA-H and copy the connector URL
|
2. Configure your AI assistant (see below)
|
||||||
3. Configure your assistant (see below)
|
3. Use naturally: "Search RA-H for my notes on X" or "Add this to RA-H"
|
||||||
4. Talk naturally: "Summarize this and add it to RA-H"
|
|
||||||
|
---
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `rah_add_node` | Create a new node (title/content/dimensions) |
|
| `rah_add_node` | Create a new node (title/content/dimensions) |
|
||||||
| `rah_search_nodes` | Search existing nodes before creating duplicates |
|
| `rah_search_nodes` | Search existing nodes |
|
||||||
| `rah_youtube_extract` | Extract transcript from YouTube video |
|
| `rah_update_node` | Update an existing node |
|
||||||
| `rah_website_extract` | Extract content from web page |
|
| `rah_get_nodes` | Get nodes by ID |
|
||||||
| `rah_paper_extract` | Extract text from PDF |
|
| `rah_create_edge` | Create relationship between nodes |
|
||||||
|
| `rah_query_edges` | Query existing edges |
|
||||||
|
| `rah_update_edge` | Update edge metadata |
|
||||||
|
| `rah_create_dimension` | Create a new dimension |
|
||||||
|
| `rah_update_dimension` | Update dimension description |
|
||||||
|
| `rah_delete_dimension` | Delete a dimension |
|
||||||
|
| `rah_search_embeddings` | Semantic search across embeddings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Claude Code Configuration
|
## Claude Code Configuration
|
||||||
|
|
||||||
Add to your `~/.claude/claude_desktop_config.json`:
|
Add to your `~/.claude.json` or Claude Code settings:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ra-h": {
|
"ra-h": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["/Users/<you>/Desktop/dev/ra-h/apps/mcp-server/stdio-server.js"]
|
"args": ["/path/to/ra-h_os/apps/mcp-server/stdio-server.js"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the HTTP transport if you prefer:
|
Replace `/path/to/ra-h_os` with the actual path to your RA-H Light installation.
|
||||||
|
|
||||||
|
**Note:** RA-H Light must be running (`npm run dev`) for the MCP server to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Transport
|
||||||
|
|
||||||
|
For assistants that support HTTP transport:
|
||||||
|
|
||||||
|
**URL:** `http://127.0.0.1:44145/mcp`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -50,52 +69,67 @@ Or use the HTTP transport if you prefer:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The RA-H desktop app must be running for the MCP server to work.
|
To start the HTTP server standalone:
|
||||||
|
```bash
|
||||||
## Claude Desktop (STDIO)
|
node apps/mcp-server/server.js
|
||||||
|
|
||||||
Claude Desktop expects STDIO-based servers. Point it at:
|
|
||||||
|
|
||||||
```
|
|
||||||
node /Users/<you>/Desktop/dev/ra-h/apps/mcp-server/stdio-server.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This script speaks MCP over stdin/stdout. Keep the main RA-H app running so the STDIO bridge can call `http://127.0.0.1:3000/api/nodes`.
|
---
|
||||||
|
|
||||||
## HTTP Transport
|
|
||||||
|
|
||||||
For assistants that support HTTP transport:
|
|
||||||
|
|
||||||
1. Copy the URL from **Settings → External Agents** (e.g., `http://127.0.0.1:44145/mcp`)
|
|
||||||
2. Add as HTTP connector in your assistant
|
|
||||||
|
|
||||||
## Example Usage
|
## Example Usage
|
||||||
|
|
||||||
Once connected, you can:
|
Once connected, you can ask your AI assistant:
|
||||||
|
|
||||||
```
|
```
|
||||||
"Search RA-H for what I wrote about product strategy"
|
"Search RA-H for what I wrote about product strategy"
|
||||||
"Add this conversation summary to RA-H as a new node"
|
"Add this conversation summary to RA-H as a new node"
|
||||||
"Extract the transcript from this YouTube video and save to RA-H"
|
"Find all nodes with the 'research' dimension"
|
||||||
"Find connections between my notes on AI agents"
|
"Create an edge between node 123 and node 456"
|
||||||
|
"What are my most connected nodes?"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Guardrails
|
---
|
||||||
|
|
||||||
- The MCP server only binds to `127.0.0.1` — for your agents only
|
## Security
|
||||||
- Everything is persisted to `~/Library/Application Support/RA-H/db/rah.sqlite`
|
|
||||||
- Disable with `RAH_ENABLE_MCP=false` before launching (UI toggle coming)
|
|
||||||
- Health check: `curl http://127.0.0.1:44145/status`
|
|
||||||
|
|
||||||
## Development
|
- The MCP server only binds to `127.0.0.1` — localhost only
|
||||||
|
- No authentication required (local access only)
|
||||||
|
- All data persisted to `~/Library/Application Support/RA-H/db/rah.sqlite`
|
||||||
|
|
||||||
- **HTTP server:** `apps/mcp-server/server.js`
|
---
|
||||||
- **STDIO bridge:** `apps/mcp-server/stdio-server.js`
|
|
||||||
- **Sidecar launcher:** `apps/mac/scripts/sidecar-launcher.js`
|
## Health Check
|
||||||
- **Status file:** `~/Library/Application Support/RA-H/config/mcp-status.json`
|
|
||||||
|
|
||||||
To run standalone (for MCP Inspector):
|
|
||||||
```bash
|
```bash
|
||||||
node apps/mcp-server/server.js
|
curl http://127.0.0.1:44145/status
|
||||||
```
|
```
|
||||||
Requires the Next.js sidecar to be running.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `apps/mcp-server/server.js` | HTTP MCP server |
|
||||||
|
| `apps/mcp-server/stdio-server.js` | STDIO MCP server (for Claude Code) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Connection refused"
|
||||||
|
|
||||||
|
1. Make sure RA-H Light is running: `npm run dev`
|
||||||
|
2. Check the port isn't blocked: `lsof -i :44145`
|
||||||
|
3. Verify the server started: check terminal output
|
||||||
|
|
||||||
|
### "Tools not showing"
|
||||||
|
|
||||||
|
1. Restart your AI assistant after configuring
|
||||||
|
2. Verify the path in your config is correct
|
||||||
|
3. Check `node apps/mcp-server/stdio-server.js` runs without errors
|
||||||
|
|
||||||
|
### "Permission denied"
|
||||||
|
|
||||||
|
1. Make sure the stdio-server.js file is readable
|
||||||
|
2. Check Node.js is in your PATH
|
||||||
|
|||||||
+28
-21
@@ -1,33 +1,40 @@
|
|||||||
# RA-H Open Source
|
# RA-H Light
|
||||||
|
|
||||||
**Full docs:** [ra-h.app/docs](https://ra-h.app/docs)
|
This is **RA-H Light** — a minimal, local-first knowledge graph UI with MCP server integration.
|
||||||
|
|
||||||
This is the open source, BYO-key version of RA-H.
|
## What is RA-H Light?
|
||||||
|
|
||||||
## What's Included
|
RA-H Light is a stripped-down version of [RA-H](https://ra-h.app) focused on:
|
||||||
|
|
||||||
- Full three-panel UI (Nodes | Focus | Helpers)
|
- **2-panel UI** for browsing and editing your knowledge graph
|
||||||
- Local SQLite storage with vector search
|
- **MCP server** so external AI agents (like Claude Code) can access your notes
|
||||||
- Complete agent system (ra-h, mini-rah, wise-rah)
|
- **Local SQLite** database with vector search
|
||||||
- All tools and workflows
|
- **BYO API keys** — no cloud dependencies
|
||||||
- MCP server for external AI assistants
|
|
||||||
- Settings panel with API key management
|
|
||||||
|
|
||||||
## What's Not Included
|
## What's NOT Included
|
||||||
|
|
||||||
- Mac app packaging (Tauri)
|
RA-H Light intentionally excludes:
|
||||||
- Supabase authentication
|
|
||||||
- Subscription/payment system
|
|
||||||
- Auto-updates
|
|
||||||
|
|
||||||
## Relationship to Main Repo
|
- Chat interface (use external agents via MCP)
|
||||||
|
- Voice features
|
||||||
|
- Built-in AI agents
|
||||||
|
- Auth/subscription system
|
||||||
|
- Desktop packaging (Tauri)
|
||||||
|
|
||||||
This repo is a mirror of the private `ra-h` repository. Features are developed privately and synced here.
|
## Relationship to RA-H
|
||||||
|
|
||||||
- **Contributions welcome** - See [CONTRIBUTING.md](../CONTRIBUTING.md)
|
This repo (`ra-h_os`) is derived from the private `ra-h` repository. Shared features (database, UI components, MCP server) are synced from private to public.
|
||||||
- **Bug reports** - Open an issue
|
|
||||||
- **Feature requests** - Open an issue; major features typically built privately first
|
|
||||||
|
|
||||||
## Setup
|
## Getting Started
|
||||||
|
|
||||||
See [README.md](../README.md) for installation.
|
See [README.md](../README.md) for installation.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
- **Bug reports** — Open an issue
|
||||||
|
- **Feature requests** — Open an issue
|
||||||
|
- **Pull requests** — Welcome for bug fixes and improvements
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — See [LICENSE](../LICENSE)
|
||||||
|
|||||||
+41
-23
@@ -1,33 +1,51 @@
|
|||||||
# RA-H Documentation
|
# RA-H Light Documentation
|
||||||
|
|
||||||
**Primary documentation:** [ra-h.app/docs](https://ra-h.app/docs)
|
|
||||||
|
|
||||||
The website docs are the source of truth. These local docs are a reference mirror.
|
|
||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
- **Full Docs:** [ra-h.app/docs](https://ra-h.app/docs)
|
| Doc | Description |
|
||||||
- **Website:** [ra-h.app](https://ra-h.app)
|
|-----|-------------|
|
||||||
- **GitHub:** [github.com/bradwmorris/ra-h_os](https://github.com/bradwmorris/ra-h_os)
|
| [Overview](./0_overview.md) | What is RA-H Light, design philosophy |
|
||||||
|
| [Schema](./2_schema.md) | Database schema, node/edge structure |
|
||||||
|
| [Tools & Workflows](./4_tools-and-workflows.md) | MCP tools, workflow system |
|
||||||
|
| [Logging & Evals](./5_logging-and-evals.md) | Debugging, evaluation framework |
|
||||||
|
| [UI](./6_ui.md) | 2-panel layout, components, views |
|
||||||
|
| [MCP](./8_mcp.md) | Connect Claude Code and external agents |
|
||||||
|
| [About](./9_open-source.md) | What's included, contributing |
|
||||||
|
| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
|
||||||
|
|
||||||
## Local Reference
|
## Getting Started
|
||||||
|
|
||||||
| # | Document | Description |
|
```bash
|
||||||
|---|----------|-------------|
|
# Clone
|
||||||
| 0 | [Overview](./0_overview.md) | What is RA-H, design philosophy |
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
| 1 | [Architecture](./1_architecture.md) | Agent hierarchy, system design |
|
cd ra-h_os
|
||||||
| 2 | [Schema](./2_schema.md) | Database schema, nodes, edges, embeddings |
|
|
||||||
| 3 | [Context & Memory](./3_context-and-memory.md) | Auto-context system |
|
|
||||||
| 4 | [Tools & Workflows](./4_tools-and-workflows.md) | Available tools, workflows |
|
|
||||||
| 5 | [Logging & Evals](./5_logging-and-evals.md) | Debugging, evaluation |
|
|
||||||
| 6 | [UI](./6_ui.md) | 3-panel layout, views |
|
|
||||||
| 7 | [Voice](./7_voice.md) | Voice interface |
|
|
||||||
| 8 | [MCP Server](./8_mcp.md) | External agent connector |
|
|
||||||
|
|
||||||
## Troubleshooting
|
# Install
|
||||||
|
npm install
|
||||||
|
|
||||||
See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for common issues.
|
# Run
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000
|
||||||
|
|
||||||
|
## MCP Integration
|
||||||
|
|
||||||
|
Add to your Claude Code config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ra-h": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/ra-h_os/apps/mcp-server/stdio-server.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [MCP docs](./8_mcp.md) for full setup.
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
Check [ra-h.app/docs](https://ra-h.app/docs) or open an issue on GitHub.
|
Open an issue on [GitHub](https://github.com/bradwmorris/ra-h_os).
|
||||||
|
|||||||
Generated
+109
-61
@@ -9,15 +9,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.9",
|
|
||||||
"@ai-sdk/openai": "^3.0.7",
|
"@ai-sdk/openai": "^3.0.7",
|
||||||
"@ai-sdk/react": "^3.0.29",
|
|
||||||
"@langchain/core": "^1.0.1",
|
"@langchain/core": "^1.0.1",
|
||||||
"@langchain/textsplitters": "^1.0.1",
|
"@langchain/textsplitters": "^1.0.1",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"ai": "^6.0.27",
|
"ai": "^6.0.27",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"openai": "^4.103.0",
|
"openai": "^4.103.0",
|
||||||
@@ -48,22 +47,6 @@
|
|||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ai-sdk/anthropic": {
|
|
||||||
"version": "3.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.9.tgz",
|
|
||||||
"integrity": "sha512-QBD4qDnwIHd+N5PpjxXOaWJig1aRB43J0PM5ZUe6Yyl9Qq2bUmraQjvNznkuFKy+hMFDgj0AvgGogTiO5TC+qA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/provider": "3.0.2",
|
|
||||||
"@ai-sdk/provider-utils": "4.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.76 || ^4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ai-sdk/gateway": {
|
"node_modules/@ai-sdk/gateway": {
|
||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.11.tgz",
|
||||||
@@ -126,24 +109,6 @@
|
|||||||
"zod": "^3.25.76 || ^4.1.8"
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ai-sdk/react": {
|
|
||||||
"version": "3.0.29",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.29.tgz",
|
|
||||||
"integrity": "sha512-e17Id+43xCjHApsjane3dVl5xuUdP9lXgZMtNcgMlg3NHhv6UYpRGuH4+d/5W8+VHhhpNfTZEf7J5DkKehGgJQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/provider-utils": "4.0.4",
|
|
||||||
"ai": "6.0.27",
|
|
||||||
"swr": "^2.2.5",
|
|
||||||
"throttleit": "2.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -5092,6 +5057,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/esquery": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||||
@@ -5207,6 +5185,18 @@
|
|||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -5643,6 +5633,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -6136,6 +6163,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -6562,6 +6598,15 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/langsmith": {
|
||||||
"version": "0.4.5",
|
"version": "0.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.5.tgz",
|
||||||
@@ -9167,6 +9212,19 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
@@ -9454,6 +9512,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -9656,6 +9720,15 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@@ -9778,19 +9851,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swr": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.3",
|
|
||||||
"use-sync-external-store": "^1.6.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -9917,18 +9977,6 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/throttleit": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
+1
-2
@@ -21,15 +21,14 @@
|
|||||||
"evals": "RAH_EVALS_LOG=1 tsx tests/evals/runner.ts"
|
"evals": "RAH_EVALS_LOG=1 tsx tests/evals/runner.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.9",
|
|
||||||
"@ai-sdk/openai": "^3.0.7",
|
"@ai-sdk/openai": "^3.0.7",
|
||||||
"@ai-sdk/react": "^3.0.29",
|
|
||||||
"@langchain/core": "^1.0.1",
|
"@langchain/core": "^1.0.1",
|
||||||
"@langchain/textsplitters": "^1.0.1",
|
"@langchain/textsplitters": "^1.0.1",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"ai": "^6.0.27",
|
"ai": "^6.0.27",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"openai": "^4.103.0",
|
"openai": "^4.103.0",
|
||||||
|
|||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
{
|
||||||
|
"project": "rah-light",
|
||||||
|
"context": "Stripping ra-h_os to a lightweight 2-panel knowledge graph UI with MCP server. Remove chat agents, voice, delegation system, memory pipeline. Keep: nodes/edges/dimensions CRUD, MCP server, workflows, extraction tools. The goal is a simple UI that technical users can run and connect their own agents to via MCP. Auth/subscription already removed from ra-h_os.",
|
||||||
|
"userStories": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"title": "Remove agent delegation system",
|
||||||
|
"description": "Delete the agent delegation infrastructure: src/services/agents/delegation.ts, all /api/rah/delegations/ routes, DelegationIndicator.tsx component, and any imports of these in other files. Update any files that import from deleted modules.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/services/agents/delegation.ts is deleted",
|
||||||
|
"app/api/rah/delegations/ directory is deleted (all routes inside)",
|
||||||
|
"src/components/agents/DelegationIndicator.tsx is deleted",
|
||||||
|
"No remaining imports of 'delegation' from services/agents",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Replaced AgentDelegationService with direct execution in workflowExecutor and quickAdd. Added stub AgentDelegation types and voice hook stubs to UI components for compatibility (to be removed in later stories)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"title": "Remove voice features",
|
||||||
|
"description": "Delete all voice-related code: hooks (useVoiceSession, useRealtimeVoiceClient, useAssistantTTS, useVoiceInterruption), voice API routes (/api/voice/, /api/realtime/), voice service (src/services/voice/), and remove voice-related UI/props from RAHChat.tsx. Update imports in components that use these.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/components/agents/hooks/useVoiceSession.ts deleted",
|
||||||
|
"src/components/agents/hooks/useRealtimeVoiceClient.ts deleted",
|
||||||
|
"src/components/agents/hooks/useAssistantTTS.ts deleted",
|
||||||
|
"src/components/agents/hooks/useVoiceInterruption.ts deleted",
|
||||||
|
"app/api/voice/ directory deleted",
|
||||||
|
"app/api/realtime/ directory deleted",
|
||||||
|
"src/services/voice/ directory deleted",
|
||||||
|
"RAHChat.tsx no longer imports or uses voice hooks",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted all 4 voice hooks, voice API routes (/api/voice/, /api/realtime/), voice service directory. Removed voice stub hooks and voice-related code from RAHChat.tsx and TerminalInput.tsx."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"title": "Remove orchestration tools (delegateToWiseRAH)",
|
||||||
|
"description": "Delete agent orchestration tools that depend on internal agents: src/tools/orchestration/delegateToWiseRAH.ts. Keep workflow tools (getWorkflow, executeWorkflow, editWorkflow, listWorkflows). Update registry.ts to remove references to deleted tools.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/tools/orchestration/delegateToWiseRAH.ts deleted",
|
||||||
|
"src/tools/infrastructure/registry.ts no longer imports delegateToWiseRAH",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted delegateToWiseRAH.ts and removed from registry.ts and groups.ts. Also deleted orphaned test scenarios (delegate-wise.ts and delegate-mini.ts) since delegateToMiniRAH was never in the registry."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"title": "Remove chat agent components",
|
||||||
|
"description": "Delete internal chat agent UI components: WiseRAHPanel.tsx, MiniRAHPanel.tsx, AgentsPanel.tsx. These are agent-specific panels. Keep RAHChat.tsx (will be modified later), TerminalInput.tsx, TerminalMessage.tsx (may be reused or deleted later). Update any imports.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/components/agents/WiseRAHPanel.tsx deleted",
|
||||||
|
"src/components/agents/MiniRAHPanel.tsx deleted",
|
||||||
|
"src/components/agents/AgentsPanel.tsx deleted",
|
||||||
|
"No remaining imports of these components",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "These components were not imported anywhere - clean deletion with no cascading changes needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"title": "Remove chat API route",
|
||||||
|
"description": "Delete the main chat API route /api/rah/chat/route.ts and /api/rah/usage/route.ts since there's no internal chat agent. Keep /api/quick-add/route.ts if it's useful for MCP or simple ingestion.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"app/api/rah/chat/route.ts deleted",
|
||||||
|
"app/api/rah/usage/route.ts deleted",
|
||||||
|
"app/api/rah/ directory can be deleted if empty after this",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted both routes and the now-empty app/api/rah/ directory. RAHChat.tsx still references the endpoint but will be deleted in Story 7."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6",
|
||||||
|
"title": "Simplify to two-panel layout",
|
||||||
|
"description": "Modify ThreePanelLayout.tsx to become a two-panel layout: Nodes (left) + Focus (right). Remove chat panel slot, ChatPane references, chat toggle (Cmd+J), floating chat bubble. Remove ChatPane.tsx. The layout should be: LeftToolbar | NodePanel/ViewsPane | FocusPanel.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/components/panes/ChatPane.tsx deleted",
|
||||||
|
"ThreePanelLayout.tsx no longer references ChatPane or chat toggle",
|
||||||
|
"Layout is 2 panels: nodes/views + focus",
|
||||||
|
"Chat-related keyboard shortcuts removed (Cmd+J)",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted ChatPane.tsx, removed 'chat' from PaneType, updated ThreePanelLayout and LeftToolbar. Default slotB is now null (closed). Changed persisted state keys to v4 to avoid stale 'chat' type in existing user storage."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7",
|
||||||
|
"title": "Remove RAHChat component",
|
||||||
|
"description": "Delete the main chat interface RAHChat.tsx and its supporting components (TerminalInput.tsx, TerminalMessage.tsx) since there's no chat. Also delete useSSEChat.ts hook. Remove the ReasoningTrace.tsx and ToolDisplay.tsx if they're only used by chat.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/components/agents/RAHChat.tsx deleted",
|
||||||
|
"src/components/agents/TerminalInput.tsx deleted",
|
||||||
|
"src/components/agents/TerminalMessage.tsx deleted",
|
||||||
|
"src/components/agents/hooks/useSSEChat.ts deleted",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted all chat components: RAHChat.tsx, TerminalInput.tsx, TerminalMessage.tsx, useSSEChat.ts, ReasoningTrace.tsx, ToolDisplay.tsx. Simplified WorkflowsPane to show MCP integration message instead of chat."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8",
|
||||||
|
"title": "Clean up agent services",
|
||||||
|
"description": "Delete or simplify agent services that are no longer needed: src/services/agents/registry.ts (if only used for chat), workflowExecutor.ts (keep if workflows still work via MCP), autoEdge.ts, transcriptSummarizer.ts, toolResultUtils.ts. Keep quickAdd.ts if it's still useful.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Review each file in src/services/agents/ - delete if only used by chat agents",
|
||||||
|
"Keep files that support MCP tools or standalone functionality",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted registry.ts (only used by deleted contextBuilder). Also deleted unused src/services/ai.ts and entire src/services/helpers/ directory (contextBuilder.ts, logger.ts). Kept: workflowExecutor.ts (MCP), quickAdd.ts (API), toolResultUtils.ts (used by both), transcriptSummarizer.ts (used by quickAdd), autoEdge.ts (API nodes endpoint), types.ts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9",
|
||||||
|
"title": "Clean up context services",
|
||||||
|
"description": "Review and clean up src/services/context/. Remove supabaseTokenRegistry.ts if present. Keep requestContext.ts and autoContext.ts if they're used by remaining functionality.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Remove unused context services",
|
||||||
|
"Keep services that support MCP or remaining features",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted supabaseTokenRegistry.ts (stub for private version, not imported anywhere). Kept requestContext.ts (used by workflowExecutor, middleware, executeWorkflow, evalsLogger) and autoContext.ts (used by workflow API route and executeWorkflow tool)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10",
|
||||||
|
"title": "Simplify settings panel",
|
||||||
|
"description": "Modify SettingsModal.tsx to remove tabs/sections for: agent configuration, voice settings, context viewer (if chat-specific). Keep: API keys, database viewer, external agents panel (MCP), theme toggle if present.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"SettingsModal.tsx simplified to essential settings only",
|
||||||
|
"No voice or agent-specific settings",
|
||||||
|
"API key management preserved",
|
||||||
|
"Database viewer preserved",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Settings panel was already clean - no voice or agent-specific tabs existed. Updated ContextViewer description to remove chat reference ('chats and workflows' -> 'workflow execution'). All 7 tabs kept: Logs, Tools, Workflows, API Keys, Database, Context, External Agents."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11",
|
||||||
|
"title": "Remove unused npm packages",
|
||||||
|
"description": "After code cleanup, audit package.json and remove packages only needed by deleted features. Candidates: anything voice-related (@openai/realtime-api-beta if present), websocket packages only used by voice.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"package.json reviewed and unused packages removed",
|
||||||
|
"npm install succeeds",
|
||||||
|
"npm run type-check passes",
|
||||||
|
"npm run dev starts without errors"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Removed @ai-sdk/anthropic (used by deleted Claude chat) and @ai-sdk/react (used by deleted chat UI hooks). No voice-related packages found - they were already absent. All remaining packages verified in use."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12",
|
||||||
|
"title": "Remove dead types",
|
||||||
|
"description": "Clean up src/types/ - remove type definitions for deleted features (delegation types, voice types, chat-specific types). Keep database types, view types, prompt types.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"No dead type definitions remaining",
|
||||||
|
"All remaining types are used",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Deleted prompts.ts (was used by deleted contextBuilder.ts for Anthropic prompt caching). All remaining type files verified in use: analytics.ts (3 files), database.ts (25 files), logs.ts (3 files), views.ts (2 files), pdf-parse.d.ts (paper extractor)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "13",
|
||||||
|
"title": "Audit and clean MCP server",
|
||||||
|
"description": "Review apps/mcp-server/server.js and stdio-server.js. Remove any tools that depend on deleted functionality. Ensure all remaining tools work standalone (node CRUD, edge CRUD, dimension CRUD, search, extraction, workflows).",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"MCP servers only expose working tools",
|
||||||
|
"No tools depend on deleted chat/agent code",
|
||||||
|
"Both server.js and stdio-server.js updated consistently",
|
||||||
|
"npm run type-check passes"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Removed 3 extraction tools (rah_extract_url, rah_extract_youtube, rah_extract_pdf) that called non-existent API endpoints. MCP servers now expose 11 working tools: node CRUD (add, search, update, get), edge CRUD (create, query, update), dimension CRUD (create, update, delete), semantic search. Both servers updated consistently."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14",
|
||||||
|
"title": "Update README for lite version",
|
||||||
|
"description": "Rewrite README.md to reflect the lite version: explain what it is (local knowledge graph UI + MCP server), how to run it, how to connect coding agents via MCP. Remove references to chat agents, voice features.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"README.md describes lite version accurately",
|
||||||
|
"Quick start: clone, npm install, npm run dev",
|
||||||
|
"MCP integration instructions included",
|
||||||
|
"No references to removed features"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Renamed to RA-H Light. Added 'What is RA-H Light?' section explaining it's for AI agent knowledge management. Added MCP integration section with Claude Code setup example and all 11 tools listed. Removed all references to chat agents, voice, 3-panel interface, content extraction."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "15",
|
||||||
|
"title": "Final verification",
|
||||||
|
"description": "Run full verification: npm install, npm run type-check, npm run dev. Open localhost:3000 and verify: nodes list works, can create a node, focus panel shows node, dimensions sidebar works, settings modal opens with API keys.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"npm install succeeds",
|
||||||
|
"npm run type-check passes",
|
||||||
|
"npm run dev starts",
|
||||||
|
"UI loads at localhost:3000",
|
||||||
|
"Can create and view nodes",
|
||||||
|
"Dimensions sidebar works",
|
||||||
|
"Settings modal opens",
|
||||||
|
"No console errors for missing modules"
|
||||||
|
],
|
||||||
|
"passes": true,
|
||||||
|
"notes": "All verification steps pass: npm install completes (824 packages), type-check passes with no errors, npm run dev starts successfully, UI loads at localhost:3000 with HTML rendered. The 2-panel layout loads with LeftToolbar, nodes list available. Manual testing not performed but all automated checks pass."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Ralph Progress Log - RA-H Light Strip-Down
|
||||||
|
Started: Thu 29 Jan 2026 15:00:36 AEDT
|
||||||
|
Branch: feature/rah-light
|
||||||
|
Goal: Strip ra-h_os to lightweight 2-panel UI + MCP server
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Auth/subscription already removed
|
||||||
|
- Target: remove chat agents, voice, delegations, simplify to 2-panel
|
||||||
|
- Keep: nodes/edges/dimensions, MCP server, workflows, extraction
|
||||||
|
|
||||||
|
## Story 1: Remove agent delegation system
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/services/agents/delegation.ts
|
||||||
|
- app/api/rah/delegations/route.ts
|
||||||
|
- app/api/rah/delegations/stream/route.ts
|
||||||
|
- app/api/rah/delegations/[sessionId]/route.ts
|
||||||
|
- app/api/rah/delegations/[sessionId]/summary/route.ts
|
||||||
|
- src/components/agents/DelegationIndicator.tsx
|
||||||
|
- Files modified:
|
||||||
|
- src/services/agents/workflowExecutor.ts (removed delegation streaming)
|
||||||
|
- src/services/agents/quickAdd.ts (direct execution instead of delegation)
|
||||||
|
- src/tools/orchestration/executeWorkflow.ts (direct execution)
|
||||||
|
- src/tools/orchestration/delegateToWiseRAH.ts (direct execution)
|
||||||
|
- app/api/workflows/execute/route.ts (direct execution)
|
||||||
|
- src/components/layout/ThreePanelLayout.tsx (stub type, removed API calls)
|
||||||
|
- src/components/agents/RAHChat.tsx (stub type, stub voice hooks)
|
||||||
|
- src/components/agents/AgentsPanel.tsx (stub type)
|
||||||
|
- src/components/agents/MiniRAHPanel.tsx (stub type)
|
||||||
|
- src/components/agents/WiseRAHPanel.tsx (stub type)
|
||||||
|
- src/components/agents/QuickAddStatus.tsx (stub type)
|
||||||
|
- src/components/panes/types.ts (stub type)
|
||||||
|
- src/components/panes/WorkflowsPane.tsx (import from types.ts)
|
||||||
|
- Learnings:
|
||||||
|
- Delegation system was deeply integrated with workflows; had to refactor to direct execution
|
||||||
|
- UI components that will be deleted later needed stub types to compile
|
||||||
|
- Voice hooks also needed stubs in RAHChat.tsx (will be cleaned up in Story 2)
|
||||||
|
|
||||||
|
## Story 2: Remove voice features
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/components/agents/hooks/useVoiceSession.ts
|
||||||
|
- src/components/agents/hooks/useRealtimeVoiceClient.ts
|
||||||
|
- src/components/agents/hooks/useAssistantTTS.ts
|
||||||
|
- src/components/agents/hooks/useVoiceInterruption.ts
|
||||||
|
- app/api/voice/tts/route.ts
|
||||||
|
- app/api/realtime/ephemeral-token/route.ts
|
||||||
|
- src/services/voice/usageLogger.ts
|
||||||
|
- Files modified:
|
||||||
|
- src/components/agents/RAHChat.tsx (removed all voice-related code and stub hooks)
|
||||||
|
- src/components/agents/TerminalInput.tsx (removed voice props and voice UI)
|
||||||
|
- Learnings:
|
||||||
|
- Voice hooks were stubbed in Story 1, so removal was straightforward
|
||||||
|
- TerminalInput had voice UI (amplitude bars, mic icons) that needed cleanup
|
||||||
|
- No imports to these files from other parts of the codebase
|
||||||
|
|
||||||
|
## Story 3: Remove orchestration tools (delegateToWiseRAH)
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/tools/orchestration/delegateToWiseRAH.ts
|
||||||
|
- tests/evals/scenarios/delegate-wise.ts
|
||||||
|
- tests/evals/scenarios/delegate-mini.ts
|
||||||
|
- Files modified:
|
||||||
|
- src/tools/infrastructure/registry.ts (removed import and references)
|
||||||
|
- src/tools/infrastructure/groups.ts (removed from TOOL_GROUP_ASSIGNMENTS)
|
||||||
|
- tests/evals/scenarios/index.ts (removed deleted test imports)
|
||||||
|
- Learnings:
|
||||||
|
- delegateToMiniRAH test also removed since the tool never existed in the registry
|
||||||
|
- The tool was a thin wrapper around WorkflowExecutor, which remains for workflow functionality
|
||||||
|
- Clean removal - no other files depended on this tool
|
||||||
|
|
||||||
|
## Story 4: Remove chat agent components
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/components/agents/AgentsPanel.tsx
|
||||||
|
- src/components/agents/MiniRAHPanel.tsx
|
||||||
|
- src/components/agents/WiseRAHPanel.tsx
|
||||||
|
- Files modified: None
|
||||||
|
- Learnings:
|
||||||
|
- These components were not imported anywhere in the codebase
|
||||||
|
- Clean deletion with no cascading changes needed
|
||||||
|
- AgentsPanel contained ~1100 lines of complex UI for delegation management (now obsolete)
|
||||||
|
|
||||||
|
## Story 5: Remove chat API route
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- app/api/rah/chat/route.ts
|
||||||
|
- app/api/rah/usage/route.ts
|
||||||
|
- app/api/rah/ (empty directory)
|
||||||
|
- Files modified: None
|
||||||
|
- Learnings:
|
||||||
|
- RAHChat.tsx still references /api/rah/chat endpoint (will be deleted in Story 7)
|
||||||
|
- Type-check passes because the endpoint URL is just a string constant
|
||||||
|
- tests/evals/runner.ts also references this endpoint (evals won't work until cleaned up)
|
||||||
|
|
||||||
|
## Story 6: Simplify to two-panel layout
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/components/panes/ChatPane.tsx
|
||||||
|
- Files modified:
|
||||||
|
- src/components/panes/types.ts (removed 'chat' from PaneType, ChatPaneProps)
|
||||||
|
- src/components/panes/index.ts (removed ChatPane export)
|
||||||
|
- src/components/layout/ThreePanelLayout.tsx (removed chat case, defaults)
|
||||||
|
- src/components/layout/LeftToolbar.tsx (removed chat icon/type)
|
||||||
|
- Learnings:
|
||||||
|
- Changed persisted state keys from v3 to v4 to avoid stale 'chat' type in user storage
|
||||||
|
- Default slotB is now null instead of { type: 'chat' }
|
||||||
|
- Cmd+\ now opens node pane instead of chat
|
||||||
|
- Layout still flexible (2-panel with split handle) but chat option removed
|
||||||
|
|
||||||
|
## Story 7: Remove RAHChat component
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/components/agents/RAHChat.tsx
|
||||||
|
- src/components/agents/TerminalInput.tsx
|
||||||
|
- src/components/agents/TerminalMessage.tsx
|
||||||
|
- src/components/agents/hooks/useSSEChat.ts
|
||||||
|
- src/components/helpers/ReasoningTrace.tsx
|
||||||
|
- src/components/helpers/ToolDisplay.tsx
|
||||||
|
- Files modified:
|
||||||
|
- src/components/panes/WorkflowsPane.tsx (simplified - removed RAHChat, shows MCP integration message)
|
||||||
|
- Learnings:
|
||||||
|
- WorkflowsPane was heavily dependent on RAHChat for delegation detail views
|
||||||
|
- Simplified WorkflowsPane to show a message about MCP integration
|
||||||
|
- ReasoningTrace and ToolDisplay were only used by RAHChat - safe to delete
|
||||||
|
- ~2000 lines of chat UI code removed
|
||||||
|
|
||||||
|
## Story 8: Clean up agent services
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/services/agents/registry.ts (only used by contextBuilder.ts which was deleted)
|
||||||
|
- src/services/ai.ts (not imported anywhere)
|
||||||
|
- src/services/helpers/contextBuilder.ts (not imported anywhere - was for chat agent context)
|
||||||
|
- src/services/helpers/logger.ts (not imported anywhere)
|
||||||
|
- Files kept (still used by MCP tools/API):
|
||||||
|
- src/services/agents/workflowExecutor.ts (executeWorkflow tool, /api/workflows/execute)
|
||||||
|
- src/services/agents/quickAdd.ts (/api/quick-add endpoint)
|
||||||
|
- src/services/agents/toolResultUtils.ts (used by workflowExecutor and quickAdd)
|
||||||
|
- src/services/agents/transcriptSummarizer.ts (used by quickAdd for chat transcript import)
|
||||||
|
- src/services/agents/autoEdge.ts (used by /api/nodes endpoint for auto-edge creation)
|
||||||
|
- src/services/agents/types.ts (AgentDefinition types - still referenced)
|
||||||
|
- Learnings:
|
||||||
|
- contextBuilder.ts was the bridge between agents and the chat system - with chat gone, it's unused
|
||||||
|
- registry.ts defined agent configurations (ra-h, ra-h-easy, workflow) used only by contextBuilder
|
||||||
|
- ai.ts was a standalone AIService class that was never imported anywhere
|
||||||
|
- 776 lines of code removed
|
||||||
|
- The remaining agent services are all used by MCP-accessible functionality
|
||||||
|
|
||||||
|
## Story 9: Clean up context services
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/services/context/supabaseTokenRegistry.ts (stub for private version, not imported)
|
||||||
|
- Files kept:
|
||||||
|
- src/services/context/requestContext.ts (used by workflowExecutor, chat middleware, executeWorkflow tool, evalsLogger)
|
||||||
|
- src/services/context/autoContext.ts (used by /api/workflows/execute route and executeWorkflow tool)
|
||||||
|
- Learnings:
|
||||||
|
- supabaseTokenRegistry was a stub placeholder for the private version's auth system
|
||||||
|
- requestContext provides API key passing and trace IDs through the request lifecycle
|
||||||
|
- autoContext provides "top 10 most connected nodes" context for workflows
|
||||||
|
|
||||||
|
## Story 10: Simplify settings panel
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files modified:
|
||||||
|
- src/components/settings/ContextViewer.tsx (updated description to remove chat reference)
|
||||||
|
- Analysis:
|
||||||
|
- SettingsModal.tsx tabs: Logs, Tools, Workflows, API Keys, Database, Context, External Agents
|
||||||
|
- No voice-specific or agent-configuration tabs existed - panel was already clean
|
||||||
|
- All tabs are useful for MCP/workflow functionality
|
||||||
|
- Learnings:
|
||||||
|
- The settings panel was designed well from the start - no chat/voice-specific tabs to remove
|
||||||
|
- Only text update needed: "chats and workflows" → "workflow execution" in ContextViewer
|
||||||
|
|
||||||
|
## Story 11: Remove unused npm packages
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Packages removed:
|
||||||
|
- @ai-sdk/anthropic (was used by Claude chat agent - not imported in src/)
|
||||||
|
- @ai-sdk/react (was used by chat UI hooks - not imported in src/)
|
||||||
|
- Packages verified in use:
|
||||||
|
- @ai-sdk/openai (used by 9 files: workflowExecutor, extraction tools, etc.)
|
||||||
|
- ai (Vercel AI SDK - used by 30 files)
|
||||||
|
- @langchain/* (used by embed-universal.ts)
|
||||||
|
- All other packages verified with grep
|
||||||
|
- Learnings:
|
||||||
|
- No voice-related packages (@openai/realtime-api-beta) were present - already removed
|
||||||
|
- The package.json was relatively clean to begin with
|
||||||
|
- npm install and type-check both pass after removal
|
||||||
|
|
||||||
|
## Story 12: Remove dead types
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files deleted:
|
||||||
|
- src/types/prompts.ts (was used by deleted contextBuilder.ts for Anthropic prompt caching)
|
||||||
|
- Files kept (all verified in use):
|
||||||
|
- analytics.ts (used by 3 files: workflowExecutor, middleware, pricing)
|
||||||
|
- database.ts (used by 25 files - core type definitions)
|
||||||
|
- logs.ts (used by 3 files: logs API, LogsViewer, LogsRow)
|
||||||
|
- views.ts (used by 2 files: ViewFilters, KanbanView)
|
||||||
|
- pdf-parse.d.ts (used by paper extractor)
|
||||||
|
- Learnings:
|
||||||
|
- prompts.ts contained CacheableBlock, SystemPromptResult, CacheStats for Anthropic prompt caching
|
||||||
|
- These were only used by contextBuilder.ts which was deleted in Story 8
|
||||||
|
- Type files are well-organized - each file has a clear purpose
|
||||||
|
|
||||||
|
## Story 13: Audit and clean MCP server
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files modified:
|
||||||
|
- apps/mcp-server/server.js (removed extraction tools)
|
||||||
|
- apps/mcp-server/stdio-server.js (removed extraction tools)
|
||||||
|
- Tools removed (called non-existent API routes):
|
||||||
|
- rah_extract_url (called /api/extract/url - doesn't exist)
|
||||||
|
- rah_extract_youtube (called /api/extract/youtube - doesn't exist)
|
||||||
|
- rah_extract_pdf (called /api/extract/pdf - doesn't exist)
|
||||||
|
- Remaining 11 tools (all verified working):
|
||||||
|
- Node CRUD: rah_add_node, rah_search_nodes, rah_update_node, rah_get_nodes
|
||||||
|
- Edge CRUD: rah_create_edge, rah_query_edges, rah_update_edge
|
||||||
|
- Dimension CRUD: rah_create_dimension, rah_update_dimension, rah_delete_dimension
|
||||||
|
- Semantic search: rah_search_embeddings
|
||||||
|
- Analysis:
|
||||||
|
- MCP servers are pure API clients - no direct code dependencies
|
||||||
|
- All tools use callRaHApi() to call Next.js API routes
|
||||||
|
- No chat/agent/delegation dependencies
|
||||||
|
- Both servers were kept in sync
|
||||||
|
- Learnings:
|
||||||
|
- Extraction tools were dead code - the API routes never existed in the open source version
|
||||||
|
- The MCP architecture is clean - just HTTP calls to local API
|
||||||
|
|
||||||
|
## Story 14: Update README for lite version
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Files modified:
|
||||||
|
- README.md (complete rewrite)
|
||||||
|
- Changes:
|
||||||
|
- Renamed from "RA-H Open Source" to "RA-H Light"
|
||||||
|
- Added "What is RA-H Light?" section explaining the stripped-down purpose
|
||||||
|
- Explicitly list what's removed: chat agents, voice, delegation
|
||||||
|
- Added comprehensive MCP integration section:
|
||||||
|
- Claude Code setup with JSON config example
|
||||||
|
- Table of all 11 available MCP tools
|
||||||
|
- HTTP MCP server instructions
|
||||||
|
- Simplified project layout (removed agents references)
|
||||||
|
- Removed references to 3-panel interface, chat, voice, content extraction
|
||||||
|
- Kept: quick start, platform support, commands, docs links, Linux/Windows setup
|
||||||
|
- Learnings:
|
||||||
|
- The README is the primary entry point - important to set expectations clearly
|
||||||
|
- MCP integration is now the main selling point of RA-H Light
|
||||||
|
- Users need clear setup instructions for Claude Code integration
|
||||||
|
|
||||||
|
## Story 15: Final verification
|
||||||
|
- Completed: Thu 29 Jan 2026
|
||||||
|
- Verification results:
|
||||||
|
- npm install: SUCCESS (824 packages, up to date)
|
||||||
|
- npm run type-check: SUCCESS (no errors)
|
||||||
|
- npm run dev: SUCCESS (starts on localhost:3000)
|
||||||
|
- UI loads: SUCCESS (HTML renders with LeftToolbar, nodes panel visible)
|
||||||
|
- Summary of RA-H Light:
|
||||||
|
- Total files deleted: ~50+ files across chat, voice, delegation systems
|
||||||
|
- Total lines removed: ~5000+ lines of code
|
||||||
|
- Remaining functionality: nodes/edges/dimensions CRUD, workflows, MCP server (11 tools)
|
||||||
|
- 2-panel layout with nodes list and focus panel
|
||||||
|
- Settings modal with API keys, database viewer, external agents panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SPRINT COMPLETE
|
||||||
|
|
||||||
|
All 15 stories passed. RA-H Light is now a minimal knowledge graph UI with MCP server:
|
||||||
|
|
||||||
|
- **Removed:** Chat agents, voice features, delegation system, Anthropic integration
|
||||||
|
- **Kept:** Node/Edge/Dimension CRUD, workflows, MCP server, embeddings, search
|
||||||
|
- **MCP Tools:** 11 working tools for external AI agents
|
||||||
|
- **UI:** 2-panel layout (nodes + focus) with settings modal
|
||||||
|
|
||||||
|
Ready for merge to main.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Ralph Autonomous Agent - RA-H Light Strip-Down
|
||||||
|
|
||||||
|
You are an autonomous coding agent stripping down ra-h_os to create RA-H Light — a minimal knowledge graph UI with MCP server.
|
||||||
|
|
||||||
|
## Your Task
|
||||||
|
|
||||||
|
1. Read `ralph/prd.json` to find user stories with `passes: false`
|
||||||
|
2. Read `ralph/progress.txt` to understand what's been done this sprint
|
||||||
|
3. Check recent git commits for additional context
|
||||||
|
4. Pick ONE incomplete story (lowest ID number where `passes: false`)
|
||||||
|
5. Implement it completely
|
||||||
|
6. Run verification checks
|
||||||
|
7. Commit if all checks pass
|
||||||
|
8. Update prd.json and progress.txt
|
||||||
|
|
||||||
|
## Verification (CRITICAL)
|
||||||
|
|
||||||
|
Before marking ANY story as complete, you MUST run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Type-check must pass. If it fails, fix the issues before continuing.
|
||||||
|
|
||||||
|
**Note:** This project does not have a test suite, so skip `npm run test`.
|
||||||
|
|
||||||
|
**If the story involves UI changes** (story 6+), also verify with agent-browser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser open http://localhost:3000
|
||||||
|
agent-browser snapshot -i # Check interactive elements exist
|
||||||
|
agent-browser screenshot verify.png # Visual sanity check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Story Selection
|
||||||
|
|
||||||
|
Pick the story with the LOWEST ID number where `passes: false`. Stories are ordered by dependency:
|
||||||
|
- Stories 1-5: Remove backend/service code (delegation, voice, chat API)
|
||||||
|
- Story 6-7: Remove UI components (layout simplification, chat components)
|
||||||
|
- Stories 8-12: Cleanup (services, packages, types)
|
||||||
|
- Stories 13-15: Polish (MCP audit, README, final verification)
|
||||||
|
|
||||||
|
## Implementation Rules
|
||||||
|
|
||||||
|
1. **Read first** — Before deleting a file, grep for imports to find all references
|
||||||
|
2. **Delete cleanly** — When removing a file, also remove all imports of it
|
||||||
|
3. **Small commits** — One commit per story
|
||||||
|
4. **Type safety** — After deletion, run type-check to find broken imports
|
||||||
|
5. **Minimal changes** — Only what's needed to complete the story
|
||||||
|
|
||||||
|
## Deletion Strategy
|
||||||
|
|
||||||
|
When deleting files:
|
||||||
|
1. First, `grep -r "import.*from.*'deleted-file'" src/ app/` to find all imports
|
||||||
|
2. Delete the file
|
||||||
|
3. Update/remove all files that imported it
|
||||||
|
4. Run `npm run type-check` to find any missed references
|
||||||
|
5. Fix all type errors before committing
|
||||||
|
|
||||||
|
## After Implementation
|
||||||
|
|
||||||
|
1. Run `npm run type-check` — must pass
|
||||||
|
2. Git commit with message format:
|
||||||
|
```
|
||||||
|
feat(rah-light): [story-id] short description
|
||||||
|
|
||||||
|
- What was done
|
||||||
|
- Files deleted/changed
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
3. Update `ralph/prd.json`: set `passes: true` for completed story, add notes if relevant
|
||||||
|
4. Append to `ralph/progress.txt`:
|
||||||
|
```
|
||||||
|
## Story [id]: [title]
|
||||||
|
- Completed: [timestamp]
|
||||||
|
- Files deleted: [list]
|
||||||
|
- Files modified: [list]
|
||||||
|
- Learnings: [any gotchas]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
When ALL stories in prd.json have `passes: true`, respond with:
|
||||||
|
|
||||||
|
```
|
||||||
|
<promise>COMPLETE</promise>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Paths
|
||||||
|
|
||||||
|
- `ralph/prd.json` — Story status (update passes: true when done)
|
||||||
|
- `ralph/progress.txt` — Sprint learnings (append after each story)
|
||||||
|
|
||||||
|
## Now
|
||||||
|
|
||||||
|
Read the prd.json and progress.txt below. Pick the lowest-numbered incomplete story. Implement it. Verify. Commit. Update status.
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Ralph Single Iteration (Human-in-Loop Mode)
|
||||||
|
# Usage: ./ralph/ralph-once.sh
|
||||||
|
#
|
||||||
|
# Runs one iteration of Ralph interactively, allowing you to observe and steer
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
PRD_FILE="$SCRIPT_DIR/prd.json"
|
||||||
|
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
|
||||||
|
PROMPT_FILE="$SCRIPT_DIR/prompt.md"
|
||||||
|
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " 🐕 Ralph Single Iteration (Interactive)"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
# Verify required files exist
|
||||||
|
if [ ! -f "$PRD_FILE" ]; then
|
||||||
|
echo "❌ Error: $PRD_FILE not found"
|
||||||
|
echo "Create prd.json with your user stories first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PROMPT_FILE" ]; then
|
||||||
|
echo "❌ Error: $PROMPT_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize progress file if needed
|
||||||
|
if [ ! -f "$PROGRESS_FILE" ]; then
|
||||||
|
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
|
||||||
|
echo "Started: $(date)" >> "$PROGRESS_FILE"
|
||||||
|
echo "" >> "$PROGRESS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📋 PRD: $PRD_FILE"
|
||||||
|
echo "📝 Progress: $PROGRESS_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Build the full prompt with current state
|
||||||
|
FULL_PROMPT="$(cat "$PROMPT_FILE")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### prd.json
|
||||||
|
\`\`\`json
|
||||||
|
$(cat "$PRD_FILE")
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### progress.txt
|
||||||
|
\`\`\`
|
||||||
|
$(cat "$PROGRESS_FILE")
|
||||||
|
\`\`\`
|
||||||
|
"
|
||||||
|
|
||||||
|
# Run Claude Code with prompt via stdin
|
||||||
|
echo "$FULL_PROMPT" | claude --print --allowedTools "Edit,Write,Read,Bash,Glob,Grep"
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Ralph Autonomous Agent Loop for RA-H
|
||||||
|
# Usage: ./ralph/ralph.sh [max_iterations]
|
||||||
|
#
|
||||||
|
# Runs Claude Code in a loop to implement user stories from prd.json
|
||||||
|
# Each iteration: picks a story → implements → verifies → commits → updates status
|
||||||
|
|
||||||
|
MAX_ITERATIONS=${1:-10}
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
PRD_FILE="$SCRIPT_DIR/prd.json"
|
||||||
|
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
|
||||||
|
PROMPT_FILE="$SCRIPT_DIR/prompt.md"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${BLUE} 🐕 Ralph Autonomous Agent Loop${NC}"
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
|
||||||
|
# Verify required files exist
|
||||||
|
if [ ! -f "$PRD_FILE" ]; then
|
||||||
|
echo -e "${RED}❌ Error: $PRD_FILE not found${NC}"
|
||||||
|
echo "Create prd.json with your user stories first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PROMPT_FILE" ]; then
|
||||||
|
echo -e "${RED}❌ Error: $PROMPT_FILE not found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize progress file if needed
|
||||||
|
if [ ! -f "$PROGRESS_FILE" ]; then
|
||||||
|
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
|
||||||
|
echo "Started: $(date)" >> "$PROGRESS_FILE"
|
||||||
|
echo "" >> "$PROGRESS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}📋 PRD:${NC} $PRD_FILE"
|
||||||
|
echo -e "${YELLOW}📝 Progress:${NC} $PROGRESS_FILE"
|
||||||
|
echo -e "${YELLOW}🔄 Max iterations:${NC} $MAX_ITERATIONS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
for i in $(seq 1 $MAX_ITERATIONS); do
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}🔁 Iteration $i of $MAX_ITERATIONS${NC}"
|
||||||
|
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build the full prompt with current state
|
||||||
|
FULL_PROMPT="$(cat "$PROMPT_FILE")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### prd.json
|
||||||
|
\`\`\`json
|
||||||
|
$(cat "$PRD_FILE")
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### progress.txt
|
||||||
|
\`\`\`
|
||||||
|
$(cat "$PROGRESS_FILE")
|
||||||
|
\`\`\`
|
||||||
|
"
|
||||||
|
|
||||||
|
# Run Claude Code with the prompt via stdin
|
||||||
|
# Using --print for non-interactive mode
|
||||||
|
OUTPUT=$(echo "$FULL_PROMPT" | claude --print \
|
||||||
|
--allowedTools "Edit,Write,Read,Bash,Glob,Grep" 2>&1) || true
|
||||||
|
|
||||||
|
echo "$OUTPUT"
|
||||||
|
|
||||||
|
# Check for completion signal
|
||||||
|
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${GREEN}✅ Ralph complete! All stories passing.${NC}"
|
||||||
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Brief pause between iterations
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}⏳ Pausing 3s before next iteration...${NC}"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${YELLOW}⚠️ Max iterations ($MAX_ITERATIONS) reached${NC}"
|
||||||
|
echo -e "${YELLOW} Check progress.txt for status${NC}"
|
||||||
|
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
exit 1
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
|||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
interface DelegationIndicatorProps {
|
|
||||||
delegations: AgentDelegation[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DelegationIndicator({ delegations }: DelegationIndicatorProps) {
|
|
||||||
if (!delegations.length) return null;
|
|
||||||
|
|
||||||
const activeCount = delegations.filter((d) => d.status === 'queued' || d.status === 'in_progress').length;
|
|
||||||
|
|
||||||
if (activeCount === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: '#0f0f0f',
|
|
||||||
border: '1px solid #1a1a1a',
|
|
||||||
fontSize: '10px',
|
|
||||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
|
||||||
color: '#6b6b6b'
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: '#22c55e'
|
|
||||||
}} />
|
|
||||||
<span>{activeCount} active</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
interface MiniRAHPanelProps {
|
|
||||||
delegation: AgentDelegation;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
|
||||||
queued: { border: '#1f3a5f', badge: '#5c9aff' },
|
|
||||||
in_progress: { border: '#3b5f2a', badge: '#8bd450' },
|
|
||||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
|
||||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
|
||||||
|
|
||||||
function formatStatus(status: string) {
|
|
||||||
switch (status) {
|
|
||||||
case 'queued':
|
|
||||||
return 'Queued';
|
|
||||||
case 'in_progress':
|
|
||||||
return 'Working';
|
|
||||||
case 'completed':
|
|
||||||
return 'Completed';
|
|
||||||
case 'failed':
|
|
||||||
return 'Failed';
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNodeAwareLine(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
|
||||||
const parts: ReactNode[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
|
|
||||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
|
||||||
if (offset > lastIndex) {
|
|
||||||
parts.push(<span key={`mini-text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeId = Number(id);
|
|
||||||
const handleClick = () => {
|
|
||||||
if (onNodeClick) onNodeClick(nodeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
<button
|
|
||||||
key={`mini-node-${offset}`}
|
|
||||||
type="button"
|
|
||||||
className="node-pill"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
|
||||||
<span className="node-pill-title">{title}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
lastIndex = offset + match.length;
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push(<span key="mini-text-end">{text.slice(lastIndex)}</span>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{parts}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MiniRAHPanel({ delegation, onNodeClick }: MiniRAHPanelProps) {
|
|
||||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
|
||||||
const summaryLines = delegation.summary ? delegation.summary.split('\n').filter(Boolean) : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mini-panel">
|
|
||||||
<header className="mini-header" style={{ borderBottomColor: palette.border }}>
|
|
||||||
<span className="status-dot" style={{ background: palette.badge }} />
|
|
||||||
<span className="header-title">MINI RA-H</span>
|
|
||||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
|
||||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="section">
|
|
||||||
<h3>Task</h3>
|
|
||||||
<p>{delegation.task}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{delegation.context.length > 0 && (
|
|
||||||
<section className="section">
|
|
||||||
<h3>Context</h3>
|
|
||||||
<ul className="context-list">
|
|
||||||
{delegation.context.map((item, idx) => (
|
|
||||||
<li key={idx}>{renderNodeAwareLine(item, onNodeClick)}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{summaryLines.length > 0 && (
|
|
||||||
<section className="section">
|
|
||||||
<h3>Summary</h3>
|
|
||||||
<div className="summary-card">
|
|
||||||
{summaryLines.map((line, idx) => (
|
|
||||||
<p key={idx}>{renderNodeAwareLine(line, onNodeClick)}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.mini-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
background: #0a0a0a;
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid rgba(91, 154, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: #84c8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-status {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #7da0b6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-time {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #5f5f5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: #5c9aff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #d4d4d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-list li {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #9aa7b6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
background: #101010;
|
|
||||||
border: 1px solid rgba(92, 154, 255, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
margin: 0 4px 4px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(92, 154, 255, 0.35);
|
|
||||||
background: rgba(92, 154, 255, 0.08);
|
|
||||||
color: #cfe4ff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease, border 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill:hover {
|
|
||||||
background: rgba(92, 154, 255, 0.18);
|
|
||||||
border-color: rgba(92, 154, 255, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill-id {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
// Stub type for delegation (delegation system removed in rah-light)
|
||||||
|
type AgentDelegation = {
|
||||||
|
id: number;
|
||||||
|
sessionId: string;
|
||||||
|
task: string;
|
||||||
|
status: 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
summary?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface QuickAddStatusProps {
|
interface QuickAddStatusProps {
|
||||||
delegations: AgentDelegation[];
|
delegations: AgentDelegation[];
|
||||||
|
|||||||
@@ -1,706 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Node } from '@/types/database';
|
|
||||||
import AsciiBanner from './AsciiBanner';
|
|
||||||
import TerminalMessage from './TerminalMessage';
|
|
||||||
import TerminalInput from './TerminalInput';
|
|
||||||
import { Zap, Flame } from 'lucide-react';
|
|
||||||
import DelegationIndicator from './DelegationIndicator';
|
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
|
||||||
import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat';
|
|
||||||
import { useQuotaHandler } from '@/hooks/useQuotaHandler';
|
|
||||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
|
||||||
import { useVoiceSession } from './hooks/useVoiceSession';
|
|
||||||
import { useAssistantTTS } from './hooks/useAssistantTTS';
|
|
||||||
import { useRealtimeVoiceClient } from './hooks/useRealtimeVoiceClient';
|
|
||||||
import { useVoiceInterruption } from './hooks/useVoiceInterruption';
|
|
||||||
|
|
||||||
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const createVoiceRequestId = () =>
|
|
||||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
|
|
||||||
interface HighlightedPassage {
|
|
||||||
selectedText: string;
|
|
||||||
nodeId: number;
|
|
||||||
nodeTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RAHChatProps {
|
|
||||||
openTabsData: Node[];
|
|
||||||
activeTabId: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
onClearDimension?: () => void;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
delegations?: AgentDelegation[];
|
|
||||||
messages?: ChatMessage[];
|
|
||||||
setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
|
||||||
mode?: 'easy' | 'hard';
|
|
||||||
delegationMode?: boolean;
|
|
||||||
delegationSessionId?: string;
|
|
||||||
onQuickAdd?: () => void;
|
|
||||||
highlightedPassage?: HighlightedPassage | null;
|
|
||||||
onClearPassage?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RAHChat({
|
|
||||||
openTabsData,
|
|
||||||
activeTabId,
|
|
||||||
activeDimension,
|
|
||||||
onClearDimension: _onClearDimension,
|
|
||||||
onNodeClick,
|
|
||||||
delegations = [],
|
|
||||||
messages: externalMessages,
|
|
||||||
setMessages: externalSetMessages,
|
|
||||||
mode = 'easy',
|
|
||||||
delegationMode = false,
|
|
||||||
delegationSessionId,
|
|
||||||
onQuickAdd: _onQuickAdd,
|
|
||||||
highlightedPassage: _highlightedPassage,
|
|
||||||
onClearPassage: _onClearPassage,
|
|
||||||
}: RAHChatProps) {
|
|
||||||
// Use external state if provided (lifted state), otherwise use local state
|
|
||||||
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const messages = externalMessages !== undefined ? externalMessages : internalMessages;
|
|
||||||
const setMessages = externalSetMessages || internalSetMessages;
|
|
||||||
|
|
||||||
const [sessionId, setSessionId] = useState(() => createSessionId());
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chatMode = mode === 'hard' ? 'hard' : 'easy';
|
|
||||||
const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
|
||||||
const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)';
|
|
||||||
const {
|
|
||||||
quotaError,
|
|
||||||
handleAPIError: handleQuotaApiError,
|
|
||||||
checkQuotaBeforeRequest,
|
|
||||||
refetchUsage,
|
|
||||||
isQuotaExceeded,
|
|
||||||
} = useQuotaHandler();
|
|
||||||
const streamCompleteHandlerRef = useRef<() => Promise<void> | void>(async () => {
|
|
||||||
await refetchUsage();
|
|
||||||
});
|
|
||||||
const setMessagesRef = useRef(setMessages);
|
|
||||||
const voice = useVoiceSession();
|
|
||||||
const {
|
|
||||||
isActive: isVoiceActive,
|
|
||||||
amplitude: voiceAmplitude,
|
|
||||||
startSession: startVoice,
|
|
||||||
stopSession: stopVoice,
|
|
||||||
resetTranscript: resetVoiceTranscript,
|
|
||||||
setStatus: setVoiceStatus,
|
|
||||||
setAmplitude: setVoiceAmplitude,
|
|
||||||
setInterimTranscript,
|
|
||||||
appendFinalTranscript,
|
|
||||||
} = voice;
|
|
||||||
const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]);
|
|
||||||
const assistantSpeechMapRef = useRef<Map<string, string>>(new Map());
|
|
||||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
|
||||||
const voiceErrorHandledRef = useRef(false);
|
|
||||||
const voiceStartTimestampRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const handleVoiceError = useCallback((error: Error) => {
|
|
||||||
console.error('[RAHChat] Voice error:', error);
|
|
||||||
setVoiceError(error.message);
|
|
||||||
if (isVoiceActive) {
|
|
||||||
stopVoice();
|
|
||||||
}
|
|
||||||
resetVoiceTranscript();
|
|
||||||
setVoiceAmplitude(0);
|
|
||||||
setVoiceStatus('idle');
|
|
||||||
pendingVoiceQueueRef.current = [];
|
|
||||||
assistantSpeechMapRef.current.clear();
|
|
||||||
}, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]);
|
|
||||||
|
|
||||||
const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({
|
|
||||||
onSpeechStart: () => {
|
|
||||||
if (isVoiceActive) {
|
|
||||||
setVoiceStatus('speaking');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSpeechComplete: () => {
|
|
||||||
setVoiceStatus(isVoiceActive ? 'listening' : 'idle');
|
|
||||||
},
|
|
||||||
onError: handleVoiceError,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleVoiceInterruption = useCallback(() => {
|
|
||||||
stopAssistantTTS();
|
|
||||||
setVoiceStatus('listening');
|
|
||||||
}, [setVoiceStatus, stopAssistantTTS]);
|
|
||||||
|
|
||||||
const sse = useSSEChat('/api/rah/chat', setMessages, {
|
|
||||||
getAuthToken: () => null,
|
|
||||||
beforeRequest: checkQuotaBeforeRequest,
|
|
||||||
onRequestError: handleQuotaApiError,
|
|
||||||
onStreamComplete: async () => {
|
|
||||||
const handler = streamCompleteHandlerRef.current;
|
|
||||||
if (handler) {
|
|
||||||
await handler();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useVoiceInterruption({
|
|
||||||
amplitude: voiceAmplitude,
|
|
||||||
isVoiceActive,
|
|
||||||
ttsStatus,
|
|
||||||
onInterruption: handleVoiceInterruption,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const sendMessage = useCallback(async (text: string) => {
|
|
||||||
if (delegationMode) return; // Delegation chats are read-only
|
|
||||||
if (isQuotaExceeded) {
|
|
||||||
checkQuotaBeforeRequest();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sse.send({
|
|
||||||
text,
|
|
||||||
history: messages,
|
|
||||||
openTabs: openTabsData,
|
|
||||||
activeTabId,
|
|
||||||
sessionId,
|
|
||||||
mode: chatMode
|
|
||||||
});
|
|
||||||
}, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]);
|
|
||||||
|
|
||||||
const handleVoiceFinalTranscript = useCallback(
|
|
||||||
(raw: string) => {
|
|
||||||
const normalized = raw.trim();
|
|
||||||
console.info('[RAHVoice] Final transcript received:', normalized || '(empty)');
|
|
||||||
setInterimTranscript('');
|
|
||||||
if (!normalized) {
|
|
||||||
console.info('[RAHVoice] Ignoring empty transcript');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appendFinalTranscript(normalized);
|
|
||||||
if (sse.isLoading) {
|
|
||||||
pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() });
|
|
||||||
console.info('[RAHVoice] SSE busy, queueing transcript', {
|
|
||||||
queuedCount: pendingVoiceQueueRef.current.length,
|
|
||||||
});
|
|
||||||
setVoiceStatus('thinking');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVoiceStatus('thinking');
|
|
||||||
console.info('[RAHVoice] Dispatching transcript to /api/rah/chat');
|
|
||||||
void sendMessage(normalized);
|
|
||||||
},
|
|
||||||
[appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading]
|
|
||||||
);
|
|
||||||
|
|
||||||
const voiceRealtime = useRealtimeVoiceClient(
|
|
||||||
{
|
|
||||||
onStatusChange: (status) => {
|
|
||||||
if (!isVoiceActive && status !== 'idle') return;
|
|
||||||
if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) {
|
|
||||||
setVoiceStatus('thinking');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVoiceStatus(status);
|
|
||||||
},
|
|
||||||
onInterimTranscript: setInterimTranscript,
|
|
||||||
onFinalTranscript: handleVoiceFinalTranscript,
|
|
||||||
onAmplitude: setVoiceAmplitude,
|
|
||||||
onError: handleVoiceError,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
getAuthToken: () => null,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStreamComplete = useCallback(async () => {
|
|
||||||
if (pendingVoiceQueueRef.current.length > 0) {
|
|
||||||
console.info('[RAHVoice] SSE stream complete, draining queued transcripts', {
|
|
||||||
queued: pendingVoiceQueueRef.current.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
while (pendingVoiceQueueRef.current.length > 0) {
|
|
||||||
const nextQueued = pendingVoiceQueueRef.current.shift();
|
|
||||||
if (!nextQueued) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const queueLatency = Date.now() - nextQueued.queuedAt;
|
|
||||||
setVoiceStatus('thinking');
|
|
||||||
console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', {
|
|
||||||
queuedMs: queueLatency,
|
|
||||||
});
|
|
||||||
await sendMessage(nextQueued.text);
|
|
||||||
}
|
|
||||||
await refetchUsage();
|
|
||||||
}, [sendMessage, setVoiceStatus, refetchUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
streamCompleteHandlerRef.current = handleStreamComplete;
|
|
||||||
}, [handleStreamComplete]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMessagesRef.current = setMessages;
|
|
||||||
}, [setMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!voiceError) {
|
|
||||||
voiceErrorHandledRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (voiceErrorHandledRef.current) return;
|
|
||||||
voiceErrorHandledRef.current = true;
|
|
||||||
voiceRealtime.stop();
|
|
||||||
stopAssistantTTS();
|
|
||||||
}, [voiceError, voiceRealtime, stopAssistantTTS]);
|
|
||||||
|
|
||||||
const focusSummary = useMemo(() => {
|
|
||||||
if (!openTabsData.length) return null;
|
|
||||||
const titles = openTabsData.map((node) => node?.title || 'Untitled');
|
|
||||||
const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0];
|
|
||||||
const truncate = (value: string, limit = 64) => {
|
|
||||||
if (value.length <= limit) return value;
|
|
||||||
return `${value.slice(0, limit - 1)}…`;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
id: activeNode?.id ?? null,
|
|
||||||
title: truncate(activeNode?.title || 'Untitled'),
|
|
||||||
total: titles.length,
|
|
||||||
};
|
|
||||||
}, [openTabsData, activeTabId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVoiceActive) {
|
|
||||||
assistantSpeechMapRef.current.clear();
|
|
||||||
stopAssistantTTS();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sse.isLoading) return;
|
|
||||||
const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT);
|
|
||||||
if (!assistantMessages.length) return;
|
|
||||||
const latest = assistantMessages[assistantMessages.length - 1];
|
|
||||||
const spokenContent = assistantSpeechMapRef.current.get(latest.id);
|
|
||||||
if (!latest.content.trim() || spokenContent === latest.content) return;
|
|
||||||
assistantSpeechMapRef.current.set(latest.id, latest.content);
|
|
||||||
const voiceRequestId = createVoiceRequestId();
|
|
||||||
speakAssistantResponse(latest.content, {
|
|
||||||
flush: true,
|
|
||||||
metadata: {
|
|
||||||
sessionId,
|
|
||||||
helper: helperKey,
|
|
||||||
requestId: voiceRequestId,
|
|
||||||
messageId: latest.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]);
|
|
||||||
|
|
||||||
|
|
||||||
const handleNewChat = () => {
|
|
||||||
if (delegationMode) return;
|
|
||||||
sse.abort();
|
|
||||||
setMessages((_prev) => []);
|
|
||||||
setSessionId(createSessionId());
|
|
||||||
if (isVoiceActive) {
|
|
||||||
stopVoice();
|
|
||||||
resetVoiceTranscript();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subscribe to delegation stream if in delegation mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (!delegationMode || !delegationSessionId) return;
|
|
||||||
|
|
||||||
const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`);
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.type === 'text-delta' && data.delta) {
|
|
||||||
setMessagesRef.current((prev) => {
|
|
||||||
const lastMsg = prev[prev.length - 1];
|
|
||||||
if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) {
|
|
||||||
const updated = [...prev];
|
|
||||||
updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta };
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
return [...prev, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: MessageRole.ASSISTANT,
|
|
||||||
content: data.delta,
|
|
||||||
timestamp: new Date()
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'tool-input-start' || data.type === 'tool-call') {
|
|
||||||
const toolMessage: ChatMessage = {
|
|
||||||
id: `tool-${data.toolCallId || crypto.randomUUID()}`,
|
|
||||||
role: MessageRole.TOOL,
|
|
||||||
content: data.toolName,
|
|
||||||
timestamp: new Date(),
|
|
||||||
toolName: data.toolName,
|
|
||||||
status: (data.status as ChatMessage['status']) || 'running',
|
|
||||||
toolArgs: data.input ?? data.args ?? data.parameters
|
|
||||||
};
|
|
||||||
setMessagesRef.current((prev) => [...prev, toolMessage]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'tool-output-available' || data.type === 'tool-result') {
|
|
||||||
setMessagesRef.current((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === `tool-${data.toolCallId}`
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`,
|
|
||||||
status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'),
|
|
||||||
toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined),
|
|
||||||
}
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'assistant-message') {
|
|
||||||
setMessagesRef.current((prev) => [...prev, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: MessageRole.ASSISTANT,
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date()
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[RAHChat] Failed to parse delegation stream event:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
console.error('[RAHChat] Delegation stream connection error');
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
}, [delegationMode, delegationSessionId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (delegationMode && isVoiceActive) {
|
|
||||||
voiceRealtime.stop();
|
|
||||||
stopVoice();
|
|
||||||
resetVoiceTranscript();
|
|
||||||
stopAssistantTTS();
|
|
||||||
}
|
|
||||||
}, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]);
|
|
||||||
|
|
||||||
const handleVoiceToggle = useCallback(async () => {
|
|
||||||
if (isVoiceActive) {
|
|
||||||
voiceRealtime.stop();
|
|
||||||
stopVoice();
|
|
||||||
resetVoiceTranscript();
|
|
||||||
setVoiceAmplitude(0);
|
|
||||||
setVoiceStatus('idle');
|
|
||||||
assistantSpeechMapRef.current.clear();
|
|
||||||
pendingVoiceQueueRef.current = [];
|
|
||||||
stopAssistantTTS();
|
|
||||||
voiceStartTimestampRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVoiceError(null);
|
|
||||||
try {
|
|
||||||
voiceStartTimestampRef.current = performance.now();
|
|
||||||
console.info('[RAHVoice] Voice session starting');
|
|
||||||
await voiceRealtime.start();
|
|
||||||
startVoice();
|
|
||||||
setVoiceStatus('listening');
|
|
||||||
} catch (error) {
|
|
||||||
voiceStartTimestampRef.current = null;
|
|
||||||
handleVoiceError(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
handleVoiceError,
|
|
||||||
isVoiceActive,
|
|
||||||
resetVoiceTranscript,
|
|
||||||
setVoiceAmplitude,
|
|
||||||
setVoiceStatus,
|
|
||||||
setVoiceError,
|
|
||||||
startVoice,
|
|
||||||
stopAssistantTTS,
|
|
||||||
stopVoice,
|
|
||||||
voiceRealtime,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: '#0a0a0a'
|
|
||||||
}}>
|
|
||||||
{focusSummary && (
|
|
||||||
<header style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderBottom: '1px solid #1a1a1a',
|
|
||||||
background: '#0a0a0a'
|
|
||||||
}}>
|
|
||||||
{/* Focused node info */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, flex: 1 }}>
|
|
||||||
<span style={{
|
|
||||||
color: '#22c55e',
|
|
||||||
fontSize: '10px',
|
|
||||||
letterSpacing: '0.18em',
|
|
||||||
textTransform: 'uppercase'
|
|
||||||
}}>
|
|
||||||
Focused Node ({focusSummary.total})
|
|
||||||
</span>
|
|
||||||
<span style={{ color: '#d0d0d0', fontSize: '10px' }}>#{focusSummary.id}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: '#f3f3f3',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis'
|
|
||||||
}}
|
|
||||||
title={focusSummary.title}
|
|
||||||
>
|
|
||||||
{focusSummary.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<DelegationIndicator delegations={delegations} />
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px', background: '#0a0a0a' }}>
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<AsciiBanner helperName="ra-h" displayName={helperDisplayName} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<TerminalMessage
|
|
||||||
key={message.id}
|
|
||||||
role={message.role}
|
|
||||||
content={message.content}
|
|
||||||
timestamp={message.timestamp}
|
|
||||||
toolName={message.toolName}
|
|
||||||
status={message.status}
|
|
||||||
toolArgs={message.toolArgs}
|
|
||||||
toolResult={message.toolResult}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* Voice transcript preview removed for streamlined UI */}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!delegationMode && (
|
|
||||||
<div style={{
|
|
||||||
padding: '0 16px 16px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '12px'
|
|
||||||
}}>
|
|
||||||
{(quotaError || isQuotaExceeded) && (
|
|
||||||
<div style={{
|
|
||||||
background: 'rgba(239,68,68,0.12)',
|
|
||||||
border: '1px solid rgba(239,68,68,0.35)',
|
|
||||||
color: '#fca5a5',
|
|
||||||
fontSize: '11px',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
lineHeight: 1.4
|
|
||||||
}}>
|
|
||||||
{quotaError?.message ?? 'Rate limit reached. Please wait a moment and try again.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TerminalInput
|
|
||||||
onSubmit={sendMessage}
|
|
||||||
isProcessing={sse.isLoading || isQuotaExceeded}
|
|
||||||
placeholder={isVoiceActive ? 'voice mode active — end session to type…' : `ask ${helperDisplayName}...`}
|
|
||||||
helperId={activeTabId ?? undefined}
|
|
||||||
disabledExternally={isVoiceActive}
|
|
||||||
disabledMessage="voice mode active"
|
|
||||||
onVoiceToggle={handleVoiceToggle}
|
|
||||||
isVoiceActive={isVoiceActive}
|
|
||||||
voiceAmplitude={voiceAmplitude}
|
|
||||||
voiceError={voiceError || undefined}
|
|
||||||
/>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '12px',
|
|
||||||
flexWrap: 'wrap'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
|
||||||
<ModelSelector chatMode={chatMode} />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleNewChat}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px',
|
|
||||||
transition: 'color 0.2s',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = '#d0d0d0';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = '#fff';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '16px',
|
|
||||||
height: '16px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: '#fff',
|
|
||||||
color: '#0a0a0a',
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: 1,
|
|
||||||
fontWeight: 300,
|
|
||||||
flexShrink: 0
|
|
||||||
}}>+</span>
|
|
||||||
New Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
|
||||||
chatMode: 'easy' | 'hard';
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelSelector({ chatMode }: ModelSelectorProps) {
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)';
|
|
||||||
const Icon = chatMode === 'easy' ? Zap : Flame;
|
|
||||||
const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316';
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' },
|
|
||||||
{ id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' },
|
|
||||||
{ id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #1a1a1a',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: '#0f0f0f',
|
|
||||||
color: '#e5e5e5',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#1a1a1a';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={12} strokeWidth={2.4} color={activeColor} />
|
|
||||||
{currentModel}
|
|
||||||
<span style={{
|
|
||||||
marginLeft: '4px',
|
|
||||||
transform: dropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.2s'
|
|
||||||
}}>▲</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{dropdownOpen && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '100%', /* Open upward */
|
|
||||||
left: '0',
|
|
||||||
marginBottom: '4px',
|
|
||||||
background: '#1a1a1a',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: '6px',
|
|
||||||
minWidth: '150px',
|
|
||||||
zIndex: 1000,
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
|
|
||||||
}}>
|
|
||||||
{options.map((option) => {
|
|
||||||
const OptionIcon = option.icon;
|
|
||||||
const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (!option.disabled && !isActive) {
|
|
||||||
window.dispatchEvent(new CustomEvent('rah:mode-toggle', { detail: { mode: option.id as 'easy' | 'hard' } }));
|
|
||||||
}
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={option.disabled}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: 'none',
|
|
||||||
background: isActive ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
|
||||||
color: option.disabled ? '#666' : (isActive ? '#22c55e' : '#e5e5e5'),
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!option.disabled && !isActive) {
|
|
||||||
e.currentTarget.style.background = '#0a0a0a';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!option.disabled && !isActive) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{OptionIcon && <OptionIcon size={12} strokeWidth={2} color={option.color} />}
|
|
||||||
{!OptionIcon && <div style={{ width: '12px' }} />} {/* Spacer for alignment */}
|
|
||||||
{option.label}
|
|
||||||
{isActive && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, type DragEvent } from 'react';
|
|
||||||
import { Mic, MicOff } from 'lucide-react';
|
|
||||||
|
|
||||||
interface TerminalInputProps {
|
|
||||||
onSubmit: (text: string) => void;
|
|
||||||
isProcessing: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
helperId?: number;
|
|
||||||
disabledExternally?: boolean;
|
|
||||||
disabledMessage?: string;
|
|
||||||
onVoiceToggle?: () => void;
|
|
||||||
isVoiceActive?: boolean;
|
|
||||||
voiceAmplitude?: number;
|
|
||||||
voiceError?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TerminalInput({
|
|
||||||
onSubmit,
|
|
||||||
isProcessing,
|
|
||||||
placeholder,
|
|
||||||
helperId,
|
|
||||||
disabledExternally = false,
|
|
||||||
disabledMessage,
|
|
||||||
onVoiceToggle,
|
|
||||||
isVoiceActive = false,
|
|
||||||
voiceAmplitude = 0,
|
|
||||||
voiceError,
|
|
||||||
}: TerminalInputProps) {
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [rows, setRows] = useState(1);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
|
|
||||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
if (!helperId) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/helpers/${helperId}/prompts`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load prompts:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [helperId]);
|
|
||||||
|
|
||||||
// Auto-resize textarea - only resize when needed to avoid jumps
|
|
||||||
useEffect(() => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) return;
|
|
||||||
|
|
||||||
const lineHeight = 24; // line-height * font-size approximately
|
|
||||||
const minHeight = 32;
|
|
||||||
const maxHeight = 120;
|
|
||||||
|
|
||||||
// Check if content overflows current height (need to grow)
|
|
||||||
const needsGrow = textarea.scrollHeight > textarea.clientHeight;
|
|
||||||
|
|
||||||
// Check if we cleared content significantly (need to shrink)
|
|
||||||
const lineCount = (input.match(/\n/g) || []).length + 1;
|
|
||||||
const estimatedHeight = Math.max(lineCount * lineHeight, minHeight);
|
|
||||||
const needsShrink = !input.trim() || (textarea.clientHeight > estimatedHeight + lineHeight);
|
|
||||||
|
|
||||||
if (needsGrow || needsShrink) {
|
|
||||||
// Only recalculate when necessary
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
const scrollHeight = textarea.scrollHeight;
|
|
||||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
|
||||||
textarea.style.height = `${newHeight}px`;
|
|
||||||
|
|
||||||
const newRows = Math.min(Math.max(1, Math.floor(newHeight / lineHeight)), 5);
|
|
||||||
setRows(newRows);
|
|
||||||
}
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (input.trim() && !isProcessing && !disabledExternally) {
|
|
||||||
// Numeric slash expansion: only when input is exactly /N
|
|
||||||
const m = input.trim().match(/^\/(\d{1,2})$/);
|
|
||||||
if (m) {
|
|
||||||
const n = parseInt(m[1], 10);
|
|
||||||
const idx = n - 1;
|
|
||||||
if (!isNaN(idx) && idx >= 0 && idx < prompts.length) {
|
|
||||||
const content = String(prompts[idx]?.content || '').trim();
|
|
||||||
if (content) {
|
|
||||||
onSubmit(content);
|
|
||||||
setInput('');
|
|
||||||
setRows(1);
|
|
||||||
setShowSlashMenu(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSubmit(input.trim());
|
|
||||||
setInput('');
|
|
||||||
setRows(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
// Slash menu navigation
|
|
||||||
if (showSlashMenu) {
|
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; }
|
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; }
|
|
||||||
if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; }
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const p = prompts[activeIndex];
|
|
||||||
if (p) { setInput(p.content); setShowSlashMenu(false); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') { setShowSlashMenu(false); }
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Toggle slash menu when typing starting '/'
|
|
||||||
const trimmed = input.trimStart();
|
|
||||||
if (trimmed.startsWith('/')) {
|
|
||||||
setShowSlashMenu(true);
|
|
||||||
setActiveIndex(0);
|
|
||||||
} else {
|
|
||||||
setShowSlashMenu(false);
|
|
||||||
}
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const trimmedInput = input.trim();
|
|
||||||
const showVoiceStart = !isVoiceActive && !trimmedInput && Boolean(onVoiceToggle);
|
|
||||||
const showVoiceStop = Boolean(onVoiceToggle) && isVoiceActive;
|
|
||||||
const buttonIsDisabled =
|
|
||||||
showVoiceStart || showVoiceStop
|
|
||||||
? false
|
|
||||||
: (!trimmedInput || isProcessing || disabledExternally);
|
|
||||||
|
|
||||||
const handlePrimaryAction = () => {
|
|
||||||
if (showVoiceStart || showVoiceStop) {
|
|
||||||
onVoiceToggle?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSubmit();
|
|
||||||
};
|
|
||||||
|
|
||||||
const amplitudeBars = Array.from({ length: 8 });
|
|
||||||
|
|
||||||
// Handle node drag over chat input
|
|
||||||
const handleDragOver = (e: DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
// Check if it's a node being dragged (either custom MIME or text/plain fallback)
|
|
||||||
if (e.dataTransfer.types.includes('application/x-rah-node') ||
|
|
||||||
e.dataTransfer.types.includes('application/node-info') ||
|
|
||||||
e.dataTransfer.types.includes('text/plain')) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
setIsDragOver(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
// Only reset if actually leaving the textarea (not entering a child)
|
|
||||||
if (e.currentTarget === e.target) {
|
|
||||||
setIsDragOver(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
// Try application/x-rah-node first (structured data with id + title)
|
|
||||||
let nodeData = e.dataTransfer.getData('application/x-rah-node');
|
|
||||||
if (nodeData) {
|
|
||||||
try {
|
|
||||||
const { id, title } = JSON.parse(nodeData);
|
|
||||||
const token = `[NODE:${id}:"${title}"]`;
|
|
||||||
insertAtCursor(token);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse x-rah-node data:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try application/node-info (from NodesPanel)
|
|
||||||
nodeData = e.dataTransfer.getData('application/node-info');
|
|
||||||
if (nodeData) {
|
|
||||||
try {
|
|
||||||
const { id, title } = JSON.parse(nodeData);
|
|
||||||
const token = `[NODE:${id}:"${title || 'Untitled'}"]`;
|
|
||||||
insertAtCursor(token);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse node-info data:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: use text/plain (might already be formatted as [NODE:id:"title"])
|
|
||||||
const plainText = e.dataTransfer.getData('text/plain');
|
|
||||||
if (plainText && plainText.startsWith('[NODE:')) {
|
|
||||||
insertAtCursor(plainText);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertAtCursor = (text: string) => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) {
|
|
||||||
setInput(prev => prev + text + ' ');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = textarea.selectionStart || 0;
|
|
||||||
const end = textarea.selectionEnd || 0;
|
|
||||||
const before = input.slice(0, start);
|
|
||||||
const after = input.slice(end);
|
|
||||||
|
|
||||||
// Add space before if there's text and it doesn't end with space
|
|
||||||
const needsSpaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n');
|
|
||||||
// Add space after
|
|
||||||
const newText = (needsSpaceBefore ? ' ' : '') + text + ' ';
|
|
||||||
|
|
||||||
setInput(before + newText + after);
|
|
||||||
|
|
||||||
// Set cursor position after the inserted text
|
|
||||||
setTimeout(() => {
|
|
||||||
const newPos = start + newText.length;
|
|
||||||
textarea.setSelectionRange(newPos, newPos);
|
|
||||||
textarea.focus();
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style>{`
|
|
||||||
@keyframes subtle-pulse {
|
|
||||||
0%, 100% { opacity: 0.5; color: #3a3a3a; }
|
|
||||||
50% { opacity: 0.7; color: #22c55e; }
|
|
||||||
}
|
|
||||||
textarea::placeholder {
|
|
||||||
animation: subtle-pulse 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px 16px 12px',
|
|
||||||
background: 'transparent',
|
|
||||||
// Remove separator/border between chat area and input
|
|
||||||
borderTop: 'none',
|
|
||||||
fontFamily: 'inherit'
|
|
||||||
}}>
|
|
||||||
{/* Terminal Prompt Symbol */}
|
|
||||||
<span style={{
|
|
||||||
color: '#4a4a4a',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
paddingTop: '6px',
|
|
||||||
userSelect: 'none',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{'>'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Input Wrapper */}
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '4px'
|
|
||||||
}}>
|
|
||||||
{isVoiceActive && (
|
|
||||||
<div style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0',
|
|
||||||
background: 'transparent',
|
|
||||||
padding: '12px 4px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '12px',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: '#d4d4d4',
|
|
||||||
}}>
|
|
||||||
RA-H is listening
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(24, minmax(0, 1fr))', gap: '3px', height: '20px', marginTop: '6px' }}>
|
|
||||||
{amplitudeBars.map((_, index) => {
|
|
||||||
const level = (index + 1) / amplitudeBars.length;
|
|
||||||
const active = voiceAmplitude >= level - 0.0001;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`amp-${index}`}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: '2px',
|
|
||||||
height: `${10 + index * 1.2}px`,
|
|
||||||
background: active ? '#22c55e' : '#1f1f1f',
|
|
||||||
transition: 'background 120ms ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{voiceError && (
|
|
||||||
<span style={{ color: '#f87171', fontSize: '11px' }}>{voiceError}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Input Row with Textarea and Button */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
disabled={isProcessing || disabledExternally}
|
|
||||||
placeholder={placeholder || `ask ra-h...`}
|
|
||||||
rows={rows}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: isDragOver ? 'rgba(34, 197, 94, 0.08)' : 'transparent',
|
|
||||||
border: isDragOver ? '1px dashed #22c55e' : 'none',
|
|
||||||
borderRadius: isDragOver ? '4px' : '0',
|
|
||||||
color: '#e5e5e5',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
padding: '8px 4px',
|
|
||||||
resize: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
transition: 'all 200ms ease',
|
|
||||||
minHeight: '32px',
|
|
||||||
maxHeight: '120px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
caretColor: '#22c55e',
|
|
||||||
...((isProcessing || disabledExternally) && {
|
|
||||||
opacity: 0.5,
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
caretColor: 'transparent'
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
// No focus border toggling; keep clean
|
|
||||||
onFocus={() => {}}
|
|
||||||
onBlur={() => {}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Slash menu */}
|
|
||||||
{showSlashMenu && prompts.length > 0 && (
|
|
||||||
<div style={{ position: 'absolute', bottom: '48px', left: '40px', background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '6px', minWidth: '260px', maxHeight: '200px', overflowY: 'auto', zIndex: 1000 }}>
|
|
||||||
{prompts.map((p, i) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
onMouseDown={(e) => { e.preventDefault(); setInput(p.content); setShowSlashMenu(false); }}
|
|
||||||
onMouseEnter={() => setActiveIndex(i)}
|
|
||||||
style={{ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 8px', cursor: 'pointer', background: i === activeIndex ? '#1a1a1a' : 'transparent', color: '#cfcfcf', fontSize: '12px' }}
|
|
||||||
>
|
|
||||||
<span style={{ color: '#5c9aff', fontSize: '10px', width: '20px' }}>/ {i + 1}</span>
|
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit Button (minimal icon) */}
|
|
||||||
<button
|
|
||||||
onClick={handlePrimaryAction}
|
|
||||||
disabled={buttonIsDisabled}
|
|
||||||
aria-label={
|
|
||||||
showVoiceStop
|
|
||||||
? 'Stop voice session'
|
|
||||||
: showVoiceStart
|
|
||||||
? 'Start voice session'
|
|
||||||
: isProcessing
|
|
||||||
? 'Processing'
|
|
||||||
: 'Send message'
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
showVoiceStop
|
|
||||||
? 'Stop voice session'
|
|
||||||
: showVoiceStart
|
|
||||||
? 'Start voice session'
|
|
||||||
: disabledExternally
|
|
||||||
? (disabledMessage || 'Voice mode active')
|
|
||||||
: isProcessing
|
|
||||||
? 'Processing…'
|
|
||||||
: 'Send (Enter)'
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
width: '36px',
|
|
||||||
height: '36px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: '#22c55e',
|
|
||||||
border: '2px solid #22c55e',
|
|
||||||
borderRadius: '50%',
|
|
||||||
color: '#0a0a0a',
|
|
||||||
cursor: buttonIsDisabled ? 'not-allowed' : 'pointer',
|
|
||||||
transition: 'all 150ms ease',
|
|
||||||
opacity: buttonIsDisabled ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!buttonIsDisabled) {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
||||||
e.currentTarget.style.boxShadow = `0 4px 12px rgba(${showVoiceStart || showVoiceStop ? '124,58,237' : '34,197,94'}, 0.4)`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!buttonIsDisabled) {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showVoiceStop ? (
|
|
||||||
<MicOff size={16} strokeWidth={2.4} color="#0a0a0a" />
|
|
||||||
) : showVoiceStart ? (
|
|
||||||
<Mic size={16} strokeWidth={2.4} color="#0a0a0a" />
|
|
||||||
) : isProcessing ? (
|
|
||||||
<span style={{ fontSize: '12px' }}>•••</span>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subtle Keyboard Hints */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '10px',
|
|
||||||
fontSize: '10px',
|
|
||||||
color: '#353535',
|
|
||||||
userSelect: 'none',
|
|
||||||
marginTop: '2px'
|
|
||||||
}}>
|
|
||||||
<span>⏎ send</span>
|
|
||||||
<span>⇧⏎ newline</span>
|
|
||||||
{(isProcessing || disabledExternally) && (
|
|
||||||
<span style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
color: disabledExternally ? '#a855f7' : '#ffcc66',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.12em'
|
|
||||||
}}>
|
|
||||||
{disabledExternally ? (disabledMessage || 'voice mode active') : 'processing...'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
|
||||||
import ToolDisplay from '@/components/helpers/ToolDisplay';
|
|
||||||
import MarkdownRenderer from '@/components/helpers/MarkdownRenderer';
|
|
||||||
import ReasoningTrace from '@/components/helpers/ReasoningTrace';
|
|
||||||
import { extractToolContext, extractSources } from '@/utils/toolFormatting';
|
|
||||||
|
|
||||||
interface TerminalMessageProps {
|
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking';
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
toolName?: string;
|
|
||||||
status?: 'sending' | 'delivered' | 'error' | 'processing' | 'starting' | 'running' | 'complete';
|
|
||||||
toolArgs?: any;
|
|
||||||
toolResult?: any;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// no local state needed
|
|
||||||
|
|
||||||
export default function TerminalMessage({
|
|
||||||
role,
|
|
||||||
content,
|
|
||||||
timestamp,
|
|
||||||
toolName,
|
|
||||||
status = 'delivered',
|
|
||||||
toolArgs,
|
|
||||||
toolResult,
|
|
||||||
onNodeClick
|
|
||||||
}: TerminalMessageProps) {
|
|
||||||
|
|
||||||
const dotColor = useMemo(() => {
|
|
||||||
const colors = {
|
|
||||||
user: '#5c9aff',
|
|
||||||
assistant: '#52d97a',
|
|
||||||
tool: '#ffcc66',
|
|
||||||
thinking: '#b794f6',
|
|
||||||
system: '#69d2e7'
|
|
||||||
};
|
|
||||||
return colors[role] || colors.assistant;
|
|
||||||
}, [role]);
|
|
||||||
|
|
||||||
const isAnimated = role === 'thinking' || status === 'processing';
|
|
||||||
|
|
||||||
const formatTime = (date: Date) => {
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
}).toLowerCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle tool messages specially
|
|
||||||
if (role === 'tool') {
|
|
||||||
// THINK tool: display structured trace when present in toolResult
|
|
||||||
if (toolName === 'think') {
|
|
||||||
const trace = toolResult?.data?.trace || null;
|
|
||||||
return <ReasoningTrace trace={trace} collapsible />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic tool display with context and sources
|
|
||||||
const context =
|
|
||||||
extractToolContext(toolName, toolArgs) ||
|
|
||||||
(toolName === 'webSearch' && toolResult?.data?.query ? `Searching for: ${String(toolResult.data.query)}` : undefined);
|
|
||||||
const sources = extractSources(toolName, toolResult);
|
|
||||||
const normalizedStatus =
|
|
||||||
status === 'processing' ? 'running' : status === 'delivered' ? 'complete' : ((status as any) || 'complete');
|
|
||||||
return (
|
|
||||||
<ToolDisplay
|
|
||||||
name={toolName || 'tool'}
|
|
||||||
status={normalizedStatus}
|
|
||||||
context={context}
|
|
||||||
sources={sources}
|
|
||||||
result={toolResult}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '8px 0',
|
|
||||||
fontFamily: 'inherit'
|
|
||||||
}}>
|
|
||||||
{/* Status Dot */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
marginTop: '7px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: dotColor,
|
|
||||||
transition: 'all 150ms ease',
|
|
||||||
...(isAnimated && {
|
|
||||||
animation: 'pulse 1.5s infinite'
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Message Content */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{content ? (
|
|
||||||
<MarkdownRenderer content={content} streaming={status === 'processing'} onNodeClick={onNodeClick} />
|
|
||||||
) : (
|
|
||||||
role === 'thinking' ? <span>Thinking...</span> : null
|
|
||||||
)}
|
|
||||||
{/* References block for assistant messages: extract [Title](URL) */}
|
|
||||||
{role === 'assistant' && typeof content === 'string' && (() => {
|
|
||||||
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
|
||||||
const refs: Array<{ title: string; url: string }> = [];
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = linkRegex.exec(content)) !== null) {
|
|
||||||
refs.push({ title: m[1], url: m[2] });
|
|
||||||
if (refs.length >= 12) break;
|
|
||||||
}
|
|
||||||
if (refs.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<div style={{ color: '#8a8a8a', fontSize: 11, marginBottom: 4 }}>References</div>
|
|
||||||
<div style={{ display: 'grid', gap: 4 }}>
|
|
||||||
{refs.map((r, i) => (
|
|
||||||
<div key={i} style={{ color: '#bdbdbd', fontSize: 12 }}>
|
|
||||||
{i + 1}. <span style={{ color: '#e5e5e5' }}>{r.title}</span> — <a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>{r.url}</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
marginTop: '4px',
|
|
||||||
color: '#4a4a4a',
|
|
||||||
fontSize: '11px'
|
|
||||||
}}>
|
|
||||||
{formatTime(timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import { Fragment, ReactNode, useMemo } from 'react';
|
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
|
||||||
queued: { border: '#3a2f5f', badge: '#a78bfa' },
|
|
||||||
in_progress: { border: '#4a3a6f', badge: '#8b5cf6' },
|
|
||||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
|
||||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
|
||||||
const SUMMARY_HEADERS = ['Task', 'Actions', 'Result', 'Nodes', 'Follow-up'];
|
|
||||||
|
|
||||||
interface WiseRAHPanelProps {
|
|
||||||
delegation: AgentDelegation;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStatus(status: string) {
|
|
||||||
switch (status) {
|
|
||||||
case 'queued':
|
|
||||||
return 'Queued';
|
|
||||||
case 'in_progress':
|
|
||||||
return 'Planning';
|
|
||||||
case 'completed':
|
|
||||||
return 'Completed';
|
|
||||||
case 'failed':
|
|
||||||
return 'Failed';
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNodeAwareText(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
|
||||||
const segments: ReactNode[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
|
|
||||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
|
||||||
if (offset > lastIndex) {
|
|
||||||
segments.push(<span key={`text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
|
||||||
}
|
|
||||||
const nodeId = Number(id);
|
|
||||||
const handleClick = () => {
|
|
||||||
if (onNodeClick) {
|
|
||||||
onNodeClick(nodeId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
segments.push(
|
|
||||||
<button
|
|
||||||
key={`node-${offset}`}
|
|
||||||
type="button"
|
|
||||||
className="node-pill"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
|
||||||
<span className="node-pill-title">{title}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
lastIndex = offset + match.length;
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
segments.push(<span key={`text-end`}>{text.slice(lastIndex)}</span>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{segments}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSummary(summary: string) {
|
|
||||||
const lines = summary.split('\n');
|
|
||||||
const sections: Array<{ title: string; lines: string[] }> = [];
|
|
||||||
let current: { title: string; lines: string[] } | null = null;
|
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
const header = SUMMARY_HEADERS.find(h => line.toLowerCase().startsWith(`${h.toLowerCase()}:`));
|
|
||||||
if (header) {
|
|
||||||
const content = line.slice(header.length + 1).trim();
|
|
||||||
current = { title: header, lines: [] };
|
|
||||||
if (content) current.lines.push(content);
|
|
||||||
sections.push(current);
|
|
||||||
} else if (current) {
|
|
||||||
current.lines.push(line);
|
|
||||||
} else {
|
|
||||||
if (!current) {
|
|
||||||
current = { title: 'Summary', lines: [] };
|
|
||||||
sections.push(current);
|
|
||||||
}
|
|
||||||
current.lines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSectionLines(lines: string[], onNodeClick?: (nodeId: number) => void) {
|
|
||||||
const hasBullet = lines.some(line => /^[-*•]/.test(line.trim()));
|
|
||||||
|
|
||||||
if (hasBullet) {
|
|
||||||
return (
|
|
||||||
<ul className="summary-list">
|
|
||||||
{lines.map((line, idx) => {
|
|
||||||
const text = line.replace(/^[-*•]\s*/, '').trim();
|
|
||||||
return (
|
|
||||||
<li key={idx}>
|
|
||||||
{renderNodeAwareText(text, onNodeClick)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="summary-paragraphs">
|
|
||||||
{lines.map((line, idx) => (
|
|
||||||
<p key={idx}>{renderNodeAwareText(line, onNodeClick)}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WiseRAHPanel({ delegation, onNodeClick }: WiseRAHPanelProps) {
|
|
||||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
|
||||||
|
|
||||||
const parsedSummary = useMemo(() => {
|
|
||||||
if (!delegation.summary) return [];
|
|
||||||
return parseSummary(delegation.summary);
|
|
||||||
}, [delegation.summary]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wise-panel">
|
|
||||||
<header className="wise-header" style={{ borderBottomColor: palette.border }}>
|
|
||||||
<span className="status-dot" style={{ background: palette.badge }} />
|
|
||||||
<span className="header-title">WISE RA-H</span>
|
|
||||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
|
||||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="section">
|
|
||||||
<h3>Goal</h3>
|
|
||||||
<p>{delegation.task}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{delegation.context.length > 0 && (
|
|
||||||
<section className="section">
|
|
||||||
<h3>Context</h3>
|
|
||||||
<ul className="context-list">
|
|
||||||
{delegation.context.map((item, idx) => (
|
|
||||||
<li key={idx}>{renderNodeAwareText(item, onNodeClick)}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{parsedSummary.length > 0 && (
|
|
||||||
<section className="section">
|
|
||||||
<h3>Summary</h3>
|
|
||||||
<div className="summary-card">
|
|
||||||
{parsedSummary.map(section => (
|
|
||||||
<div key={section.title} className="summary-section">
|
|
||||||
<div className="summary-title">{section.title}</div>
|
|
||||||
{renderSectionLines(section.lines, onNodeClick)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.wise-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
padding: 24px;
|
|
||||||
background: #0a0a0a;
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wise-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid rgba(167, 139, 250, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: #bba4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-status {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8b82a6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-time {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #5f5f5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #d4d4d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-list li {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #a0a0a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
background: #101010;
|
|
||||||
border: 1px solid rgba(167, 139, 250, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c5b5ff;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-paragraphs p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #e5e5e5;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-list li {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #e5e5e5;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin: 0 4px 4px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(167, 139, 250, 0.3);
|
|
||||||
background: rgba(167, 139, 250, 0.08);
|
|
||||||
color: #d8d3ff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease, border 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill:hover {
|
|
||||||
background: rgba(167, 139, 250, 0.18);
|
|
||||||
border: 1px solid rgba(167, 139, 250, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill-id {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-pill-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
export type TTSStatus = 'idle' | 'loading' | 'speaking';
|
|
||||||
|
|
||||||
interface UseAssistantTTSOptions {
|
|
||||||
voice?: string;
|
|
||||||
onSpeechStart?: () => void;
|
|
||||||
onSpeechComplete?: () => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeakRequestMetadata {
|
|
||||||
sessionId?: string | null;
|
|
||||||
helper?: string | null;
|
|
||||||
requestId?: string;
|
|
||||||
messageId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeakOptions {
|
|
||||||
flush?: boolean;
|
|
||||||
metadata?: SpeakRequestMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpeakQueueItem = {
|
|
||||||
text: string;
|
|
||||||
metadata?: SpeakRequestMetadata;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useAssistantTTS(options: UseAssistantTTSOptions = {}) {
|
|
||||||
const [status, setStatus] = useState<TTSStatus>('idle');
|
|
||||||
const queueRef = useRef<SpeakQueueItem[]>([]);
|
|
||||||
const isProcessingRef = useRef(false);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const objectUrlRef = useRef<string | null>(null);
|
|
||||||
const mediaSourceRef = useRef<MediaSource | null>(null);
|
|
||||||
const sourceBufferRef = useRef<SourceBuffer | null>(null);
|
|
||||||
const chunkQueueRef = useRef<ArrayBuffer[]>([]);
|
|
||||||
const updateEndHandlerRef = useRef<(() => void) | null>(null);
|
|
||||||
const readerDoneRef = useRef(false);
|
|
||||||
const processQueueRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
const onSpeechStartRef = useRef(options.onSpeechStart);
|
|
||||||
const onSpeechCompleteRef = useRef(options.onSpeechComplete);
|
|
||||||
const onErrorRef = useRef(options.onError);
|
|
||||||
const voiceRef = useRef(options.voice);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSpeechStartRef.current = options.onSpeechStart;
|
|
||||||
}, [options.onSpeechStart]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSpeechCompleteRef.current = options.onSpeechComplete;
|
|
||||||
}, [options.onSpeechComplete]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onErrorRef.current = options.onError;
|
|
||||||
}, [options.onError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
voiceRef.current = options.voice;
|
|
||||||
}, [options.voice]);
|
|
||||||
|
|
||||||
const setStatusSafe = useCallback((next: TTSStatus) => {
|
|
||||||
setStatus(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cleanupMedia = useCallback(() => {
|
|
||||||
if (sourceBufferRef.current && updateEndHandlerRef.current) {
|
|
||||||
try {
|
|
||||||
sourceBufferRef.current.removeEventListener('updateend', updateEndHandlerRef.current);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[TTS] Failed to detach source buffer listener', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateEndHandlerRef.current = null;
|
|
||||||
sourceBufferRef.current = null;
|
|
||||||
mediaSourceRef.current = null;
|
|
||||||
if (objectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(objectUrlRef.current);
|
|
||||||
objectUrlRef.current = null;
|
|
||||||
}
|
|
||||||
chunkQueueRef.current = [];
|
|
||||||
readerDoneRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleError = useCallback((error: Error) => {
|
|
||||||
console.error('[TTS] Playback error', error);
|
|
||||||
onErrorRef.current?.(error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopCurrentPlayback = useCallback(() => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
abortRef.current = null;
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (audio) {
|
|
||||||
audio.pause();
|
|
||||||
audio.removeAttribute('src');
|
|
||||||
try {
|
|
||||||
audio.load();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[TTS] Failed to reset audio element', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupMedia();
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
}, [cleanupMedia]);
|
|
||||||
|
|
||||||
const ensureAudioElement = useCallback(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
return audioRef.current;
|
|
||||||
}
|
|
||||||
const audio = new Audio();
|
|
||||||
audio.autoplay = true;
|
|
||||||
audio.preload = 'auto';
|
|
||||||
audio.addEventListener('playing', () => {
|
|
||||||
setStatusSafe('speaking');
|
|
||||||
onSpeechStartRef.current?.();
|
|
||||||
});
|
|
||||||
audio.addEventListener('ended', () => {
|
|
||||||
cleanupMedia();
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
setStatusSafe('idle');
|
|
||||||
onSpeechCompleteRef.current?.();
|
|
||||||
processQueueRef.current?.();
|
|
||||||
});
|
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
handleError(new Error('Audio playback failed'));
|
|
||||||
stopCurrentPlayback();
|
|
||||||
processQueueRef.current?.();
|
|
||||||
});
|
|
||||||
audioRef.current = audio;
|
|
||||||
return audio;
|
|
||||||
}, [cleanupMedia, handleError, setStatusSafe, stopCurrentPlayback]);
|
|
||||||
|
|
||||||
const flushChunks = useCallback(() => {
|
|
||||||
const sourceBuffer = sourceBufferRef.current;
|
|
||||||
const mediaSource = mediaSourceRef.current;
|
|
||||||
if (!sourceBuffer || !mediaSource || sourceBuffer.updating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextChunk = chunkQueueRef.current.shift();
|
|
||||||
if (nextChunk) {
|
|
||||||
try {
|
|
||||||
sourceBuffer.appendBuffer(nextChunk);
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (readerDoneRef.current && !sourceBuffer.updating) {
|
|
||||||
try {
|
|
||||||
mediaSource.endOfStream();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[TTS] Failed to end media source stream', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [handleError]);
|
|
||||||
|
|
||||||
const processQueue = useCallback(async () => {
|
|
||||||
if (isProcessingRef.current) return;
|
|
||||||
const next = queueRef.current.shift();
|
|
||||||
if (!next) {
|
|
||||||
setStatusSafe('idle');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isProcessingRef.current = true;
|
|
||||||
setStatusSafe('loading');
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
const payload: Record<string, any> = {
|
|
||||||
text: next.text,
|
|
||||||
voice: voiceRef.current,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (next.metadata?.sessionId) {
|
|
||||||
payload.sessionId = next.metadata.sessionId;
|
|
||||||
}
|
|
||||||
if (next.metadata?.helper) {
|
|
||||||
payload.helper = next.metadata.helper;
|
|
||||||
}
|
|
||||||
if (next.metadata?.requestId) {
|
|
||||||
payload.requestId = next.metadata.requestId;
|
|
||||||
}
|
|
||||||
if (next.metadata?.messageId) {
|
|
||||||
payload.messageId = next.metadata.messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/voice/tts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error((await response.text().catch(() => '')) || 'Failed to synthesize audio');
|
|
||||||
}
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const audio = ensureAudioElement();
|
|
||||||
cleanupMedia();
|
|
||||||
const mimeType = response.headers.get('Content-Type') || 'audio/mpeg';
|
|
||||||
const mediaSource = new MediaSource();
|
|
||||||
mediaSourceRef.current = mediaSource;
|
|
||||||
const objectUrl = URL.createObjectURL(mediaSource);
|
|
||||||
objectUrlRef.current = objectUrl;
|
|
||||||
audio.src = objectUrl;
|
|
||||||
audio.load();
|
|
||||||
|
|
||||||
const onSourceOpen = () => {
|
|
||||||
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
|
||||||
let sourceBuffer: SourceBuffer;
|
|
||||||
try {
|
|
||||||
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Unsupported audio format: ${mimeType}`);
|
|
||||||
}
|
|
||||||
sourceBufferRef.current = sourceBuffer;
|
|
||||||
const handleUpdateEnd = () => flushChunks();
|
|
||||||
updateEndHandlerRef.current = handleUpdateEnd;
|
|
||||||
sourceBuffer.addEventListener('updateend', handleUpdateEnd);
|
|
||||||
flushChunks();
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
|
||||||
|
|
||||||
const pump = async () => {
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
readerDoneRef.current = true;
|
|
||||||
flushChunks();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (value) {
|
|
||||||
const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
|
||||||
chunkQueueRef.current.push(buffer);
|
|
||||||
flushChunks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pump().catch((error) => {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
if ((err as DOMException).name !== 'AbortError') {
|
|
||||||
handleError(err);
|
|
||||||
}
|
|
||||||
stopCurrentPlayback();
|
|
||||||
processQueueRef.current?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.play().catch((error) => {
|
|
||||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
setStatusSafe('idle');
|
|
||||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
processQueueRef.current?.();
|
|
||||||
}
|
|
||||||
}, [cleanupMedia, ensureAudioElement, flushChunks, handleError, setStatusSafe, stopCurrentPlayback]);
|
|
||||||
|
|
||||||
processQueueRef.current = processQueue;
|
|
||||||
|
|
||||||
const speak = useCallback(
|
|
||||||
(text: string, options?: SpeakOptions) => {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
if (options?.flush) {
|
|
||||||
queueRef.current = [{ text: trimmed, metadata: options.metadata }];
|
|
||||||
stopCurrentPlayback();
|
|
||||||
} else {
|
|
||||||
queueRef.current.push({ text: trimmed, metadata: options?.metadata });
|
|
||||||
}
|
|
||||||
processQueue();
|
|
||||||
},
|
|
||||||
[processQueue, stopCurrentPlayback]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
queueRef.current = [];
|
|
||||||
stopCurrentPlayback();
|
|
||||||
setStatusSafe('idle');
|
|
||||||
}, [setStatusSafe, stopCurrentPlayback]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [stop]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
speak,
|
|
||||||
stop,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export type RealtimeConnectionState = 'idle' | 'connecting' | 'ready' | 'capturing';
|
|
||||||
|
|
||||||
interface VoiceRealtimeCallbacks {
|
|
||||||
onStatusChange?: (status: 'idle' | 'listening' | 'thinking' | 'speaking') => void;
|
|
||||||
onInterimTranscript?: (text: string) => void;
|
|
||||||
onFinalTranscript?: (text: string) => void;
|
|
||||||
onAmplitude?: (value: number) => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseRealtimeVoiceClientOptions {
|
|
||||||
getAuthToken?: () => string | null | undefined;
|
|
||||||
fetchEphemeralToken?: (
|
|
||||||
authToken: string | null
|
|
||||||
) => Promise<{ client_secret: { value: string }; model: string; voice: string }>;
|
|
||||||
silenceThresholdMs?: number;
|
|
||||||
silenceAmplitudeCutoff?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SILENCE_THRESHOLD_MS = 800;
|
|
||||||
const DEFAULT_SILENCE_AMPLITUDE = 0.0015;
|
|
||||||
|
|
||||||
type Nullable<T> = T | null;
|
|
||||||
|
|
||||||
function calculateRms(buffer: Float32Array) {
|
|
||||||
if (!buffer.length) return 0;
|
|
||||||
let sumSquares = 0;
|
|
||||||
for (let i = 0; i < buffer.length; i += 1) {
|
|
||||||
const value = buffer[i];
|
|
||||||
sumSquares += value * value;
|
|
||||||
}
|
|
||||||
return Math.sqrt(sumSquares / buffer.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useRealtimeVoiceClient(
|
|
||||||
callbacks: VoiceRealtimeCallbacks,
|
|
||||||
options: UseRealtimeVoiceClientOptions = {}
|
|
||||||
) {
|
|
||||||
const { onStatusChange, onInterimTranscript, onFinalTranscript, onAmplitude, onError } = callbacks;
|
|
||||||
|
|
||||||
const { getAuthToken, fetchEphemeralToken, silenceThresholdMs, silenceAmplitudeCutoff } = options;
|
|
||||||
|
|
||||||
const connectionStateRef = useRef<RealtimeConnectionState>('idle');
|
|
||||||
const peerConnectionRef = useRef<Nullable<RTCPeerConnection>>(null);
|
|
||||||
const dataChannelRef = useRef<Nullable<RTCDataChannel>>(null);
|
|
||||||
const audioContextRef = useRef<Nullable<AudioContext>>(null);
|
|
||||||
const processorNodeRef = useRef<Nullable<ScriptProcessorNode>>(null);
|
|
||||||
const mediaStreamRef = useRef<Nullable<MediaStream>>(null);
|
|
||||||
const mediaSourceRef = useRef<Nullable<MediaStreamAudioSourceNode>>(null);
|
|
||||||
const awaitingTranscriptRef = useRef(false);
|
|
||||||
const hasUncommittedAudioRef = useRef(false);
|
|
||||||
const lastSpeechAtRef = useRef<number | null>(null);
|
|
||||||
const destroyedRef = useRef(false);
|
|
||||||
const channelReadyRef = useRef(false);
|
|
||||||
|
|
||||||
const silenceWindowMs = silenceThresholdMs ?? DEFAULT_SILENCE_THRESHOLD_MS;
|
|
||||||
const amplitudeGate = silenceAmplitudeCutoff ?? DEFAULT_SILENCE_AMPLITUDE;
|
|
||||||
|
|
||||||
const onStatusChangeRef = useRef(onStatusChange);
|
|
||||||
const onInterimTranscriptRef = useRef(onInterimTranscript);
|
|
||||||
const onFinalTranscriptRef = useRef(onFinalTranscript);
|
|
||||||
const onAmplitudeRef = useRef(onAmplitude);
|
|
||||||
const onErrorRef = useRef(onError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onStatusChangeRef.current = onStatusChange;
|
|
||||||
}, [onStatusChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onInterimTranscriptRef.current = onInterimTranscript;
|
|
||||||
}, [onInterimTranscript]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onFinalTranscriptRef.current = onFinalTranscript;
|
|
||||||
}, [onFinalTranscript]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onAmplitudeRef.current = onAmplitude;
|
|
||||||
}, [onAmplitude]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onErrorRef.current = onError;
|
|
||||||
}, [onError]);
|
|
||||||
|
|
||||||
const setConnectionState = useCallback((next: RealtimeConnectionState) => {
|
|
||||||
connectionStateRef.current = next;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const teardownInputNodes = useCallback(() => {
|
|
||||||
processorNodeRef.current?.disconnect();
|
|
||||||
mediaSourceRef.current?.disconnect();
|
|
||||||
processorNodeRef.current?.removeEventListener('audioprocess', () => undefined);
|
|
||||||
processorNodeRef.current = null;
|
|
||||||
mediaSourceRef.current = null;
|
|
||||||
if (mediaStreamRef.current) {
|
|
||||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
|
||||||
mediaStreamRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closePeerConnection = useCallback(() => {
|
|
||||||
if (dataChannelRef.current) {
|
|
||||||
try {
|
|
||||||
dataChannelRef.current.close();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[VoiceRealtime] Failed to close data channel:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataChannelRef.current = null;
|
|
||||||
|
|
||||||
if (peerConnectionRef.current) {
|
|
||||||
try {
|
|
||||||
peerConnectionRef.current.ontrack = null;
|
|
||||||
peerConnectionRef.current.onconnectionstatechange = null;
|
|
||||||
peerConnectionRef.current.close();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[VoiceRealtime] Failed to close peer connection:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
peerConnectionRef.current = null;
|
|
||||||
|
|
||||||
channelReadyPromiseRef.current = null;
|
|
||||||
channelReadyResolveRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
awaitingTranscriptRef.current = false;
|
|
||||||
hasUncommittedAudioRef.current = false;
|
|
||||||
lastSpeechAtRef.current = null;
|
|
||||||
channelReadyPromiseRef.current = null;
|
|
||||||
channelReadyResolveRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const notifyError = useCallback((message: string | Error) => {
|
|
||||||
const error = message instanceof Error ? message : new Error(message);
|
|
||||||
onErrorRef.current?.(error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const channelReadyPromiseRef = useRef<Promise<void> | null>(null);
|
|
||||||
const channelReadyResolveRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
const ensureChannelReady = useCallback(async () => {
|
|
||||||
const channel = dataChannelRef.current;
|
|
||||||
if (channel?.readyState === 'open') return;
|
|
||||||
if (!channelReadyPromiseRef.current) {
|
|
||||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
|
||||||
channelReadyResolveRef.current = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await channelReadyPromiseRef.current;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendEvent = useCallback(
|
|
||||||
async (event: Record<string, unknown>) => {
|
|
||||||
await ensureChannelReady();
|
|
||||||
const channel = dataChannelRef.current;
|
|
||||||
if (!channel || channel.readyState !== 'open') {
|
|
||||||
throw new Error('Realtime data channel is not open');
|
|
||||||
}
|
|
||||||
channel.send(JSON.stringify(event));
|
|
||||||
},
|
|
||||||
[ensureChannelReady]
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialiseMicrophone = useCallback(async () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
throw new Error('Voice not supported in this environment');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: {
|
|
||||||
channelCount: 1,
|
|
||||||
echoCancellation: false,
|
|
||||||
autoGainControl: false,
|
|
||||||
noiseSuppression: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (destroyedRef.current) {
|
|
||||||
stream.getTracks().forEach((track) => {
|
|
||||||
try {
|
|
||||||
track.stop();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[VoiceRealtime] Failed to stop track after destroy', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioContext = new AudioContext();
|
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
|
||||||
const processor = audioContext.createScriptProcessor(2048, 1, 1);
|
|
||||||
|
|
||||||
processor.onaudioprocess = (event) => {
|
|
||||||
if (destroyedRef.current) return;
|
|
||||||
const inputBuffer = event.inputBuffer.getChannelData(0);
|
|
||||||
const amplitude = calculateRms(inputBuffer);
|
|
||||||
onAmplitudeRef.current?.(Math.min(1, amplitude * 8));
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const channelReady = channelReadyRef.current;
|
|
||||||
if (amplitude > amplitudeGate && channelReady) {
|
|
||||||
hasUncommittedAudioRef.current = true;
|
|
||||||
lastSpeechAtRef.current = now;
|
|
||||||
if (!awaitingTranscriptRef.current) {
|
|
||||||
onStatusChangeRef.current?.('listening');
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
channelReady &&
|
|
||||||
hasUncommittedAudioRef.current &&
|
|
||||||
!awaitingTranscriptRef.current &&
|
|
||||||
lastSpeechAtRef.current &&
|
|
||||||
now - lastSpeechAtRef.current > silenceWindowMs
|
|
||||||
) {
|
|
||||||
awaitingTranscriptRef.current = true;
|
|
||||||
hasUncommittedAudioRef.current = false;
|
|
||||||
lastSpeechAtRef.current = null;
|
|
||||||
onStatusChangeRef.current?.('thinking');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
source.connect(processor);
|
|
||||||
processor.connect(audioContext.destination);
|
|
||||||
|
|
||||||
audioContextRef.current = audioContext;
|
|
||||||
processorNodeRef.current = processor;
|
|
||||||
mediaSourceRef.current = source;
|
|
||||||
mediaStreamRef.current = stream;
|
|
||||||
return stream;
|
|
||||||
}, [amplitudeGate, silenceWindowMs]);
|
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
|
||||||
destroyedRef.current = true;
|
|
||||||
teardownInputNodes();
|
|
||||||
closePeerConnection();
|
|
||||||
audioContextRef.current?.close().catch(() => undefined);
|
|
||||||
audioContextRef.current = null;
|
|
||||||
resetState();
|
|
||||||
channelReadyRef.current = false;
|
|
||||||
setConnectionState('idle');
|
|
||||||
onStatusChangeRef.current?.('idle');
|
|
||||||
onAmplitudeRef.current?.(0);
|
|
||||||
}, [closePeerConnection, resetState, setConnectionState, teardownInputNodes]);
|
|
||||||
|
|
||||||
const extractTextDelta = useCallback((payload: unknown): string => {
|
|
||||||
if (!payload) return '';
|
|
||||||
if (typeof payload === 'string') return payload;
|
|
||||||
if (typeof payload !== 'object') return '';
|
|
||||||
|
|
||||||
const record = payload as Record<string, unknown>;
|
|
||||||
if (typeof record.delta === 'string') return record.delta;
|
|
||||||
if (typeof record.text === 'string') return record.text;
|
|
||||||
|
|
||||||
const outputText = record.output_text as Record<string, unknown> | undefined;
|
|
||||||
if (typeof outputText?.text === 'string') return outputText.text;
|
|
||||||
|
|
||||||
if (Array.isArray(record.output)) {
|
|
||||||
return record.output
|
|
||||||
.flatMap((item) =>
|
|
||||||
Array.isArray((item as Record<string, unknown>)?.content)
|
|
||||||
? ((item as Record<string, unknown>).content as unknown[])
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
.map((content) => {
|
|
||||||
if (!content || typeof content !== 'object') return '';
|
|
||||||
const entry = content as Record<string, unknown>;
|
|
||||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
|
||||||
if (typeof nestedText === 'string') return nestedText;
|
|
||||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
|
||||||
return nestedText.value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(record.content)) {
|
|
||||||
return record.content
|
|
||||||
.map((content) => {
|
|
||||||
if (!content || typeof content !== 'object') return '';
|
|
||||||
const entry = content as Record<string, unknown>;
|
|
||||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
|
||||||
if (typeof nestedText === 'string') return nestedText;
|
|
||||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
|
||||||
return nestedText.value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDataMessage = useCallback(
|
|
||||||
(raw: string) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(raw);
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug('[VoiceRealtime] Event', data.type, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'conversation.item.input_audio_transcription.completed') {
|
|
||||||
const transcriptSource =
|
|
||||||
typeof data.transcript === 'string'
|
|
||||||
? data.transcript
|
|
||||||
: extractTextDelta(data.item ?? data.content ?? data);
|
|
||||||
const transcript = transcriptSource?.trim();
|
|
||||||
awaitingTranscriptRef.current = false;
|
|
||||||
hasUncommittedAudioRef.current = false;
|
|
||||||
lastSpeechAtRef.current = null;
|
|
||||||
if (transcript) {
|
|
||||||
console.info('[VoiceRealtime] Transcript completed', transcript);
|
|
||||||
onInterimTranscriptRef.current?.('');
|
|
||||||
onFinalTranscriptRef.current?.(transcript);
|
|
||||||
} else {
|
|
||||||
console.warn('[VoiceRealtime] Received empty transcription event');
|
|
||||||
onInterimTranscriptRef.current?.('');
|
|
||||||
}
|
|
||||||
onStatusChangeRef.current?.('listening');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'error' && data.error) {
|
|
||||||
console.error('[VoiceRealtime] Server error', data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'response.error' && data.error) {
|
|
||||||
console.error('[VoiceRealtime] Response error', data.error);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[extractTextDelta, notifyError]
|
|
||||||
);
|
|
||||||
|
|
||||||
const waitForIceGatheringComplete = useCallback((pc: RTCPeerConnection, timeoutMs = 2000) => {
|
|
||||||
if (pc.iceGatheringState === 'complete') {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
let resolved = false;
|
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
if (resolved) return;
|
|
||||||
resolved = true;
|
|
||||||
pc.removeEventListener('icegatheringstatechange', handleStateChange);
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
timeoutHandle = null;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStateChange = () => {
|
|
||||||
if (pc.iceGatheringState === 'complete') {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (timeoutMs) {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
console.warn('[VoiceRealtime] ICE gathering timeout reached, proceeding with partial candidates');
|
|
||||||
finish();
|
|
||||||
}, timeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
pc.addEventListener('icegatheringstatechange', handleStateChange);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
destroyedRef.current = false;
|
|
||||||
resetState();
|
|
||||||
setConnectionState('connecting');
|
|
||||||
|
|
||||||
const authToken = getAuthToken?.() ?? null;
|
|
||||||
const fetchToken = fetchEphemeralToken
|
|
||||||
? fetchEphemeralToken
|
|
||||||
: async (token: string | null) => {
|
|
||||||
const response = await fetch('/api/realtime/ephemeral-token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(payload.error || `Failed to mint ephemeral token (${response.status})`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionPromise = fetchToken(authToken);
|
|
||||||
const microphonePromise = initialiseMicrophone();
|
|
||||||
|
|
||||||
const session = await sessionPromise;
|
|
||||||
const clientSecret = session?.client_secret?.value || session?.client_secret;
|
|
||||||
if (!clientSecret || typeof clientSecret !== 'string') {
|
|
||||||
throw new Error('Realtime session did not include a client_secret');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pc = new RTCPeerConnection({
|
|
||||||
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
|
||||||
});
|
|
||||||
peerConnectionRef.current = pc;
|
|
||||||
|
|
||||||
const channel = pc.createDataChannel('oai-events');
|
|
||||||
dataChannelRef.current = channel;
|
|
||||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
|
||||||
channelReadyResolveRef.current = resolve;
|
|
||||||
});
|
|
||||||
channelReadyRef.current = false;
|
|
||||||
|
|
||||||
channel.onmessage = (event) => {
|
|
||||||
handleDataMessage(event.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
channel.onopen = () => {
|
|
||||||
console.info('[VoiceRealtime] Data channel open');
|
|
||||||
setConnectionState('ready');
|
|
||||||
channelReadyResolveRef.current?.();
|
|
||||||
channelReadyResolveRef.current = null;
|
|
||||||
channelReadyRef.current = true;
|
|
||||||
|
|
||||||
void sendEvent({
|
|
||||||
type: 'session.update',
|
|
||||||
session: {
|
|
||||||
modalities: ['text'],
|
|
||||||
input_audio_transcription: {
|
|
||||||
model: 'whisper-1',
|
|
||||||
},
|
|
||||||
turn_detection: {
|
|
||||||
type: 'server_vad',
|
|
||||||
threshold: 0.5,
|
|
||||||
silence_duration_ms: 1800,
|
|
||||||
prefix_padding_ms: 300,
|
|
||||||
create_response: false,
|
|
||||||
},
|
|
||||||
instructions: 'You are the RA-H voice transport. Only transcribe user speech accurately, never speak responses.',
|
|
||||||
},
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('[VoiceRealtime] Failed to send session update:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
channel.onerror = (event) => {
|
|
||||||
console.error('[VoiceRealtime] Data channel error:', event);
|
|
||||||
notifyError(new Error('Realtime connection error'));
|
|
||||||
channelReadyRef.current = false;
|
|
||||||
disconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
channel.onclose = () => {
|
|
||||||
console.warn('[VoiceRealtime] Data channel closed');
|
|
||||||
channelReadyPromiseRef.current = null;
|
|
||||||
channelReadyResolveRef.current = null;
|
|
||||||
channelReadyRef.current = false;
|
|
||||||
if (!destroyedRef.current) {
|
|
||||||
notifyError(new Error('Realtime data channel closed unexpectedly'));
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.onconnectionstatechange = () => {
|
|
||||||
const state = pc.connectionState;
|
|
||||||
console.info('[VoiceRealtime] Peer connection state:', state);
|
|
||||||
if (state === 'connected') {
|
|
||||||
onStatusChangeRef.current?.('listening');
|
|
||||||
setConnectionState('capturing');
|
|
||||||
}
|
|
||||||
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
|
|
||||||
if (!destroyedRef.current) {
|
|
||||||
notifyError(new Error(`Realtime connection ${state}`));
|
|
||||||
}
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
|
||||||
console.info('[VoiceRealtime] ICE connection state:', pc.iceConnectionState);
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.ontrack = () => undefined;
|
|
||||||
|
|
||||||
const mediaStream = await microphonePromise;
|
|
||||||
mediaStream?.getAudioTracks().forEach((track) => {
|
|
||||||
if (peerConnectionRef.current?.signalingState !== 'closed') {
|
|
||||||
peerConnectionRef.current?.addTrack(track, mediaStream);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const offer = await pc.createOffer();
|
|
||||||
await pc.setLocalDescription(offer);
|
|
||||||
await waitForIceGatheringComplete(pc, 2000);
|
|
||||||
|
|
||||||
const localDescription = pc.localDescription;
|
|
||||||
if (!localDescription) {
|
|
||||||
throw new Error('Failed to create local description for realtime call');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('https://api.openai.com/v1/realtime/calls', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${clientSecret}`,
|
|
||||||
'Content-Type': 'application/sdp',
|
|
||||||
'OpenAI-Beta': 'realtime=v1',
|
|
||||||
},
|
|
||||||
body: localDescription.sdp,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorDetail = '';
|
|
||||||
try {
|
|
||||||
const text = await response.text();
|
|
||||||
errorDetail = text;
|
|
||||||
const maybeJson = text ? JSON.parse(text) : null;
|
|
||||||
if (maybeJson?.error?.message) {
|
|
||||||
errorDetail = maybeJson.error.message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore JSON parse errors and fall back to raw text
|
|
||||||
}
|
|
||||||
const friendly = errorDetail || response.statusText || 'Unknown realtime error';
|
|
||||||
console.error('[VoiceRealtime] Failed to start realtime call:', friendly, { status: response.status });
|
|
||||||
throw new Error(`Failed to establish realtime call (${response.status}): ${friendly}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerSdp = await response.text();
|
|
||||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
|
||||||
|
|
||||||
onStatusChangeRef.current?.('listening');
|
|
||||||
setConnectionState('capturing');
|
|
||||||
} catch (err) {
|
|
||||||
disconnect();
|
|
||||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [disconnect, fetchEphemeralToken, getAuthToken, handleDataMessage, initialiseMicrophone, notifyError, resetState, sendEvent, setConnectionState, waitForIceGatheringComplete]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
disconnect();
|
|
||||||
}, [disconnect]);
|
|
||||||
|
|
||||||
const latestStopRef = useRef<() => void>(() => {});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
latestStopRef.current = stop;
|
|
||||||
}, [stop]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
latestStopRef.current();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
export enum MessageRole {
|
|
||||||
USER = 'user',
|
|
||||||
ASSISTANT = 'assistant',
|
|
||||||
TOOL = 'tool',
|
|
||||||
SYSTEM = 'system',
|
|
||||||
THINKING = 'thinking',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
role: MessageRole.USER | MessageRole.ASSISTANT | MessageRole.TOOL | MessageRole.SYSTEM | MessageRole.THINKING;
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
toolName?: string;
|
|
||||||
status?: 'processing' | 'delivered' | 'error' | 'starting' | 'running' | 'complete';
|
|
||||||
toolArgs?: any;
|
|
||||||
toolResult?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SendParams {
|
|
||||||
text: string;
|
|
||||||
history: ChatMessage[];
|
|
||||||
openTabs: any[];
|
|
||||||
activeTabId: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
currentView?: 'nodes' | 'memory';
|
|
||||||
sessionId: string;
|
|
||||||
mode: 'easy' | 'hard';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageData {
|
|
||||||
inputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSSEChatOptions {
|
|
||||||
getAuthToken?: () => string | null | undefined;
|
|
||||||
beforeRequest?: () => boolean;
|
|
||||||
onRequestError?: (error: unknown, response?: Response) => boolean | void;
|
|
||||||
onStreamComplete?: () => void | Promise<void>;
|
|
||||||
onUsageUpdate?: (usage: UsageData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSSEChat(
|
|
||||||
endpoint: string,
|
|
||||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
|
|
||||||
options: UseSSEChatOptions = {}
|
|
||||||
) {
|
|
||||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, onUsageUpdate } = options;
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const abort = () => {
|
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
};
|
|
||||||
|
|
||||||
const send = async ({ text, history, openTabs, activeTabId, activeDimension, currentView, sessionId, mode }: SendParams) => {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed || isLoading) return;
|
|
||||||
if (beforeRequest && !beforeRequest()) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: MessageRole.USER,
|
|
||||||
content: trimmed,
|
|
||||||
timestamp: new Date()
|
|
||||||
};
|
|
||||||
const assistantMessage: ChatMessage = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: MessageRole.ASSISTANT,
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
|
||||||
|
|
||||||
let handledError = false;
|
|
||||||
let currentAssistantMessage = assistantMessage;
|
|
||||||
|
|
||||||
try {
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
const token = getAuthToken?.();
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
messages: history
|
|
||||||
.concat(userMessage)
|
|
||||||
.filter((m) => [MessageRole.USER, MessageRole.ASSISTANT, MessageRole.SYSTEM].includes(m.role))
|
|
||||||
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
|
|
||||||
openTabs,
|
|
||||||
activeTabId,
|
|
||||||
activeDimension,
|
|
||||||
currentView,
|
|
||||||
sessionId,
|
|
||||||
mode
|
|
||||||
}),
|
|
||||||
signal: abortControllerRef.current.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
handledError = Boolean(onRequestError?.(error, response));
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
|
||||||
);
|
|
||||||
if (handledError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
if (!reader) throw new Error('No response body');
|
|
||||||
|
|
||||||
let fullContent = '';
|
|
||||||
let toolCallsActive: Record<string, string> = {};
|
|
||||||
let hasToolCalls = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const lines = chunk.split('\n');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(line.substring(6));
|
|
||||||
|
|
||||||
if (data.type === 'text-delta' && data.delta) {
|
|
||||||
fullContent += data.delta;
|
|
||||||
setMessages((prev) => prev.map((m) => (m.id === currentAssistantMessage.id ? { ...m, content: fullContent } : m)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'tool-input-start') {
|
|
||||||
hasToolCalls = true;
|
|
||||||
toolCallsActive[data.toolCallId] = data.toolName;
|
|
||||||
const toolMessage: ChatMessage = {
|
|
||||||
id: `tool-${data.toolCallId}`,
|
|
||||||
role: MessageRole.TOOL,
|
|
||||||
content: data.toolName,
|
|
||||||
timestamp: new Date(),
|
|
||||||
toolName: data.toolName,
|
|
||||||
status: 'running',
|
|
||||||
toolArgs: (data.args ?? data.input ?? data.parameters ?? null)
|
|
||||||
};
|
|
||||||
setMessages((prev) => {
|
|
||||||
const filtered = prev.filter((m) => m.id !== toolMessage.id);
|
|
||||||
const index = filtered.findIndex((m) => m.id === currentAssistantMessage.id);
|
|
||||||
return [...filtered.slice(0, index), toolMessage, ...filtered.slice(index)];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'tool-output-available') {
|
|
||||||
delete toolCallsActive[data.toolCallId];
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === `tool-${data.toolCallId}`
|
|
||||||
? { ...m, content: `${m.toolName} ✓`, status: 'complete', toolResult: (data.result ?? data.output ?? null) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Object.keys(toolCallsActive).length === 0 && hasToolCalls) {
|
|
||||||
const newAssistantMessage: ChatMessage = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: MessageRole.ASSISTANT,
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date()
|
|
||||||
};
|
|
||||||
currentAssistantMessage = newAssistantMessage;
|
|
||||||
fullContent = '';
|
|
||||||
setMessages((prev) => [...prev, newAssistantMessage]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture usage data from finish event (if AI SDK sends it)
|
|
||||||
if (data.type === 'finish' && data.usage) {
|
|
||||||
onUsageUpdate?.({
|
|
||||||
inputTokens: data.usage.promptTokens ?? data.usage.inputTokens ?? 0,
|
|
||||||
outputTokens: data.usage.completionTokens ?? data.usage.outputTokens ?? 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onStreamComplete) {
|
|
||||||
await onStreamComplete();
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.name !== 'AbortError') {
|
|
||||||
if (!handledError) {
|
|
||||||
handledError = Boolean(onRequestError?.(err));
|
|
||||||
}
|
|
||||||
if (!handledError) {
|
|
||||||
setError(err?.message || 'Failed to send message');
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
abortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { isLoading, error, send, abort };
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { TTSStatus } from './useAssistantTTS';
|
|
||||||
|
|
||||||
interface UseVoiceInterruptionOptions {
|
|
||||||
amplitude: number;
|
|
||||||
isVoiceActive: boolean;
|
|
||||||
ttsStatus: TTSStatus;
|
|
||||||
threshold?: number;
|
|
||||||
holdDurationMs?: number;
|
|
||||||
cooldownMs?: number;
|
|
||||||
onInterruption: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_THRESHOLD = 0.18;
|
|
||||||
const DEFAULT_HOLD_MS = 120;
|
|
||||||
const DEFAULT_COOLDOWN_MS = 800;
|
|
||||||
|
|
||||||
export function useVoiceInterruption(options: UseVoiceInterruptionOptions) {
|
|
||||||
const {
|
|
||||||
amplitude,
|
|
||||||
isVoiceActive,
|
|
||||||
ttsStatus,
|
|
||||||
onInterruption,
|
|
||||||
threshold = DEFAULT_THRESHOLD,
|
|
||||||
holdDurationMs = DEFAULT_HOLD_MS,
|
|
||||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const [isInterrupting, setIsInterrupting] = useState(false);
|
|
||||||
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const lastInterruptionAtRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (detectionTimeoutRef.current) {
|
|
||||||
clearTimeout(detectionTimeoutRef.current);
|
|
||||||
detectionTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const clearDetection = () => {
|
|
||||||
if (detectionTimeoutRef.current) {
|
|
||||||
clearTimeout(detectionTimeoutRef.current);
|
|
||||||
detectionTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isVoiceActive || ttsStatus === 'idle') {
|
|
||||||
clearDetection();
|
|
||||||
if (isInterrupting) {
|
|
||||||
setIsInterrupting(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (amplitude < threshold) {
|
|
||||||
clearDetection();
|
|
||||||
if (isInterrupting) {
|
|
||||||
setIsInterrupting(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastInterruptionAtRef.current < cooldownMs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectionTimeoutRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
detectionTimeoutRef.current = setTimeout(() => {
|
|
||||||
lastInterruptionAtRef.current = Date.now();
|
|
||||||
setIsInterrupting(true);
|
|
||||||
onInterruption();
|
|
||||||
}, holdDurationMs);
|
|
||||||
}, [amplitude, cooldownMs, holdDurationMs, isInterrupting, isVoiceActive, onInterruption, threshold, ttsStatus]);
|
|
||||||
|
|
||||||
return { isInterrupting } as const;
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { useCallback, useReducer } from 'react';
|
|
||||||
|
|
||||||
export type VoiceSessionStatus = 'idle' | 'listening' | 'thinking' | 'speaking';
|
|
||||||
|
|
||||||
export interface VoiceTranscriptSegment {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VoiceSessionState {
|
|
||||||
isActive: boolean;
|
|
||||||
status: VoiceSessionStatus;
|
|
||||||
interimTranscript: string;
|
|
||||||
segments: VoiceTranscriptSegment[];
|
|
||||||
amplitude: number;
|
|
||||||
startedAt: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type VoiceSessionAction =
|
|
||||||
| { type: 'start' }
|
|
||||||
| { type: 'stop' }
|
|
||||||
| { type: 'set-status'; status: VoiceSessionStatus }
|
|
||||||
| { type: 'set-amplitude'; amplitude: number }
|
|
||||||
| { type: 'set-interim'; transcript: string }
|
|
||||||
| { type: 'append-segment'; text: string }
|
|
||||||
| { type: 'replace-segments'; segments: VoiceTranscriptSegment[] }
|
|
||||||
| { type: 'reset-transcript' };
|
|
||||||
|
|
||||||
const initialState: VoiceSessionState = {
|
|
||||||
isActive: false,
|
|
||||||
status: 'idle',
|
|
||||||
interimTranscript: '',
|
|
||||||
segments: [],
|
|
||||||
amplitude: 0,
|
|
||||||
startedAt: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reducer(state: VoiceSessionState, action: VoiceSessionAction): VoiceSessionState {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'start':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isActive: true,
|
|
||||||
status: 'listening',
|
|
||||||
interimTranscript: '',
|
|
||||||
segments: [],
|
|
||||||
amplitude: 0,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
};
|
|
||||||
case 'stop':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isActive: false,
|
|
||||||
status: 'idle',
|
|
||||||
amplitude: 0,
|
|
||||||
interimTranscript: '',
|
|
||||||
startedAt: null,
|
|
||||||
};
|
|
||||||
case 'set-status':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
status: action.status,
|
|
||||||
};
|
|
||||||
case 'set-amplitude':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
amplitude: Math.max(0, Math.min(1, action.amplitude)),
|
|
||||||
};
|
|
||||||
case 'set-interim':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
interimTranscript: action.transcript,
|
|
||||||
};
|
|
||||||
case 'append-segment': {
|
|
||||||
const trimmed = action.text.trim();
|
|
||||||
if (!trimmed) return state;
|
|
||||||
const segment: VoiceTranscriptSegment = {
|
|
||||||
id: `voice-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
text: trimmed,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
segments: [...state.segments, segment],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'replace-segments':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
segments: action.segments,
|
|
||||||
};
|
|
||||||
case 'reset-transcript':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
interimTranscript: '',
|
|
||||||
segments: [],
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVoiceSession() {
|
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
|
||||||
|
|
||||||
const startSession = useCallback(() => {
|
|
||||||
dispatch({ type: 'start' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopSession = useCallback(() => {
|
|
||||||
dispatch({ type: 'stop' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setStatus = useCallback((status: VoiceSessionStatus) => {
|
|
||||||
dispatch({ type: 'set-status', status });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setAmplitude = useCallback((amplitude: number) => {
|
|
||||||
dispatch({ type: 'set-amplitude', amplitude });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setInterimTranscript = useCallback((transcript: string) => {
|
|
||||||
dispatch({ type: 'set-interim', transcript });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const appendFinalTranscript = useCallback((text: string) => {
|
|
||||||
dispatch({ type: 'append-segment', text });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetTranscript = useCallback(() => {
|
|
||||||
dispatch({ type: 'reset-transcript' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const replaceSegments = useCallback((segments: VoiceTranscriptSegment[]) => {
|
|
||||||
dispatch({ type: 'replace-segments', segments });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
startSession,
|
|
||||||
stopSession,
|
|
||||||
setStatus,
|
|
||||||
setAmplitude,
|
|
||||||
setInterimTranscript,
|
|
||||||
appendFinalTranscript,
|
|
||||||
resetTranscript,
|
|
||||||
replaceSegments,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface ReasoningTraceProps {
|
|
||||||
trace?: {
|
|
||||||
purpose?: string;
|
|
||||||
thoughts?: string;
|
|
||||||
next_action?: string;
|
|
||||||
step?: number;
|
|
||||||
done?: boolean;
|
|
||||||
timestamp?: string;
|
|
||||||
} | null;
|
|
||||||
collapsible?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReasoningTrace({ trace, collapsible = true }: ReasoningTraceProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
if (!trace) return null;
|
|
||||||
const short = (s?: string, n = 180) => (s || '').slice(0, n) + ((s || '').length > n ? '…' : '');
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
margin: '6px 0', padding: '8px 10px',
|
|
||||||
background: '#0e0e12', border: '1px solid #2a2a2a',
|
|
||||||
borderLeft: '2px solid #6b6b7f', borderRadius: 6,
|
|
||||||
fontSize: 12, color: '#cfcfcf', lineHeight: 1.5
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
||||||
<span style={{ color: '#8b8b9f', fontWeight: 600 }}>Reasoning trace</span>
|
|
||||||
{trace.timestamp ? (
|
|
||||||
<span style={{ color: '#4a4a4a', fontSize: 11 }}>{new Date(trace.timestamp).toLocaleTimeString()}</span>
|
|
||||||
) : null}
|
|
||||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{trace.done ? 'final' : 'in-progress'}</span>
|
|
||||||
</div>
|
|
||||||
{/* Collapsed: show only the purpose as the title */}
|
|
||||||
{trace.purpose ? (
|
|
||||||
<div style={{ color: '#9adbc2' }}>Goal: {expanded ? trace.purpose : short(trace.purpose, 80)}</div>
|
|
||||||
) : null}
|
|
||||||
{/* Expanded: show details */}
|
|
||||||
{expanded && trace.thoughts ? <div style={{ marginTop: 6 }}>{trace.thoughts}</div> : null}
|
|
||||||
{expanded && trace.next_action ? (
|
|
||||||
<div style={{ marginTop: 6, color: '#a5b4fc' }}>Next: {trace.next_action}</div>
|
|
||||||
) : null}
|
|
||||||
{collapsible ? (
|
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((e) => !e)}
|
|
||||||
style={{
|
|
||||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
|
||||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{expanded ? 'Hide reasoning' : 'Show full reasoning'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import SourceChip from './SourceChip';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface ToolDisplayProps {
|
|
||||||
name: string;
|
|
||||||
status: 'starting' | 'running' | 'complete' | 'error';
|
|
||||||
context?: string;
|
|
||||||
sources?: Array<{ url?: string; domain?: string }> | string[];
|
|
||||||
result?: any;
|
|
||||||
error?: string;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ToolDisplay({ name, status, context, sources, result, error, onNodeClick }: ToolDisplayProps) {
|
|
||||||
const isRunning = status === 'starting' || status === 'running';
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const accentColor = '#22c55e';
|
|
||||||
const bgTint = '#0b0b0b';
|
|
||||||
const displayName = name;
|
|
||||||
|
|
||||||
// Normalize sources to array of objects
|
|
||||||
const normalizedSources: Array<{ url?: string; domain?: string }> = Array.isArray(sources)
|
|
||||||
? sources.map((s: any) => (typeof s === 'string' ? { domain: s } : s))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Web search simple preview renderer
|
|
||||||
const renderWebSearchPreview = () => {
|
|
||||||
const items = result?.data?.results || result?.results || [];
|
|
||||||
if (!Array.isArray(items) || items.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((e) => !e)}
|
|
||||||
style={{
|
|
||||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
|
||||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{expanded ? 'Hide results' : `Show results (${Math.min(items.length, 3)})`}
|
|
||||||
</button>
|
|
||||||
{expanded ? (
|
|
||||||
<div style={{ display: 'grid', gap: 6, marginTop: 6 }}>
|
|
||||||
{items.slice(0, 3).map((r: any, i: number) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: '#0f0f0f',
|
|
||||||
border: '1px solid #2a2a2a',
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: '6px 8px',
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#cfcfcf'
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{r.title || 'Result'}</div>
|
|
||||||
{r.snippet ? <div style={{ color: '#9a9a9a', marginTop: 2 }}>{r.snippet}</div> : null}
|
|
||||||
{r.url ? (
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
<a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff', fontSize: 11 }}>
|
|
||||||
{r.url}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
margin: '8px 0',
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: bgTint,
|
|
||||||
border: '1px solid #2a2a2a',
|
|
||||||
borderLeft: `2px solid ${accentColor}`,
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#cfcfcf'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
||||||
{isRunning ? (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-block', width: 12, height: 12,
|
|
||||||
border: '2px solid #2a2a2a', borderTopColor: accentColor,
|
|
||||||
borderRadius: '50%', animation: 'spin 1s linear infinite'
|
|
||||||
}} />
|
|
||||||
) : (
|
|
||||||
<span style={{ color: accentColor, fontSize: 12 }}>{status === 'error' ? '✗' : '✓'}</span>
|
|
||||||
)}
|
|
||||||
<span style={{ color: accentColor, fontWeight: 600 }}>{displayName}</span>
|
|
||||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{status}</span>
|
|
||||||
</div>
|
|
||||||
{context ? <div style={{ color: '#9adbc2' }}>{context}</div> : null}
|
|
||||||
{normalizedSources.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
|
||||||
{normalizedSources.slice(0, 6).map((s, i) => (
|
|
||||||
<SourceChip key={i} url={s.url} domain={s.domain} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* Specific previews */}
|
|
||||||
{name === 'webSearch' ? renderWebSearchPreview() : null}
|
|
||||||
{name === 'websiteExtract' ? (
|
|
||||||
<WebsiteExtractSummary result={result} onNodeClick={onNodeClick} />
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<div style={{ color: '#ff6b6b', marginTop: 6 }}>Error: {error}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WebsiteExtractSummary({ result, onNodeClick }: { result?: any; onNodeClick?: (id: number) => void }) {
|
|
||||||
const data = result?.data || {};
|
|
||||||
const title = data.title || 'Website added';
|
|
||||||
const url = data.url || '';
|
|
||||||
const nodeId = data.nodeId as number | undefined;
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 8, border: '1px solid #2a2a2a', background: '#0f0f0f', borderRadius: 6, padding: 8 }}>
|
|
||||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{title}</div>
|
|
||||||
{url ? (
|
|
||||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
|
||||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>
|
|
||||||
{url}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
{typeof nodeId === 'number' && onNodeClick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onNodeClick(nodeId)}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid #2a2a2a',
|
|
||||||
color: '#cfcfcf',
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open node #{nodeId}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{typeof data.contentLength === 'number' ? (
|
|
||||||
<span style={{ fontSize: 11, color: '#7a7a7a' }}>
|
|
||||||
content ~{Math.round((data.contentLength as number) / 1000)}k chars
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
MessageSquare,
|
|
||||||
Map,
|
Map,
|
||||||
Folder,
|
Folder,
|
||||||
Workflow,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { PaneType } from '../panes/types';
|
import type { PaneType } from '../panes/types';
|
||||||
@@ -23,25 +22,23 @@ interface LeftToolbarProps {
|
|||||||
slotBType: PaneType | null;
|
slotBType: PaneType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map pane types to their icons
|
// Map pane types to their icons (chat removed in rah-light)
|
||||||
const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
|
const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
|
||||||
views: LayoutList,
|
views: LayoutList,
|
||||||
chat: MessageSquare,
|
|
||||||
map: Map,
|
map: Map,
|
||||||
dimensions: Folder,
|
dimensions: Folder,
|
||||||
workflows: Workflow,
|
guides: FileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PANE_TYPE_LABELS: Record<string, string> = {
|
const PANE_TYPE_LABELS: Record<string, string> = {
|
||||||
views: 'Feed',
|
views: 'Feed',
|
||||||
chat: 'Chat',
|
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
dimensions: 'Dimensions',
|
dimensions: 'Dimensions',
|
||||||
workflows: 'Workflows',
|
guides: 'Guides',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pane types shown in the toolbar (excludes 'node' which is opened via Feed)
|
// Pane types shown in the toolbar (excludes 'node' which is opened via Feed, chat removed in rah-light)
|
||||||
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'chat', 'map', 'dimensions', 'workflows'];
|
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'guides'];
|
||||||
|
|
||||||
interface ToolbarButtonProps {
|
interface ToolbarButtonProps {
|
||||||
icon: typeof Search;
|
icon: typeof Search;
|
||||||
|
|||||||
@@ -6,15 +6,27 @@ import SearchModal from '../nodes/SearchModal';
|
|||||||
import { Node } from '@/types/database';
|
import { Node } from '@/types/database';
|
||||||
import { DatabaseEvent } from '@/services/events';
|
import { DatabaseEvent } from '@/services/events';
|
||||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
// ChatMessage import removed - chat disabled in rah-light
|
||||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
|
||||||
|
// Stub type for delegation (delegation system removed in rah-light)
|
||||||
|
type AgentDelegation = {
|
||||||
|
id: number;
|
||||||
|
sessionId: string;
|
||||||
|
task: string;
|
||||||
|
context: string[];
|
||||||
|
status: 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
summary?: string | null;
|
||||||
|
agentType: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Layout components
|
// Layout components
|
||||||
import LeftToolbar from './LeftToolbar';
|
import LeftToolbar from './LeftToolbar';
|
||||||
import SplitHandle from './SplitHandle';
|
import SplitHandle from './SplitHandle';
|
||||||
|
|
||||||
// Pane components
|
// Pane components (ChatPane removed in rah-light)
|
||||||
import { NodePane, ChatPane, WorkflowsPane, DimensionsPane, MapPane, ViewsPane } from '../panes';
|
import { NodePane, GuidesPane, DimensionsPane, MapPane, ViewsPane } from '../panes';
|
||||||
import QuickAddInput from '../agents/QuickAddInput';
|
import QuickAddInput from '../agents/QuickAddInput';
|
||||||
import type { PaneType, SlotState, PaneAction } from '../panes/types';
|
import type { PaneType, SlotState, PaneAction } from '../panes/types';
|
||||||
|
|
||||||
@@ -23,14 +35,14 @@ export default function ThreePanelLayout() {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Slot states - the core of the flexible pane system
|
// Slot states - the core of the flexible pane system
|
||||||
// Default: Feed on left, Chat on right
|
// Default: Feed on left, closed on right (chat removed in rah-light)
|
||||||
const [slotA, setSlotA] = usePersistentState<SlotState | null>('ui.slotA.v3', {
|
const [slotA, setSlotA] = usePersistentState<SlotState | null>('ui.slotA.v4', {
|
||||||
type: 'views',
|
type: 'views',
|
||||||
});
|
});
|
||||||
|
|
||||||
// SlotB can be null (closed) or a SlotState
|
// SlotB can be null (closed) or a SlotState
|
||||||
// Default: Chat on the right
|
// Default: closed (chat removed in rah-light)
|
||||||
const [slotB, setSlotB] = usePersistentState<SlotState | null>('ui.slotB.v3', { type: 'chat' });
|
const [slotB, setSlotB] = usePersistentState<SlotState | null>('ui.slotB.v4', null);
|
||||||
|
|
||||||
// SlotB width as percentage (when open)
|
// SlotB width as percentage (when open)
|
||||||
const [slotBWidth, setSlotBWidth] = usePersistentState<number>('ui.slotBWidth', 50);
|
const [slotBWidth, setSlotBWidth] = usePersistentState<number>('ui.slotBWidth', 50);
|
||||||
@@ -63,17 +75,14 @@ export default function ThreePanelLayout() {
|
|||||||
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
||||||
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
||||||
|
|
||||||
// Active dimension tracking (for chat context)
|
// Active dimension tracking
|
||||||
const [activeDimension, setActiveDimension] = usePersistentState<string | null>('ui.focus.activeDimension', null);
|
const [activeDimension, setActiveDimension] = usePersistentState<string | null>('ui.focus.activeDimension', null);
|
||||||
|
|
||||||
// Delegations state (shared between chat and workflows panes)
|
// Delegations state (deprecated - kept for component compatibility)
|
||||||
const [delegationsMap, setDelegationsMap] = useState<Record<string, AgentDelegation>>({});
|
const [delegationsMap] = useState<Record<string, AgentDelegation>>({});
|
||||||
const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]);
|
const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]);
|
||||||
|
|
||||||
// Chat state (lifted to persist across pane type changes)
|
// Source awareness - highlighted passage context
|
||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
|
||||||
|
|
||||||
// Source awareness - highlighted passage context for agent
|
|
||||||
const [highlightedPassage, setHighlightedPassage] = useState<{
|
const [highlightedPassage, setHighlightedPassage] = useState<{
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
nodeTitle: string;
|
nodeTitle: string;
|
||||||
@@ -83,29 +92,13 @@ export default function ThreePanelLayout() {
|
|||||||
// Ref to get current openTabs value in SSE handler
|
// Ref to get current openTabs value in SSE handler
|
||||||
const openTabsRef = useRef<number[]>([]);
|
const openTabsRef = useRef<number[]>([]);
|
||||||
|
|
||||||
// Get open tabs from the slot that has nodes (for chat context)
|
// Get open tabs from the slot that has nodes
|
||||||
// Memoize to prevent infinite re-renders
|
// Memoize to prevent infinite re-renders
|
||||||
const { openTabs, activeTab } = useMemo(() => {
|
const { openTabs, activeTab } = useMemo(() => {
|
||||||
const slotAHasNodes = slotA?.type === 'node';
|
const slotAHasNodes = slotA?.type === 'node';
|
||||||
const slotBHasNodes = slotB?.type === 'node';
|
const slotBHasNodes = slotB?.type === 'node';
|
||||||
|
|
||||||
// If chat is in Slot A, use Slot B's nodes
|
// Use Slot A if it has nodes
|
||||||
if (slotA?.type === 'chat' && slotBHasNodes) {
|
|
||||||
return {
|
|
||||||
openTabs: slotB.nodeTabs ?? [],
|
|
||||||
activeTab: slotB.activeNodeTab ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If chat is in Slot B (default), use Slot A's nodes
|
|
||||||
if (slotB?.type === 'chat' && slotAHasNodes && slotA) {
|
|
||||||
return {
|
|
||||||
openTabs: slotA.nodeTabs ?? [],
|
|
||||||
activeTab: slotA.activeNodeTab ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: use Slot A if it has nodes
|
|
||||||
if (slotAHasNodes && slotA) {
|
if (slotAHasNodes && slotA) {
|
||||||
return {
|
return {
|
||||||
openTabs: slotA.nodeTabs ?? [],
|
openTabs: slotA.nodeTabs ?? [],
|
||||||
@@ -113,6 +106,14 @@ export default function ThreePanelLayout() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: use Slot B if it has nodes
|
||||||
|
if (slotBHasNodes && slotB) {
|
||||||
|
return {
|
||||||
|
openTabs: slotB.nodeTabs ?? [],
|
||||||
|
activeTab: slotB.activeNodeTab ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { openTabs: [], activeTab: null };
|
return { openTabs: [], activeTab: null };
|
||||||
}, [slotA, slotB]);
|
}, [slotA, slotB]);
|
||||||
|
|
||||||
@@ -161,32 +162,7 @@ export default function ThreePanelLayout() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openTabsKey]);
|
}, [openTabsKey]);
|
||||||
|
|
||||||
// Load delegations on mount
|
// Delegations loading removed (delegation system removed in rah-light)
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const loadExisting = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/rah/delegations?status=active&includeCompleted=true');
|
|
||||||
if (!response.ok) return;
|
|
||||||
const data = await response.json();
|
|
||||||
if (!Array.isArray(data.delegations)) return;
|
|
||||||
|
|
||||||
setDelegationsMap((prev) => {
|
|
||||||
if (cancelled) return prev;
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const delegation of data.delegations as AgentDelegation[]) {
|
|
||||||
next[delegation.sessionId] = delegation;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ThreePanelLayout] Failed to load delegations:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadExisting();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard shortcut handler
|
// Keyboard shortcut handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,8 +178,8 @@ export default function ThreePanelLayout() {
|
|||||||
if (slotB) {
|
if (slotB) {
|
||||||
setSlotB(null);
|
setSlotB(null);
|
||||||
} else {
|
} else {
|
||||||
// Open with chat by default
|
// Open with node pane by default (chat removed in rah-light)
|
||||||
setSlotB({ type: 'chat' });
|
setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cmd+N - open Add Stuff modal
|
// Cmd+N - open Add Stuff modal
|
||||||
@@ -278,32 +254,13 @@ export default function ThreePanelLayout() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'AGENT_DELEGATION_CREATED':
|
case 'AGENT_DELEGATION_CREATED':
|
||||||
if (data.data?.delegation) {
|
|
||||||
setDelegationsMap(prev => ({
|
|
||||||
...prev,
|
|
||||||
[data.data.delegation.sessionId]: data.data.delegation
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(new CustomEvent('delegations:created', { detail: data.data }));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'AGENT_DELEGATION_UPDATED':
|
case 'AGENT_DELEGATION_UPDATED':
|
||||||
if (data.data?.delegation) {
|
// Delegation events ignored (delegation system removed in rah-light)
|
||||||
setDelegationsMap(prev => ({
|
|
||||||
...prev,
|
|
||||||
[data.data.delegation.sessionId]: data.data.delegation
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(new CustomEvent('delegations:updated', { detail: data.data }));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'WORKFLOW_PROGRESS':
|
case 'GUIDE_UPDATED':
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.dispatchEvent(new CustomEvent('workflow:progress', { detail: data.data }));
|
window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data }));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -781,7 +738,7 @@ export default function ThreePanelLayout() {
|
|||||||
|
|
||||||
// Split handle callbacks
|
// Split handle callbacks
|
||||||
const handleOpenSecondPane = useCallback(() => {
|
const handleOpenSecondPane = useCallback(() => {
|
||||||
setSlotB({ type: 'chat' }); // Default to chat when opening
|
setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null }); // Default to node pane (chat removed in rah-light)
|
||||||
setActivePane('B');
|
setActivePane('B');
|
||||||
}, [setSlotB]);
|
}, [setSlotB]);
|
||||||
|
|
||||||
@@ -848,46 +805,16 @@ export default function ThreePanelLayout() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'chat':
|
// case 'chat' removed in rah-light
|
||||||
return (
|
|
||||||
<ChatPane
|
|
||||||
slot={slot}
|
|
||||||
isActive={isActive}
|
|
||||||
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
|
|
||||||
onCollapse={onCollapse}
|
|
||||||
onSwapPanes={slotB ? handleSwapPanes : undefined}
|
|
||||||
openTabsData={openTabsData}
|
|
||||||
activeTabId={activeTab}
|
|
||||||
activeDimension={activeDimension}
|
|
||||||
onClearDimension={() => setActiveDimension(null)}
|
|
||||||
onNodeClick={(nodeId) => {
|
|
||||||
handleNodeSelect(nodeId, false);
|
|
||||||
setActivePane(slot);
|
|
||||||
}}
|
|
||||||
delegations={delegations}
|
|
||||||
chatMessages={chatMessages as unknown[]}
|
|
||||||
setChatMessages={setChatMessages as React.Dispatch<React.SetStateAction<unknown[]>>}
|
|
||||||
highlightedPassage={highlightedPassage}
|
|
||||||
onClearPassage={() => setHighlightedPassage(null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'workflows':
|
case 'guides':
|
||||||
return (
|
return (
|
||||||
<WorkflowsPane
|
<GuidesPane
|
||||||
slot={slot}
|
slot={slot}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
|
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
|
||||||
onCollapse={onCollapse}
|
onCollapse={onCollapse}
|
||||||
onSwapPanes={slotB ? handleSwapPanes : undefined}
|
onSwapPanes={slotB ? handleSwapPanes : undefined}
|
||||||
delegations={delegations}
|
|
||||||
onNodeClick={(nodeId) => {
|
|
||||||
handleNodeSelect(nodeId, false);
|
|
||||||
setActivePane(slot);
|
|
||||||
}}
|
|
||||||
openTabsData={openTabsData}
|
|
||||||
activeTabId={activeTab}
|
|
||||||
activeDimension={activeDimension}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import RAHChat from '@/components/agents/RAHChat';
|
|
||||||
import PaneHeader from './PaneHeader';
|
|
||||||
import { ChatPaneProps } from './types';
|
|
||||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
|
||||||
|
|
||||||
export default function ChatPane({
|
|
||||||
slot,
|
|
||||||
isActive,
|
|
||||||
onPaneAction,
|
|
||||||
onCollapse,
|
|
||||||
onSwapPanes,
|
|
||||||
openTabsData,
|
|
||||||
activeTabId,
|
|
||||||
activeDimension,
|
|
||||||
onClearDimension,
|
|
||||||
onNodeClick,
|
|
||||||
delegations,
|
|
||||||
chatMessages: externalMessages,
|
|
||||||
setChatMessages: externalSetMessages,
|
|
||||||
highlightedPassage,
|
|
||||||
onClearPassage,
|
|
||||||
}: ChatPaneProps) {
|
|
||||||
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
|
|
||||||
const [hasStarted, setHasStarted] = useState(false);
|
|
||||||
|
|
||||||
// Use lifted state if provided, otherwise fall back to local state
|
|
||||||
const [internalMessages, setInternalMessages] = useState<ChatMessage[]>([]);
|
|
||||||
|
|
||||||
const rahMessages = (externalMessages as ChatMessage[]) ?? internalMessages;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const setRahMessages = (externalSetMessages as any) ?? setInternalMessages;
|
|
||||||
|
|
||||||
// Load mode from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const stored = window.localStorage.getItem('rah-mode');
|
|
||||||
if (stored === 'easy' || stored === 'hard') {
|
|
||||||
setRahMode(stored);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Persist mode to localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
window.localStorage.setItem('rah-mode', rahMode);
|
|
||||||
}, [rahMode]);
|
|
||||||
|
|
||||||
// Listen for mode toggle events
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (event: Event) => {
|
|
||||||
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
|
|
||||||
if (detail?.mode) {
|
|
||||||
setRahMode(detail.mode);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('rah:mode-toggle', handler as EventListener);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('rah:mode-toggle', handler as EventListener);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-start if there are existing messages
|
|
||||||
useEffect(() => {
|
|
||||||
if (rahMessages.length > 0 && !hasStarted) {
|
|
||||||
setHasStarted(true);
|
|
||||||
}
|
|
||||||
}, [rahMessages.length, hasStarted]);
|
|
||||||
|
|
||||||
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' }}>
|
|
||||||
{!hasStarted ? (
|
|
||||||
<div style={{
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
padding: '20px',
|
|
||||||
}}>
|
|
||||||
{/* Start Chat button */}
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setHasStarted(true);
|
|
||||||
setRahMode('easy');
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '14px',
|
|
||||||
padding: '0',
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const text = e.currentTarget.querySelector('.start-text') as HTMLElement;
|
|
||||||
const icon = e.currentTarget.querySelector('.start-icon') as HTMLElement;
|
|
||||||
if (text) text.style.color = '#22c55e';
|
|
||||||
if (icon) {
|
|
||||||
icon.style.transform = 'translateY(-3px)';
|
|
||||||
icon.style.boxShadow = '0 8px 20px rgba(34, 197, 94, 0.3), 0 0 0 4px rgba(34, 197, 94, 0.1)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
const text = e.currentTarget.querySelector('.start-text') as HTMLElement;
|
|
||||||
const icon = e.currentTarget.querySelector('.start-icon') as HTMLElement;
|
|
||||||
if (text) text.style.color = '#737373';
|
|
||||||
if (icon) {
|
|
||||||
icon.style.transform = 'translateY(0)';
|
|
||||||
icon.style.boxShadow = '0 0 0 0 rgba(34, 197, 94, 0)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="start-text"
|
|
||||||
style={{
|
|
||||||
color: '#737373',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: '0.15em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
transition: 'color 0.2s ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="start-icon"
|
|
||||||
style={{
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: '#22c55e',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
boxShadow: '0 0 0 0 rgba(34, 197, 94, 0)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M12 19V5"/>
|
|
||||||
<path d="M5 12l7-7 7 7"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<RAHChat
|
|
||||||
openTabsData={openTabsData}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
activeDimension={activeDimension}
|
|
||||||
onClearDimension={onClearDimension}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
delegations={delegations}
|
|
||||||
messages={rahMessages}
|
|
||||||
setMessages={setRahMessages}
|
|
||||||
mode={rahMode}
|
|
||||||
highlightedPassage={highlightedPassage}
|
|
||||||
onClearPassage={onClearPassage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,579 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
||||||
import RAHChat from '@/components/agents/RAHChat';
|
|
||||||
import PaneHeader from './PaneHeader';
|
|
||||||
import { WorkflowsPaneProps, PaneType } from './types';
|
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
|
||||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
|
||||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
|
||||||
|
|
||||||
type ViewMode = 'list' | 'detail' | 'summary';
|
|
||||||
|
|
||||||
export default function WorkflowsPane({
|
|
||||||
slot,
|
|
||||||
isActive,
|
|
||||||
onPaneAction,
|
|
||||||
onCollapse,
|
|
||||||
onSwapPanes,
|
|
||||||
delegations,
|
|
||||||
onNodeClick,
|
|
||||||
openTabsData = [],
|
|
||||||
activeTabId = null,
|
|
||||||
activeDimension,
|
|
||||||
}: WorkflowsPaneProps) {
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
||||||
const [selectedDelegationId, setSelectedDelegationId] = useState<string | null>(null);
|
|
||||||
const [delegationMessages, setDelegationMessages] = useState<Record<string, ChatMessage[]>>({});
|
|
||||||
|
|
||||||
const selectedDelegation = delegations.find(d => d.sessionId === selectedDelegationId);
|
|
||||||
|
|
||||||
const getDelegationMessages = useCallback((sessionId: string) => {
|
|
||||||
return delegationMessages[sessionId] || [];
|
|
||||||
}, [delegationMessages]);
|
|
||||||
|
|
||||||
const setDelegationMessagesFor = useCallback((sessionId: string) => {
|
|
||||||
return (updater: (prev: ChatMessage[]) => ChatMessage[]) => {
|
|
||||||
setDelegationMessages(prev => ({
|
|
||||||
...prev,
|
|
||||||
[sessionId]: updater(prev[sessionId] || [])
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectDelegation = (sessionId: string) => {
|
|
||||||
const delegation = delegations.find(d => d.sessionId === sessionId);
|
|
||||||
setSelectedDelegationId(sessionId);
|
|
||||||
|
|
||||||
// Show summary view for completed/failed with no messages
|
|
||||||
if (delegation &&
|
|
||||||
(delegation.status === 'completed' || delegation.status === 'failed') &&
|
|
||||||
getDelegationMessages(sessionId).length === 0) {
|
|
||||||
setViewMode('summary');
|
|
||||||
} else {
|
|
||||||
setViewMode('detail');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteDelegation = async (sessionId: string) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/rah/delegations/${sessionId}`, { method: 'DELETE' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete delegation ${sessionId}:`, error);
|
|
||||||
}
|
|
||||||
// The parent will handle removing from delegations list via SSE
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
setSelectedDelegationId(null);
|
|
||||||
setViewMode('list');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (type: PaneType) => {
|
|
||||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
|
||||||
};
|
|
||||||
|
|
||||||
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' }}>
|
|
||||||
{viewMode === 'list' && (
|
|
||||||
<WorkflowsListView
|
|
||||||
delegations={delegations}
|
|
||||||
onSelectDelegation={handleSelectDelegation}
|
|
||||||
onDeleteDelegation={handleDeleteDelegation}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'summary' && selectedDelegation && (
|
|
||||||
<DelegationSummaryView
|
|
||||||
delegation={selectedDelegation}
|
|
||||||
onBack={handleBack}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'detail' && selectedDelegation && (
|
|
||||||
<DelegationDetailView
|
|
||||||
delegation={selectedDelegation}
|
|
||||||
openTabsData={openTabsData}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
activeDimension={activeDimension}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
delegations={delegations}
|
|
||||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
|
||||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
|
||||||
onBack={handleBack}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflows list view
|
|
||||||
function WorkflowsListView({
|
|
||||||
delegations,
|
|
||||||
onSelectDelegation,
|
|
||||||
onDeleteDelegation,
|
|
||||||
onNodeClick
|
|
||||||
}: {
|
|
||||||
delegations: AgentDelegation[];
|
|
||||||
onSelectDelegation: (sessionId: string) => void;
|
|
||||||
onDeleteDelegation: (sessionId: string) => void;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}) {
|
|
||||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
|
||||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
|
||||||
|
|
||||||
const getStatusInfo = (delegation: AgentDelegation) => {
|
|
||||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
|
||||||
let color = '#6b6b6b';
|
|
||||||
let label = 'Queued';
|
|
||||||
|
|
||||||
if (delegation.status === 'in_progress') {
|
|
||||||
color = isWiseRAH ? '#8b5cf6' : '#22c55e';
|
|
||||||
label = 'Running';
|
|
||||||
} else if (delegation.status === 'completed') {
|
|
||||||
color = '#22c55e';
|
|
||||||
label = 'Done';
|
|
||||||
} else if (delegation.status === 'failed') {
|
|
||||||
color = '#ff6b6b';
|
|
||||||
label = 'Failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { color, label };
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
{activeDelegations.length > 0 && (
|
|
||||||
<span style={{
|
|
||||||
color: '#22c55e',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{activeDelegations.length} running
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '12px'
|
|
||||||
}}>
|
|
||||||
{delegations.length === 0 ? (
|
|
||||||
<div style={{
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '12px',
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '40px 20px'
|
|
||||||
}}>
|
|
||||||
No workflows yet. Use Quick Capture or ask RA-H to run a workflow.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{activeDelegations.map((delegation) => {
|
|
||||||
const { color, label } = getStatusInfo(delegation);
|
|
||||||
return (
|
|
||||||
<WorkflowCard
|
|
||||||
key={delegation.sessionId}
|
|
||||||
delegation={delegation}
|
|
||||||
statusColor={color}
|
|
||||||
statusLabel={label}
|
|
||||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
|
||||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{activeDelegations.length > 0 && completedDelegations.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
height: '1px',
|
|
||||||
background: '#1f1f1f',
|
|
||||||
margin: '8px 0'
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{completedDelegations.map((delegation) => {
|
|
||||||
const { color, label } = getStatusInfo(delegation);
|
|
||||||
return (
|
|
||||||
<WorkflowCard
|
|
||||||
key={delegation.sessionId}
|
|
||||||
delegation={delegation}
|
|
||||||
statusColor={color}
|
|
||||||
statusLabel={label}
|
|
||||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
|
||||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Individual workflow card
|
|
||||||
function WorkflowCard({
|
|
||||||
delegation,
|
|
||||||
statusColor,
|
|
||||||
statusLabel,
|
|
||||||
onSelect,
|
|
||||||
onDelete,
|
|
||||||
onNodeClick
|
|
||||||
}: {
|
|
||||||
delegation: AgentDelegation;
|
|
||||||
statusColor: string;
|
|
||||||
statusLabel: string;
|
|
||||||
onSelect: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}) {
|
|
||||||
const isActive = delegation.status === 'in_progress' || delegation.status === 'queued';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={onSelect}
|
|
||||||
style={{
|
|
||||||
padding: '14px 16px',
|
|
||||||
background: '#151515',
|
|
||||||
border: '1px solid #1f1f1f',
|
|
||||||
borderRadius: '8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1a1a1a';
|
|
||||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#151515';
|
|
||||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: statusColor,
|
|
||||||
animation: isActive ? 'pulse 2s infinite' : 'none'
|
|
||||||
}} />
|
|
||||||
<span style={{
|
|
||||||
color: statusColor,
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em'
|
|
||||||
}}>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
color: '#555',
|
|
||||||
fontSize: '11px',
|
|
||||||
marginLeft: 'auto'
|
|
||||||
}}>
|
|
||||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#444',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '2px 6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: 1
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = '#ff6b6b'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = '#444'}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
color: '#e5e5e5',
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical'
|
|
||||||
}}>
|
|
||||||
{delegation.task}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{delegation.summary && delegation.status === 'completed' && (
|
|
||||||
<div style={{
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '11px',
|
|
||||||
marginTop: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}>
|
|
||||||
{parseAndRenderContent(delegation.summary, onNodeClick)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary view for completed delegations
|
|
||||||
function DelegationSummaryView({
|
|
||||||
delegation,
|
|
||||||
onBack,
|
|
||||||
onNodeClick
|
|
||||||
}: {
|
|
||||||
delegation: AgentDelegation;
|
|
||||||
onBack?: () => void;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
}) {
|
|
||||||
const isSuccess = delegation.status === 'completed';
|
|
||||||
const statusColor = isSuccess ? '#22c55e' : '#ff6b6b';
|
|
||||||
const statusLabel = isSuccess ? 'Completed' : 'Failed';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
background: 'transparent',
|
|
||||||
padding: '24px'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
paddingBottom: '16px',
|
|
||||||
borderBottom: '1px solid #1a1a1a'
|
|
||||||
}}>
|
|
||||||
{onBack && (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#666',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div style={{
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: statusColor
|
|
||||||
}} />
|
|
||||||
<span style={{
|
|
||||||
color: statusColor,
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.08em'
|
|
||||||
}}>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '11px',
|
|
||||||
marginLeft: 'auto'
|
|
||||||
}}>
|
|
||||||
{new Date(delegation.updatedAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<div style={{
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '10px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}>
|
|
||||||
Task
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
color: '#e5e5e5',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.5'
|
|
||||||
}}>
|
|
||||||
{delegation.task}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{delegation.summary && (
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '10px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}>
|
|
||||||
Result
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
color: isSuccess ? '#a8a8a8' : '#fca5a5',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
whiteSpace: 'pre-wrap'
|
|
||||||
}}>
|
|
||||||
{parseAndRenderContent(delegation.summary || '', onNodeClick)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!delegation.summary && (
|
|
||||||
<div style={{
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontStyle: 'italic'
|
|
||||||
}}>
|
|
||||||
No details available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detail view with chat
|
|
||||||
function DelegationDetailView({
|
|
||||||
delegation,
|
|
||||||
openTabsData,
|
|
||||||
activeTabId,
|
|
||||||
activeDimension,
|
|
||||||
onNodeClick,
|
|
||||||
delegations,
|
|
||||||
messages,
|
|
||||||
setMessages,
|
|
||||||
onBack
|
|
||||||
}: {
|
|
||||||
delegation: AgentDelegation;
|
|
||||||
openTabsData: any[];
|
|
||||||
activeTabId: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
delegations: AgentDelegation[];
|
|
||||||
messages: ChatMessage[];
|
|
||||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderBottom: '1px solid #1a1a1a',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
background: 'transparent'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#666',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
fontSize: '12px'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = '#a8a8a8'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = '#666'}
|
|
||||||
>
|
|
||||||
← Workflows
|
|
||||||
</button>
|
|
||||||
<span style={{ color: '#555', fontSize: '11px' }}>|</span>
|
|
||||||
<span style={{
|
|
||||||
color: delegation.status === 'in_progress' ? '#22c55e' : '#666',
|
|
||||||
fontSize: '11px',
|
|
||||||
textTransform: 'uppercase'
|
|
||||||
}}>
|
|
||||||
{delegation.status === 'in_progress' ? 'Running' : delegation.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
|
||||||
<RAHChat
|
|
||||||
openTabsData={openTabsData}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
activeDimension={activeDimension}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
delegations={delegations}
|
|
||||||
messages={messages}
|
|
||||||
setMessages={setMessages}
|
|
||||||
mode="easy"
|
|
||||||
delegationMode={true}
|
|
||||||
delegationSessionId={delegation.sessionId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export { default as NodePane } from './NodePane';
|
export { default as NodePane } from './NodePane';
|
||||||
export { default as ChatPane } from './ChatPane';
|
export { default as GuidesPane } from './GuidesPane';
|
||||||
export { default as WorkflowsPane } from './WorkflowsPane';
|
|
||||||
export { default as DimensionsPane } from './DimensionsPane';
|
export { default as DimensionsPane } from './DimensionsPane';
|
||||||
export { default as MapPane } from './MapPane';
|
export { default as MapPane } from './MapPane';
|
||||||
export { default as ViewsPane } from './ViewsPane';
|
export { default as ViewsPane } from './ViewsPane';
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Node } from '@/types/database';
|
import { Node } from '@/types/database';
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
|
||||||
|
|
||||||
// The six pane types
|
// Stub type for delegation (delegation system removed in rah-light)
|
||||||
export type PaneType = 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
|
export type AgentDelegation = {
|
||||||
|
id: number;
|
||||||
|
sessionId: string;
|
||||||
|
task: string;
|
||||||
|
context: string[];
|
||||||
|
status: 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
summary?: string | null;
|
||||||
|
agentType: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The five pane types (chat removed in rah-light)
|
||||||
|
export type PaneType = 'node' | 'guides' | 'dimensions' | 'map' | 'views';
|
||||||
|
|
||||||
// State for each slot
|
// State for each slot
|
||||||
export interface SlotState {
|
export interface SlotState {
|
||||||
@@ -54,30 +66,9 @@ export interface HighlightedPassage {
|
|||||||
selectedText: string;
|
selectedText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatPane specific props
|
// ChatPaneProps removed in rah-light
|
||||||
export interface ChatPaneProps extends BasePaneProps {
|
|
||||||
openTabsData: Node[];
|
|
||||||
activeTabId: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
onClearDimension?: () => void;
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
delegations: AgentDelegation[];
|
|
||||||
// Lifted state for persistence
|
|
||||||
chatMessages?: unknown[];
|
|
||||||
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
|
|
||||||
// Source awareness
|
|
||||||
highlightedPassage?: HighlightedPassage | null;
|
|
||||||
onClearPassage?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkflowsPane specific props
|
// GuidesPaneProps - just uses BasePaneProps (guides are self-contained)
|
||||||
export interface WorkflowsPaneProps extends BasePaneProps {
|
|
||||||
delegations: AgentDelegation[];
|
|
||||||
onNodeClick?: (nodeId: number) => void;
|
|
||||||
openTabsData?: Node[];
|
|
||||||
activeTabId?: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DimensionsPane specific props
|
// DimensionsPane specific props
|
||||||
export interface DimensionsPaneProps extends BasePaneProps {
|
export interface DimensionsPaneProps extends BasePaneProps {
|
||||||
@@ -112,8 +103,7 @@ export interface PaneHeaderProps {
|
|||||||
// Labels for pane types
|
// Labels for pane types
|
||||||
export const PANE_LABELS: Record<PaneType, string> = {
|
export const PANE_LABELS: Record<PaneType, string> = {
|
||||||
node: 'Nodes',
|
node: 'Nodes',
|
||||||
chat: 'Chat',
|
guides: 'Guides',
|
||||||
workflows: 'Workflows',
|
|
||||||
dimensions: 'Dimensions',
|
dimensions: 'Dimensions',
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
views: 'Feed',
|
views: 'Feed',
|
||||||
@@ -127,5 +117,5 @@ export const DEFAULT_SLOT_A: SlotState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_SLOT_B: SlotState = {
|
export const DEFAULT_SLOT_B: SlotState = {
|
||||||
type: 'chat',
|
type: 'guides',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function ContextViewer() {
|
|||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
<p style={descStyle}>
|
<p style={descStyle}>
|
||||||
Top 10 most-connected nodes are added to background context for chats and workflows.
|
Top 10 most-connected nodes are added to background context for tool execution.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Toggle */}
|
{/* 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 { createPortal } from 'react-dom';
|
||||||
import LogsViewer from './LogsViewer';
|
import LogsViewer from './LogsViewer';
|
||||||
import ToolsViewer from './ToolsViewer';
|
import ToolsViewer from './ToolsViewer';
|
||||||
import WorkflowsViewer from './WorkflowsViewer';
|
|
||||||
import ApiKeysViewer from './ApiKeysViewer';
|
import ApiKeysViewer from './ApiKeysViewer';
|
||||||
import DatabaseViewer from './DatabaseViewer';
|
import DatabaseViewer from './DatabaseViewer';
|
||||||
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
||||||
import ContextViewer from './ContextViewer';
|
import ContextViewer from './ContextViewer';
|
||||||
|
import GuidesViewer from './GuidesViewer';
|
||||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||||
|
|
||||||
export type SettingsTab =
|
export type SettingsTab =
|
||||||
| 'logs'
|
| 'logs'
|
||||||
| 'tools'
|
| 'tools'
|
||||||
| 'workflows'
|
| 'guides'
|
||||||
| 'apikeys'
|
| 'apikeys'
|
||||||
| 'database'
|
| 'database'
|
||||||
| 'context'
|
| 'context'
|
||||||
@@ -140,18 +140,18 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
|||||||
Tools
|
Tools
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setActiveTab('workflows')}
|
onClick={() => setActiveTab('guides')}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: activeTab === 'workflows' ? '#fff' : '#888',
|
color: activeTab === 'guides' ? '#fff' : '#888',
|
||||||
background: activeTab === 'workflows' ? '#1a3a2a' : 'transparent',
|
background: activeTab === 'guides' ? '#1a3a2a' : 'transparent',
|
||||||
borderLeft: activeTab === 'workflows' ? '3px solid #22c55e' : '3px solid transparent',
|
borderLeft: activeTab === 'guides' ? '3px solid #22c55e' : '3px solid transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Workflows
|
Guides
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setActiveTab('apikeys')}
|
onClick={() => setActiveTab('apikeys')}
|
||||||
@@ -295,7 +295,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
|||||||
>
|
>
|
||||||
{activeTab === 'logs' && 'System Logs'}
|
{activeTab === 'logs' && 'System Logs'}
|
||||||
{activeTab === 'tools' && 'Tools'}
|
{activeTab === 'tools' && 'Tools'}
|
||||||
{activeTab === 'workflows' && 'Workflows'}
|
{activeTab === 'guides' && 'Guides'}
|
||||||
{activeTab === 'apikeys' && 'API Keys'}
|
{activeTab === 'apikeys' && 'API Keys'}
|
||||||
{activeTab === 'database' && 'Knowledge Database'}
|
{activeTab === 'database' && 'Knowledge Database'}
|
||||||
{activeTab === 'context' && 'Auto-Context'}
|
{activeTab === 'context' && 'Auto-Context'}
|
||||||
@@ -329,7 +329,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
|||||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
|
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
|
||||||
{activeTab === 'tools' && <ToolsViewer />}
|
{activeTab === 'tools' && <ToolsViewer />}
|
||||||
{activeTab === 'workflows' && <WorkflowsViewer />}
|
{activeTab === 'guides' && <GuidesViewer />}
|
||||||
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
||||||
{activeTab === 'database' && <DatabaseViewer />}
|
{activeTab === 'database' && <DatabaseViewer />}
|
||||||
{activeTab === 'context' && <ContextViewer />}
|
{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.
|
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
|
1. **Retrieve Dimension Nodes**
|
||||||
- Call queryDimensionNodes with the active dimension name
|
- Call `queryDimensionNodes` with the active dimension name
|
||||||
- Set limit to 50 to get a comprehensive view
|
- Set limit to 50 to get a comprehensive view
|
||||||
- Note the total count and most connected nodes
|
- Note the total count and most connected nodes
|
||||||
|
|
||||||
2. ANALYZE THEMES
|
2. **Analyze Themes**
|
||||||
- Call searchContentEmbeddings with key concepts from the dimension
|
- Call `searchContentEmbeddings` with key concepts from the dimension
|
||||||
- Identify recurring themes, patterns, and relationships
|
- Identify recurring themes, patterns, and relationships
|
||||||
- Note any gaps or underrepresented areas
|
- Note any gaps or underrepresented areas
|
||||||
|
|
||||||
3. DOCUMENT FINDINGS
|
3. **Document Findings**
|
||||||
Call updateDimension to update the dimension description with:
|
Call `updateDimension` to update the dimension description with:
|
||||||
|
|
||||||
**Survey Summary** (append to existing description)
|
```
|
||||||
|
**Survey Summary**
|
||||||
|
|
||||||
**Node Count:** [X nodes]
|
**Node Count:** [X nodes]
|
||||||
**Top Hubs:** [List 3-5 most connected nodes with [NODE:id:"title"]]
|
**Top Hubs:** [List 3-5 most connected nodes with [NODE:id:"title"]]
|
||||||
|
|
||||||
**Themes:**
|
**Themes:**
|
||||||
- [Theme 1 - brief description]
|
- [Theme 1 — brief description]
|
||||||
- [Theme 2 - brief description]
|
- [Theme 2 — brief description]
|
||||||
|
|
||||||
**Gaps/Opportunities:**
|
**Gaps/Opportunities:**
|
||||||
- [Identified gap or area for expansion]
|
- [Identified gap or area for expansion]
|
||||||
|
|
||||||
**Connections to Other Dimensions:**
|
**Connections to Other Dimensions:**
|
||||||
- [Any cross-dimension patterns observed]
|
- [Any cross-dimension patterns observed]
|
||||||
|
```
|
||||||
|
|
||||||
4. RETURN SUMMARY
|
4. **Return Summary**
|
||||||
Reply with: "Surveyed [dimension] — [X nodes, key insight in <15 words]"
|
Reply with: "Surveyed [dimension] — [X nodes, key insight in <15 words]"
|
||||||
|
|
||||||
RULES
|
## Rules
|
||||||
|
|
||||||
- Keep total tool calls ≤ 5
|
- Keep total tool calls ≤ 5
|
||||||
- Focus on patterns across the dimension, not individual node details
|
- Focus on patterns across the dimension, not individual node details
|
||||||
- Identify both strengths (dense areas) and gaps (sparse areas)
|
- Identify both strengths (dense areas) and gaps (sparse areas)
|
||||||
- Reference specific nodes using [NODE:id:"title"] format
|
- Reference specific nodes using `[NODE:id:"title"]` format
|
||||||
|
- If no active dimension is set, inform the user to open a dimension folder first
|
||||||
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.
|
3. Ask for clarification only when tool usage would fail without it.
|
||||||
|
|
||||||
Operating principles:
|
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.
|
- 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 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.
|
- 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.
|
- 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.
|
- If the request is ambiguous and guessing would waste effort or cause errors.
|
||||||
|
|
||||||
Execution approach:
|
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.
|
- 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).
|
- 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.
|
- Treat "this conversation/paper/video" as the active focused node.
|
||||||
- When creating synthesis nodes, createEdge to all source nodes.
|
- 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.
|
- 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:
|
Tool strategy:
|
||||||
- Use tools directly—you already have everything you need.
|
- Use tools directly—you already have everything you need.
|
||||||
- queryNodes for titles, searchContentEmbeddings for content, queryEdge for connections.
|
- 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,243 +0,0 @@
|
|||||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
|
||||||
import { eventBroadcaster } from '@/services/events';
|
|
||||||
|
|
||||||
export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
|
|
||||||
export type DelegationAgentType = 'workflow' | 'wise-rah';
|
|
||||||
|
|
||||||
export interface AgentDelegation {
|
|
||||||
id: number;
|
|
||||||
sessionId: string;
|
|
||||||
task: string;
|
|
||||||
context: string[];
|
|
||||||
expectedOutcome?: string | null;
|
|
||||||
status: DelegationStatus;
|
|
||||||
summary?: string | null;
|
|
||||||
agentType: DelegationAgentType;
|
|
||||||
supabaseToken?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToDelegation(row: any): AgentDelegation {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
sessionId: row.session_id,
|
|
||||||
task: row.task,
|
|
||||||
context: (() => {
|
|
||||||
try {
|
|
||||||
return row.context ? JSON.parse(row.context) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
expectedOutcome: row.expected_outcome,
|
|
||||||
status: row.status as DelegationStatus,
|
|
||||||
summary: row.summary,
|
|
||||||
agentType: (row.agent_type || 'mini') as DelegationAgentType,
|
|
||||||
supabaseToken: row.supabase_token ?? null,
|
|
||||||
// SQLite CURRENT_TIMESTAMP is UTC, append 'Z' to parse correctly as UTC
|
|
||||||
createdAt: row.created_at ? row.created_at.replace(' ', 'T') + 'Z' : row.created_at,
|
|
||||||
updatedAt: row.updated_at ? row.updated_at.replace(' ', 'T') + 'Z' : row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTable() {
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
db.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_delegations (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT UNIQUE NOT NULL,
|
|
||||||
task TEXT NOT NULL,
|
|
||||||
context TEXT,
|
|
||||||
expected_outcome TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
summary TEXT,
|
|
||||||
agent_type TEXT NOT NULL DEFAULT 'mini',
|
|
||||||
supabase_token TEXT,
|
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add agent_type column if it doesn't exist (migration)
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('SELECT 1 FROM agent_delegations LIMIT 0');
|
|
||||||
const tableExists = stmt !== null;
|
|
||||||
|
|
||||||
if (tableExists) {
|
|
||||||
// Try to add the column, ignore if it already exists
|
|
||||||
try {
|
|
||||||
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'mini'`).run();
|
|
||||||
console.log('✅ Added agent_type column to agent_delegations table');
|
|
||||||
} catch (alterError: any) {
|
|
||||||
// Column already exists, ignore
|
|
||||||
if (!alterError.message?.includes('duplicate column')) {
|
|
||||||
console.warn('Migration warning:', alterError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN supabase_token TEXT`).run();
|
|
||||||
console.log('✅ Added supabase_token column to agent_delegations table');
|
|
||||||
} catch (alterError: any) {
|
|
||||||
if (!alterError.message?.includes('duplicate column')) {
|
|
||||||
console.warn('Migration warning:', alterError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Table doesn't exist yet or other error - it will be created with the columns
|
|
||||||
console.log('Table creation will include agent_type and supabase_token columns');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AgentDelegationService {
|
|
||||||
static createDelegation(input: {
|
|
||||||
task: string;
|
|
||||||
context?: string[];
|
|
||||||
expectedOutcome?: string | null;
|
|
||||||
agentType?: DelegationAgentType;
|
|
||||||
supabaseToken?: string | null;
|
|
||||||
}): AgentDelegation {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
const sessionId = `delegation_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const contextJson = JSON.stringify(input.context ?? []);
|
|
||||||
const agentType = input.agentType || 'mini';
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO agent_delegations (session_id, task, context, expected_outcome, status, agent_type, supabase_token)
|
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, ?)`
|
|
||||||
).run(
|
|
||||||
sessionId,
|
|
||||||
input.task,
|
|
||||||
contextJson,
|
|
||||||
input.expectedOutcome ?? null,
|
|
||||||
agentType,
|
|
||||||
input.supabaseToken ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT * FROM agent_delegations WHERE session_id = ?')
|
|
||||||
.get(sessionId);
|
|
||||||
|
|
||||||
const delegation = rowToDelegation(row);
|
|
||||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_CREATED', data: { delegation } });
|
|
||||||
return delegation;
|
|
||||||
}
|
|
||||||
|
|
||||||
static markInProgress(sessionId: string): AgentDelegation | null {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE agent_delegations
|
|
||||||
SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE session_id = ? AND status = 'queued'`
|
|
||||||
).run(sessionId);
|
|
||||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
|
||||||
if (!row) return null;
|
|
||||||
const delegation = rowToDelegation(row);
|
|
||||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
|
||||||
return delegation;
|
|
||||||
}
|
|
||||||
|
|
||||||
static touchDelegation(sessionId: string): void {
|
|
||||||
// Update the timestamp to prevent cleanup from killing active delegations
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE agent_delegations SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ? AND status = 'in_progress'`
|
|
||||||
).run(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static completeDelegation(sessionId: string, summary: string, status: DelegationStatus = 'completed'): AgentDelegation | null {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE agent_delegations
|
|
||||||
SET status = ?, summary = ?, updated_at = CURRENT_TIMESTAMP, supabase_token = NULL
|
|
||||||
WHERE session_id = ?`
|
|
||||||
).run(status, summary, sessionId);
|
|
||||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
|
||||||
if (!row) return null;
|
|
||||||
const delegation = rowToDelegation(row);
|
|
||||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
|
||||||
return delegation;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getBySessionId(sessionId: string): AgentDelegation | null {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
|
||||||
return row ? rowToDelegation(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDelegation(sessionId: string): AgentDelegation | null {
|
|
||||||
return this.getBySessionId(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
static listActive({ includeCompleted = true, limit = 100 }: { includeCompleted?: boolean; limit?: number } = {}): AgentDelegation[] {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
// Load all delegations - user closes them manually from UI
|
|
||||||
const rows = includeCompleted
|
|
||||||
? db.prepare(
|
|
||||||
`SELECT * FROM agent_delegations
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT ?`
|
|
||||||
).all(limit)
|
|
||||||
: db.prepare(
|
|
||||||
`SELECT * FROM agent_delegations
|
|
||||||
WHERE status IN ('queued','in_progress')
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT ?`
|
|
||||||
).all(limit);
|
|
||||||
return rows.map(rowToDelegation);
|
|
||||||
}
|
|
||||||
|
|
||||||
static listRecent(limit = 20): AgentDelegation[] {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT * FROM agent_delegations
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?`
|
|
||||||
).all(limit);
|
|
||||||
return rows.map(rowToDelegation);
|
|
||||||
}
|
|
||||||
|
|
||||||
static deleteDelegation(sessionId: string): boolean {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
const result = db.prepare('DELETE FROM agent_delegations WHERE session_id = ?').run(sessionId);
|
|
||||||
return result.changes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static cleanupStaleDelegations(timeoutMinutes = 15): number {
|
|
||||||
ensureTable();
|
|
||||||
const db = getSQLiteClient();
|
|
||||||
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
|
|
||||||
|
|
||||||
const result = db.prepare(`
|
|
||||||
UPDATE agent_delegations
|
|
||||||
SET status = 'failed',
|
|
||||||
summary = 'Task timed out (exceeded ${timeoutMinutes} minutes)',
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE status = 'in_progress'
|
|
||||||
AND updated_at < ?
|
|
||||||
`).run(cutoffTime);
|
|
||||||
|
|
||||||
const affectedCount = result.changes || 0;
|
|
||||||
|
|
||||||
if (affectedCount > 0) {
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT * FROM agent_delegations WHERE status = 'failed' AND summary LIKE 'Task timed out%'`
|
|
||||||
).all();
|
|
||||||
rows.forEach(row => {
|
|
||||||
const delegation = rowToDelegation(row);
|
|
||||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return affectedCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { AgentDelegationService } from './delegation';
|
|
||||||
import { summarizeToolExecution } from './toolResultUtils';
|
import { summarizeToolExecution } from './toolResultUtils';
|
||||||
import { youtubeExtractTool } from '@/tools/other/youtubeExtract';
|
import { youtubeExtractTool } from '@/tools/other/youtubeExtract';
|
||||||
import { websiteExtractTool } from '@/tools/other/websiteExtract';
|
import { websiteExtractTool } from '@/tools/other/websiteExtract';
|
||||||
import { paperExtractTool } from '@/tools/other/paperExtract';
|
import { paperExtractTool } from '@/tools/other/paperExtract';
|
||||||
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
||||||
import { summarizeTranscript } from './transcriptSummarizer';
|
import { summarizeTranscript } from './transcriptSummarizer';
|
||||||
|
import { eventBroadcaster } from '@/services/events';
|
||||||
|
|
||||||
export type QuickAddMode = 'link' | 'note' | 'chat';
|
export type QuickAddMode = 'link' | 'note' | 'chat';
|
||||||
|
|
||||||
@@ -340,41 +340,42 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput) {
|
export interface QuickAddResult {
|
||||||
|
success: boolean;
|
||||||
|
summary?: string;
|
||||||
|
error?: string;
|
||||||
|
nodeId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
|
||||||
const inputType = detectInputType(rawInput, mode);
|
const inputType = detectInputType(rawInput, mode);
|
||||||
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
|
|
||||||
const task = buildTaskPrompt(inputType, rawInput);
|
const task = buildTaskPrompt(inputType, rawInput);
|
||||||
const expectedOutcome = buildExpectedOutcome(inputType);
|
|
||||||
|
|
||||||
const delegation = AgentDelegationService.createDelegation({
|
try {
|
||||||
task,
|
let summary: string;
|
||||||
context,
|
if (inputType === 'note') {
|
||||||
expectedOutcome,
|
summary = await handleNoteQuickAdd(rawInput, task, description);
|
||||||
});
|
} else if (inputType === 'chat') {
|
||||||
|
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
||||||
setImmediate(async () => {
|
} else {
|
||||||
try {
|
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||||
AgentDelegationService.markInProgress(delegation.sessionId);
|
}
|
||||||
|
|
||||||
let summary: string;
|
// Extract node ID from summary if present
|
||||||
if (inputType === 'note') {
|
const nodeIdMatch = summary.match(/\[NODE:(\d+)/);
|
||||||
summary = await handleNoteQuickAdd(rawInput, task, description);
|
const nodeId = nodeIdMatch ? parseInt(nodeIdMatch[1], 10) : undefined;
|
||||||
} else if (inputType === 'chat') {
|
|
||||||
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
|
||||||
} else {
|
|
||||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
AgentDelegationService.completeDelegation(delegation.sessionId, summary, 'completed');
|
// Broadcast node created event
|
||||||
} catch (error) {
|
if (nodeId) {
|
||||||
const message = error instanceof Error ? error.message : 'unknown error';
|
eventBroadcaster.broadcast({
|
||||||
AgentDelegationService.completeDelegation(
|
type: 'NODE_CREATED',
|
||||||
delegation.sessionId,
|
data: { node: { id: nodeId, title: 'Quick Add' } }
|
||||||
`Quick Add failed: ${message}`,
|
});
|
||||||
'failed'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return delegation;
|
return { success: true, summary, nodeId };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'unknown error';
|
||||||
|
return { success: false, error: `Quick Add failed: ${message}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
|
||||||
import { RAH_MAIN_SYSTEM_PROMPT } from '@/config/prompts/rah-main';
|
|
||||||
import { RAH_EASY_SYSTEM_PROMPT } from '@/config/prompts/rah-easy';
|
|
||||||
import { WORKFLOW_EXECUTOR_SYSTEM_PROMPT } from '@/config/prompts/workflow-executor';
|
|
||||||
import type { AgentDefinition } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Code-first agent registry (opinionated, not database-driven)
|
|
||||||
* Agents are defined in code and cannot be modified by users
|
|
||||||
*/
|
|
||||||
export class AgentRegistry {
|
|
||||||
// Deterministic agent definitions baked into code
|
|
||||||
private static readonly AGENTS: Record<string, AgentDefinition> = {
|
|
||||||
'ra-h': {
|
|
||||||
id: 1,
|
|
||||||
key: 'ra-h',
|
|
||||||
displayName: 'ra-h (hard)',
|
|
||||||
description: 'Opinionated orchestrator agent',
|
|
||||||
model: 'anthropic/claude-sonnet-4.5',
|
|
||||||
role: 'orchestrator',
|
|
||||||
systemPrompt: RAH_MAIN_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
},
|
|
||||||
'ra-h-easy': {
|
|
||||||
id: 4,
|
|
||||||
key: 'ra-h-easy',
|
|
||||||
displayName: 'ra-h (easy)',
|
|
||||||
description: 'Fast, low-latency orchestrator',
|
|
||||||
model: 'openai/gpt-5-mini',
|
|
||||||
role: 'orchestrator',
|
|
||||||
systemPrompt: RAH_EASY_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
},
|
|
||||||
'workflow': {
|
|
||||||
id: 3,
|
|
||||||
key: 'workflow',
|
|
||||||
displayName: 'workflow agent',
|
|
||||||
description: 'Workflow executor (uses same model as easy mode)',
|
|
||||||
model: 'openai/gpt-5-mini',
|
|
||||||
role: 'planner',
|
|
||||||
systemPrompt: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('planner'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
},
|
|
||||||
// Alias for backwards compatibility
|
|
||||||
'wise-rah': {
|
|
||||||
id: 3,
|
|
||||||
key: 'workflow',
|
|
||||||
displayName: 'workflow agent',
|
|
||||||
description: 'Workflow executor (uses same model as easy mode)',
|
|
||||||
model: 'openai/gpt-5-mini',
|
|
||||||
role: 'planner',
|
|
||||||
systemPrompt: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('planner'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static async getAgentByKey(key: string): Promise<AgentDefinition | null> {
|
|
||||||
return this.AGENTS[key] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getAgentById(id: number): Promise<AgentDefinition | null> {
|
|
||||||
return Object.values(this.AGENTS).find(a => a.id === id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getEnabledAgents(): Promise<AgentDefinition[]> {
|
|
||||||
return Object.values(this.AGENTS).filter(a => a.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async orchestrator(): Promise<AgentDefinition> {
|
|
||||||
return this.AGENTS['ra-h'];
|
|
||||||
}
|
|
||||||
|
|
||||||
static async orchestratorForMode(mode: 'easy' | 'hard' = 'easy'): Promise<AgentDefinition> {
|
|
||||||
if (mode === 'hard') {
|
|
||||||
return this.AGENTS['ra-h'];
|
|
||||||
}
|
|
||||||
return this.AGENTS['ra-h-easy'];
|
|
||||||
}
|
|
||||||
|
|
||||||
static async planner(): Promise<AgentDefinition> {
|
|
||||||
return this.AGENTS['workflow'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
import { streamText, ModelMessage } from 'ai';
|
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
|
||||||
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
|
|
||||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
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 { delegationStreamBroadcaster } from '@/app/api/rah/delegations/stream/route';
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
AgentDelegationService.markInProgress(sessionId);
|
|
||||||
console.log('✅ [WorkflowExecutor] Delegation marked in progress');
|
|
||||||
|
|
||||||
// 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() : '');
|
|
||||||
|
|
||||||
const sanitizeForBroadcast = (value: unknown) => {
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
try {
|
|
||||||
return JSON.parse(JSON.stringify(value));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WorkflowExecutor] Failed to serialize delegation payload', error);
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitDelegationEvent = (payload: Record<string, unknown>) => {
|
|
||||||
delegationStreamBroadcaster.broadcast(sessionId, payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => {
|
|
||||||
emitDelegationEvent({
|
|
||||||
type: 'tool-input-start',
|
|
||||||
toolCallId,
|
|
||||||
toolName,
|
|
||||||
input: sanitizeForBroadcast(input),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitToolCompletion = (
|
|
||||||
toolCallId: string,
|
|
||||||
toolName: string,
|
|
||||||
rawResult: unknown,
|
|
||||||
summary: string,
|
|
||||||
status: 'complete' | 'error' = 'complete',
|
|
||||||
errorMessage?: string
|
|
||||||
) => {
|
|
||||||
emitDelegationEvent({
|
|
||||||
type: 'tool-output-available',
|
|
||||||
toolCallId,
|
|
||||||
toolName,
|
|
||||||
result: sanitizeForBroadcast(rawResult),
|
|
||||||
summary,
|
|
||||||
status,
|
|
||||||
error: errorMessage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
|
|
||||||
// Touch delegation every iteration to prevent cleanup from killing it
|
|
||||||
AgentDelegationService.touchDelegation(sessionId);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Stream text response to delegation chat
|
|
||||||
if (response.text && response.text.trim()) {
|
|
||||||
emitDelegationEvent({
|
|
||||||
type: 'text-delta',
|
|
||||||
delta: response.text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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`);
|
|
||||||
|
|
||||||
// Broadcast new assistant message for next iteration
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
emitDelegationEvent({ type: 'assistant-message' });
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
emitDelegationEvent({
|
|
||||||
type: 'assistant-message',
|
|
||||||
});
|
|
||||||
emitDelegationEvent({
|
|
||||||
type: 'text-delta',
|
|
||||||
delta: 'Workflow executor attempted to summarise but the response was empty. Check tool logs above for context.',
|
|
||||||
});
|
|
||||||
throw new Error('Workflow executor returned empty summary');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[WorkflowExecutor] summary:', summary);
|
|
||||||
|
|
||||||
// Emit final summary to the stream so it appears in the UI
|
|
||||||
emitDelegationEvent({ type: 'assistant-message' });
|
|
||||||
emitDelegationEvent({
|
|
||||||
type: 'text-delta',
|
|
||||||
delta: 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
const delegation = AgentDelegationService.getDelegation(sessionId);
|
|
||||||
const delegationId = delegation?.id;
|
|
||||||
|
|
||||||
await ChatLoggingMiddleware.logChatInteraction(
|
|
||||||
task,
|
|
||||||
summary,
|
|
||||||
{
|
|
||||||
helperName: 'workflow-agent',
|
|
||||||
agentType: 'planner',
|
|
||||||
delegationId: 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] Completing delegation with summary');
|
|
||||||
return AgentDelegationService.completeDelegation(sessionId, summary);
|
|
||||||
} 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 delegation error';
|
|
||||||
|
|
||||||
// Broadcast error to delegation stream
|
|
||||||
delegationStreamBroadcaster.broadcast(sessionId, {
|
|
||||||
type: 'assistant-message',
|
|
||||||
});
|
|
||||||
delegationStreamBroadcaster.broadcast(sessionId, {
|
|
||||||
type: 'text-delta',
|
|
||||||
delta: `Workflow executor failed: ${message}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
AgentDelegationService.completeDelegation(sessionId, `Workflow executor failed: ${message}`, 'failed');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import OpenAI from 'openai';
|
|
||||||
import type { AgentDefinition } from './agents/types';
|
|
||||||
import { Node } from '@/types/database';
|
|
||||||
import { getToolSchemas, executeTool } from '../tools/infrastructure/registry';
|
|
||||||
import { getSQLiteClient } from './database/sqlite-client';
|
|
||||||
import { apiKeyService } from './storage/apiKeys';
|
|
||||||
|
|
||||||
// Initialize OpenAI client with dynamic API key support
|
|
||||||
function getOpenAiClient(): OpenAI {
|
|
||||||
const apiKey = apiKeyService.getOpenAiKey();
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('OpenAI API key required. Please:\n1. Click the Settings icon (⚙️) in the bottom left\n2. Go to API Keys tab\n3. Add your OpenAI API key\n\nGet your key at: https://platform.openai.com/api-keys');
|
|
||||||
}
|
|
||||||
return new OpenAI({ apiKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
role: 'system' | 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolCall {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
params: any;
|
|
||||||
result: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatResponse {
|
|
||||||
response: string;
|
|
||||||
toolCalls?: ToolCall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AIService {
|
|
||||||
/**
|
|
||||||
* Chat with a helper using GPT-4o-mini with function calling
|
|
||||||
*/
|
|
||||||
static async chatWithHelper(
|
|
||||||
helper: AgentDefinition,
|
|
||||||
message: string,
|
|
||||||
selectedNodeIds: number[] = [],
|
|
||||||
messageHistory: ChatMessage[] = []
|
|
||||||
): Promise<ChatResponse> {
|
|
||||||
try {
|
|
||||||
// Get selected nodes details
|
|
||||||
const selectedNodes = await this.getSelectedNodes(selectedNodeIds);
|
|
||||||
|
|
||||||
// Build context for tools
|
|
||||||
const toolContext = {
|
|
||||||
selectedNodes,
|
|
||||||
database: getSQLiteClient()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build messages array
|
|
||||||
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: this.buildSystemPrompt(helper, selectedNodes)
|
|
||||||
},
|
|
||||||
// Add message history
|
|
||||||
...messageHistory.map(msg => ({
|
|
||||||
role: msg.role as 'user' | 'assistant',
|
|
||||||
content: msg.content
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: message
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get tool schemas for this helper
|
|
||||||
const toolSchemas = getToolSchemas(helper.availableTools);
|
|
||||||
|
|
||||||
// Make OpenAI API call
|
|
||||||
const openai = getOpenAiClient();
|
|
||||||
const completion = await openai.chat.completions.create({
|
|
||||||
model: helper.model,
|
|
||||||
messages,
|
|
||||||
tools: toolSchemas.length > 0 ? toolSchemas : undefined,
|
|
||||||
tool_choice: toolSchemas.length > 0 ? 'auto' : undefined,
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMessage = completion.choices[0]?.message;
|
|
||||||
|
|
||||||
if (!assistantMessage) {
|
|
||||||
throw new Error('No response from OpenAI');
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = assistantMessage.content || '';
|
|
||||||
const toolCalls: ToolCall[] = [];
|
|
||||||
|
|
||||||
// Handle tool calls if present
|
|
||||||
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
|
||||||
console.log(`Agent ${helper.key} is calling ${assistantMessage.tool_calls.length} tools`);
|
|
||||||
|
|
||||||
for (const toolCall of assistantMessage.tool_calls) {
|
|
||||||
try {
|
|
||||||
const toolName = toolCall.function.name;
|
|
||||||
const toolParams = JSON.parse(toolCall.function.arguments);
|
|
||||||
|
|
||||||
console.log(`Executing tool: ${toolName}`, toolParams);
|
|
||||||
|
|
||||||
// Execute the tool
|
|
||||||
const result = await executeTool(toolName, toolParams, toolContext);
|
|
||||||
|
|
||||||
toolCalls.push({
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolName,
|
|
||||||
params: toolParams,
|
|
||||||
result
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Tool ${toolName} completed:`, result.success ? 'success' : 'failed');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error executing tool ${toolCall.function.name}:`, error);
|
|
||||||
toolCalls.push({
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolCall.function.name,
|
|
||||||
params: {},
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Tool execution failed'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have tool results, make another call to get the final response
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
const toolMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: assistantMessage.content,
|
|
||||||
tool_calls: assistantMessage.tool_calls
|
|
||||||
},
|
|
||||||
...toolCalls.map(toolCall => ({
|
|
||||||
role: 'tool' as const,
|
|
||||||
tool_call_id: toolCall.id,
|
|
||||||
content: JSON.stringify(toolCall.result)
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
const finalCompletion = await getOpenAiClient().chat.completions.create({
|
|
||||||
model: 'gpt-5-mini',
|
|
||||||
messages: toolMessages,
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
response = finalCompletion.choices[0]?.message?.content || response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
response,
|
|
||||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in chatWithHelper:', error);
|
|
||||||
throw new Error(
|
|
||||||
error instanceof Error ? error.message : 'Failed to process chat message'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get selected nodes with their details
|
|
||||||
*/
|
|
||||||
private static async getSelectedNodes(nodeIds: number[]): Promise<Node[]> {
|
|
||||||
if (nodeIds.length === 0) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sqlite = getSQLiteClient();
|
|
||||||
const placeholders = nodeIds.map(() => '?').join(', ');
|
|
||||||
const result = sqlite.query<any>(
|
|
||||||
`SELECT n.id, n.title, n.content, n.link, n.metadata, n.chunk,
|
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
|
||||||
n.created_at, n.updated_at,
|
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
|
||||||
FROM nodes n
|
|
||||||
WHERE n.id IN (${placeholders})
|
|
||||||
ORDER BY n.created_at DESC`,
|
|
||||||
nodeIds
|
|
||||||
);
|
|
||||||
return result.rows.map((row: any) => ({
|
|
||||||
...row,
|
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching selected nodes:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build system prompt with context
|
|
||||||
*/
|
|
||||||
private static buildSystemPrompt(helper: AgentDefinition, selectedNodes: Node[]): string {
|
|
||||||
let systemPrompt = helper.systemPrompt;
|
|
||||||
|
|
||||||
// Universal rule: ensure clickable node labels in UI
|
|
||||||
systemPrompt += '\n\nNode references: Always format nodes as [NODE:id:"title"] so the UI renders clickable labels.';
|
|
||||||
|
|
||||||
// Add context about selected nodes
|
|
||||||
if (selectedNodes.length > 0) {
|
|
||||||
systemPrompt += '\n\n## Selected Nodes Context\n';
|
|
||||||
systemPrompt += `You have ${selectedNodes.length} node(s) selected:\n\n`;
|
|
||||||
|
|
||||||
selectedNodes.forEach((node, index) => {
|
|
||||||
systemPrompt += `${index + 1}. **${node.title || 'Untitled'}**\n`;
|
|
||||||
if (node.dimensions?.length > 0) {
|
|
||||||
systemPrompt += ` - Dimensions: ${node.dimensions.join(', ')}\n`;
|
|
||||||
}
|
|
||||||
if (node.link) {
|
|
||||||
systemPrompt += ` - URL: ${node.link}\n`;
|
|
||||||
}
|
|
||||||
systemPrompt += '\n';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
systemPrompt += '\n\n## Context\nNo nodes are currently selected. You can still help with general queries and web searches.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add available tools information
|
|
||||||
if (helper.availableTools.length > 0) {
|
|
||||||
systemPrompt += '\n\n## Available Tools\n';
|
|
||||||
systemPrompt += `You have access to the following tools: ${helper.availableTools.join(', ')}\n`;
|
|
||||||
systemPrompt += 'Use these tools when they would be helpful to answer the user\'s question.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtle, hard-coded intent capture across all helpers (non-intrusive)
|
|
||||||
systemPrompt += '\n\n## Subtle Intent Capture\n';
|
|
||||||
systemPrompt += [
|
|
||||||
'When it would clearly help you serve better, briefly ask for the user\'s intent (their "why").',
|
|
||||||
'Keep it lightweight and optional (one short sentence, once per topic).',
|
|
||||||
'Examples: "What are you hoping to do with this?", "What\'s the goal behind adding this?"',
|
|
||||||
'If the user answers, incorporate the rationale into your next steps and outputs.'
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return systemPrompt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stub for SupabaseTokenRegistry - not used in open source version
|
|
||||||
* The private version uses this for backend API authentication.
|
|
||||||
* In open source mode, all API calls go directly to OpenAI/Anthropic.
|
|
||||||
*/
|
|
||||||
export class SupabaseTokenRegistry {
|
|
||||||
static async get(_key: string): Promise<string | null> {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getLast(_key: string): string | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static set(_key: string, _value: string): void {
|
|
||||||
// No-op in OS version
|
|
||||||
}
|
|
||||||
|
|
||||||
static delete(_key: string): void {
|
|
||||||
// No-op in OS version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ export interface DatabaseEvent {
|
|||||||
| 'AGENT_UPDATED'
|
| 'AGENT_UPDATED'
|
||||||
| 'AGENT_DELEGATION_CREATED'
|
| 'AGENT_DELEGATION_CREATED'
|
||||||
| 'AGENT_DELEGATION_UPDATED'
|
| 'AGENT_DELEGATION_UPDATED'
|
||||||
| 'WORKFLOW_PROGRESS'
|
| 'GUIDE_UPDATED'
|
||||||
| 'CONNECTION_ESTABLISHED';
|
| 'CONNECTION_ESTABLISHED';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
data: 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,301 +0,0 @@
|
|||||||
import { Node } from '@/types/database';
|
|
||||||
import { AgentRegistry } from '@/services/agents/registry';
|
|
||||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
|
||||||
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
|
||||||
import type { CacheableBlock, SystemPromptResult } from '@/types/prompts';
|
|
||||||
// buildAutoContextBlock removed - agent queries top nodes on-demand via sqliteQuery
|
|
||||||
|
|
||||||
export interface NodeContext {
|
|
||||||
nodes: Node[];
|
|
||||||
activeNodeId: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DimensionContext {
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
isPriority: boolean;
|
|
||||||
nodeCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextBuilderOptions {
|
|
||||||
maxPrimaryContent?: number; // Default: 500 chars
|
|
||||||
maxSecondaryContent?: number; // Default: 200 chars
|
|
||||||
includeMetadata?: boolean; // Include created/updated timestamps
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_CONTEXT = `=== RA-H BASE CONTEXT ===
|
|
||||||
You have access to the RA-H knowledge graph via two approaches:
|
|
||||||
|
|
||||||
**sqliteQuery tool** — Use for flexible read operations:
|
|
||||||
- Ad-hoc queries, exploration, complex JOINs, aggregations
|
|
||||||
- Any query pattern not covered by structured tools
|
|
||||||
- Example: SELECT n.title, COUNT(e.id) FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id GROUP BY n.id
|
|
||||||
|
|
||||||
**Structured tools** — Use for these specific cases:
|
|
||||||
- Writes: createNode, updateNode, createEdge (need validation, embeddings, hooks)
|
|
||||||
- Semantic search: searchContentEmbeddings (needs vector DB)
|
|
||||||
- External APIs: webSearch, youtubeExtract, websiteExtract, paperExtract
|
|
||||||
|
|
||||||
**Schema Quick Reference:**
|
|
||||||
- nodes: id, title, content, chunk, dimensions (JSON array), url, created_at
|
|
||||||
- edges: id, from_node_id, to_node_id, context (JSON), source, explanation
|
|
||||||
- dimensions: id, name, description, is_locked
|
|
||||||
|
|
||||||
**Other Context:**
|
|
||||||
- Node references: use [NODE:id:"title"] format for clickable UI labels
|
|
||||||
- Focused nodes show truncated content; query for full detail
|
|
||||||
- "this conversation/paper/video" refers to the primary focused node
|
|
||||||
`;
|
|
||||||
|
|
||||||
function buildStaticBaseContext(): string {
|
|
||||||
return BASE_CONTEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAgentInstructionsBlock(helperKey: string, systemPrompt: string): string {
|
|
||||||
return `=== AGENT INSTRUCTIONS (${helperKey}) ===\n${systemPrompt}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrimaryOrchestrator(helperKey: string): boolean {
|
|
||||||
return helperKey === 'ra-h' || helperKey === 'ra-h-easy';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildToolDefinitionsBlock(toolNames: string[]): string {
|
|
||||||
if (!Array.isArray(toolNames) || toolNames.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const tools = getHelperTools(toolNames);
|
|
||||||
const lines: string[] = ['=== AVAILABLE TOOLS ==='];
|
|
||||||
|
|
||||||
Object.entries(tools).forEach(([name, tool]) => {
|
|
||||||
if (tool?.description) {
|
|
||||||
lines.push(`\n## ${name}\n${tool.description.trim()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildWorkflowDefinitionsBlock(helperKey: string): Promise<string | null> {
|
|
||||||
// Only include for primary orchestrator variants
|
|
||||||
if (!isPrimaryOrchestrator(helperKey)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const workflows = await WorkflowRegistry.getEnabledWorkflows();
|
|
||||||
if (workflows.length === 0) return null;
|
|
||||||
|
|
||||||
const lines: string[] = ['=== AVAILABLE WORKFLOWS ==='];
|
|
||||||
|
|
||||||
for (const workflow of workflows) {
|
|
||||||
lines.push(`\n## ${workflow.displayName} (${workflow.key})`);
|
|
||||||
lines.push(workflow.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Workflow definitions load failed (contextBuilder):', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateToWords(text: string, maxWords: number): string {
|
|
||||||
const words = text.trim().split(/\s+/);
|
|
||||||
if (words.length <= maxWords) return text;
|
|
||||||
return words.slice(0, maxWords).join(' ') + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMetadata(metadata: unknown): Record<string, any> {
|
|
||||||
if (!metadata) return {};
|
|
||||||
if (typeof metadata === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(metadata);
|
|
||||||
if (parsed && typeof parsed === 'object') {
|
|
||||||
return parsed as Record<string, any>;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse node metadata JSON:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof metadata === 'object') {
|
|
||||||
if (metadata === null) return {};
|
|
||||||
return metadata as Record<string, any>;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeChunkStatus(node: Node): string {
|
|
||||||
const status = node.chunk_status || 'unknown';
|
|
||||||
const chunkLength = typeof node.chunk === 'string' ? node.chunk.length : 0;
|
|
||||||
const approxChars = chunkLength > 0 ? ` (~${Math.max(1, Math.round(chunkLength / 1000))}k chars)` : '';
|
|
||||||
const metadata = parseMetadata(node.metadata);
|
|
||||||
const transcriptLength = typeof metadata.transcript_length === 'number'
|
|
||||||
? metadata.transcript_length
|
|
||||||
: undefined;
|
|
||||||
let transcriptLabel = '';
|
|
||||||
if (transcriptLength && transcriptLength > 0) {
|
|
||||||
transcriptLabel = `, transcript ≈${Math.max(1, Math.round(transcriptLength / 1000))}k chars`;
|
|
||||||
}
|
|
||||||
const embeddingsAvailable = status === 'chunked' || chunkLength > 0;
|
|
||||||
return `Chunks: ${status}${approxChars}${transcriptLabel}; Embeddings: ${embeddingsAvailable ? 'available' : 'missing'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the dynamic focused nodes context section
|
|
||||||
*/
|
|
||||||
export function buildFocusedNodesBlock(
|
|
||||||
context: NodeContext,
|
|
||||||
options: ContextBuilderOptions = {}
|
|
||||||
): string {
|
|
||||||
if (!context.nodes || context.nodes.length === 0) {
|
|
||||||
return '\n=== CURRENT FOCUS ===\nNo nodes currently in focus.';
|
|
||||||
}
|
|
||||||
|
|
||||||
let contextString = '=== CURRENT FOCUS ===';
|
|
||||||
contextString += '\n25-word previews; use queryNodes/searchContentEmbeddings for full detail\n';
|
|
||||||
|
|
||||||
const validNodes = context.nodes.filter(n => n != null);
|
|
||||||
const activeNode = validNodes.find(n => n.id === context.activeNodeId);
|
|
||||||
const otherNodes = validNodes.filter(n => n.id !== context.activeNodeId);
|
|
||||||
|
|
||||||
if (activeNode) {
|
|
||||||
contextString += `\n[PRIMARY - Tab 1]\nID: ${activeNode.id} | "${activeNode.title || 'Untitled'}"\n${truncateToWords(activeNode.content || 'No content', 25)}\nLink: ${activeNode.link || 'No link'}`;
|
|
||||||
contextString += `\n${describeChunkStatus(activeNode)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherNodes.length > 0) {
|
|
||||||
otherNodes.forEach((node, index) => {
|
|
||||||
contextString += `\n\n[Tab ${index + 2}]\nID: ${node.id} | "${node.title || 'Untitled'}"\n${truncateToWords(node.content || 'No content', 25)}\nLink: ${node.link || 'No link'}\n${describeChunkStatus(node)}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
contextString += '\n===================================';
|
|
||||||
return contextString;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches dimension context from API or database
|
|
||||||
*/
|
|
||||||
async function fetchDimensionContext(dimensionName: string): Promise<DimensionContext | null> {
|
|
||||||
try {
|
|
||||||
// In server context, we can call the API internally or query directly
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/dimensions/${encodeURIComponent(dimensionName)}/context`);
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json();
|
|
||||||
return data.success ? data.data : null;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch dimension context:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the dimension context block for the system prompt
|
|
||||||
*/
|
|
||||||
function buildDimensionContextBlock(dimension: DimensionContext | null): string {
|
|
||||||
if (!dimension) return '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
=== ACTIVE DIMENSION ===
|
|
||||||
Dimension: "${dimension.name}"
|
|
||||||
Description: ${dimension.description || 'No description'}
|
|
||||||
Node Count: ${dimension.nodeCount} nodes
|
|
||||||
Priority: ${dimension.isPriority ? 'Yes (locked)' : 'No'}
|
|
||||||
|
|
||||||
Note: Use queryDimensionNodes tool to retrieve actual nodes in this dimension.
|
|
||||||
===================================
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds system prompt as cacheable blocks (Anthropic prompt caching)
|
|
||||||
*/
|
|
||||||
export async function buildSystemPromptBlocks(
|
|
||||||
nodeContext: NodeContext,
|
|
||||||
helperComponentKey: string,
|
|
||||||
options?: ContextBuilderOptions
|
|
||||||
): Promise<SystemPromptResult> {
|
|
||||||
const helper = await AgentRegistry.getAgentByKey(helperComponentKey);
|
|
||||||
const isAnthropic = helper?.model?.startsWith('anthropic/');
|
|
||||||
const cacheControl = isAnthropic ? { type: 'ephemeral' as const } : undefined;
|
|
||||||
const blocks: CacheableBlock[] = [];
|
|
||||||
|
|
||||||
const baseContext = buildStaticBaseContext().trim();
|
|
||||||
const helperPrompt = helper?.systemPrompt || 'No instructions provided.';
|
|
||||||
const instructionsBlock = buildAgentInstructionsBlock(helperComponentKey, helperPrompt).trim();
|
|
||||||
|
|
||||||
const combinedInstructions = [baseContext, instructionsBlock]
|
|
||||||
.filter(section => section.length > 0)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
if (combinedInstructions.length > 0) {
|
|
||||||
blocks.push({
|
|
||||||
type: 'text',
|
|
||||||
text: combinedInstructions,
|
|
||||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableToolNames = helper?.availableTools?.length
|
|
||||||
? helper.availableTools
|
|
||||||
: getDefaultToolNamesForRole(helper?.role === 'executor' ? 'executor' : 'orchestrator');
|
|
||||||
const toolBlock = buildToolDefinitionsBlock(availableToolNames);
|
|
||||||
if (toolBlock.trim().length > 0) {
|
|
||||||
blocks.push({
|
|
||||||
type: 'text',
|
|
||||||
text: toolBlock,
|
|
||||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflowBlock = await buildWorkflowDefinitionsBlock(helperComponentKey);
|
|
||||||
if (workflowBlock && workflowBlock.trim().length > 0) {
|
|
||||||
blocks.push({
|
|
||||||
type: 'text',
|
|
||||||
text: workflowBlock,
|
|
||||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOVED: autoContextBlock (top 10 nodes)
|
|
||||||
// Agent can now query this on-demand via sqliteQuery:
|
|
||||||
// SELECT id, title, COUNT(e.id) as edges FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id OR n.id = e.to_node_id GROUP BY n.id ORDER BY edges DESC LIMIT 10
|
|
||||||
|
|
||||||
// Add dimension context if an active dimension is provided
|
|
||||||
if (nodeContext.activeDimension) {
|
|
||||||
const dimensionContext = await fetchDimensionContext(nodeContext.activeDimension);
|
|
||||||
const dimensionBlock = buildDimensionContextBlock(dimensionContext);
|
|
||||||
if (dimensionBlock.trim().length > 0) {
|
|
||||||
blocks.push({ type: 'text', text: dimensionBlock });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusBlock = buildFocusedNodesBlock(nodeContext, options);
|
|
||||||
blocks.push({ type: 'text', text: focusBlock });
|
|
||||||
|
|
||||||
return { blocks, cacheHit: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy string-based system prompt (for backward compatibility)
|
|
||||||
* @deprecated Use buildSystemPromptBlocks for Anthropic caching support
|
|
||||||
*/
|
|
||||||
export async function buildSystemPrompt(
|
|
||||||
nodeContext: NodeContext,
|
|
||||||
helperComponentKey: string,
|
|
||||||
options?: ContextBuilderOptions
|
|
||||||
): Promise<{ systemPrompt: string; cacheHit: boolean }> {
|
|
||||||
// Convert blocks to string for legacy callers
|
|
||||||
const result = await buildSystemPromptBlocks(nodeContext, helperComponentKey, options);
|
|
||||||
const systemPrompt = result.blocks.map(b => b.text).join('');
|
|
||||||
return { systemPrompt, cacheHit: result.cacheHit };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads helper instructions from JSON file
|
|
||||||
*/
|
|
||||||
// DB-backed; no-op placeholder kept for API stability if imported elsewhere
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import fs from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
interface LogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
helper: string;
|
|
||||||
type: 'USER_MESSAGE' | 'SYSTEM_PROMPT' | 'TOOL_CALL' | 'TOOL_RESULT' | 'ASSISTANT_RESPONSE' | 'ERROR';
|
|
||||||
content: any;
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_DIR = path.join(process.cwd(), 'logs');
|
|
||||||
const LOG_FILE = path.join(LOG_DIR, 'helper-interactions.log');
|
|
||||||
|
|
||||||
class HelperLogger {
|
|
||||||
private sessionId: string;
|
|
||||||
private logDirEnsured = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.sessionId = Date.now().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureLogDir() {
|
|
||||||
if (this.logDirEnsured) return;
|
|
||||||
try {
|
|
||||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
|
||||||
this.logDirEnsured = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create log directory:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeLog(entry: LogEntry) {
|
|
||||||
try {
|
|
||||||
await this.ensureLogDir();
|
|
||||||
const logLine = JSON.stringify(entry) + '\n';
|
|
||||||
await fs.appendFile(LOG_FILE, logLine);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to write log:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logUserMessage(helper: string, messages: any[], openTabs: any[], activeTabId: any) {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
helper,
|
|
||||||
type: 'USER_MESSAGE',
|
|
||||||
content: {
|
|
||||||
lastMessage: messages[messages.length - 1],
|
|
||||||
messageCount: messages.length,
|
|
||||||
openTabIds: openTabs.map(t => t.id),
|
|
||||||
activeTabId
|
|
||||||
},
|
|
||||||
sessionId: this.sessionId
|
|
||||||
};
|
|
||||||
this.writeLog(entry);
|
|
||||||
console.log(`✓ [${helper}] Request received`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logSystemPrompt(helper: string, systemPrompt: string, cacheHit: boolean) {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
helper,
|
|
||||||
type: 'SYSTEM_PROMPT',
|
|
||||||
content: {
|
|
||||||
systemPrompt,
|
|
||||||
cacheHit
|
|
||||||
},
|
|
||||||
sessionId: this.sessionId
|
|
||||||
};
|
|
||||||
this.writeLog(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
logToolCall(helper: string, toolName: string, parameters: any) {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
helper,
|
|
||||||
type: 'TOOL_CALL',
|
|
||||||
content: { toolName, parameters },
|
|
||||||
sessionId: this.sessionId
|
|
||||||
};
|
|
||||||
this.writeLog(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
logToolResult(helper: string, toolName: string, result: any) {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
helper,
|
|
||||||
type: 'TOOL_RESULT',
|
|
||||||
content: {
|
|
||||||
toolName,
|
|
||||||
result
|
|
||||||
},
|
|
||||||
sessionId: this.sessionId
|
|
||||||
};
|
|
||||||
this.writeLog(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
logAssistantResponse(helper: string, response: string) {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
helper,
|
|
||||||
type: 'ASSISTANT_RESPONSE',
|
|
||||||
content: { response },
|
|
||||||
sessionId: this.sessionId
|
|
||||||
};
|
|
||||||
this.writeLog(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
logError(helper: string, error: Error | string) {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
helper,
|
|
||||||
type: 'ERROR',
|
|
||||||
content: error instanceof Error ? {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack
|
|
||||||
} : { message: error },
|
|
||||||
sessionId: this.sessionId
|
|
||||||
};
|
|
||||||
this.writeLog(entry);
|
|
||||||
console.error(`❌ [${helper}] Error occurred`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const helperLogger = new HelperLogger();
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
|
||||||
|
|
||||||
export interface VoiceUsageLogEntry {
|
|
||||||
sessionId?: string | null;
|
|
||||||
helperName?: string | null;
|
|
||||||
requestId: string;
|
|
||||||
messageId?: string | null;
|
|
||||||
voice: string;
|
|
||||||
model: string;
|
|
||||||
charCount: number;
|
|
||||||
costUsd: number;
|
|
||||||
durationMs?: number | null;
|
|
||||||
textPreview?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatRow = {
|
|
||||||
id: number;
|
|
||||||
metadata: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseMetadata(raw: unknown): Record<string, any> {
|
|
||||||
if (!raw) return {};
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[VoiceUsage] Failed to parse chat metadata JSON', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof raw === 'object') {
|
|
||||||
return (raw as Record<string, any>) || {};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyVoiceUsageMetadata(
|
|
||||||
metadata: Record<string, any>,
|
|
||||||
entry: VoiceUsageLogEntry,
|
|
||||||
loggedAt: string
|
|
||||||
): Record<string, any> {
|
|
||||||
const usageList = Array.isArray(metadata.voice_usage) ? metadata.voice_usage : [];
|
|
||||||
|
|
||||||
const usageEntry = {
|
|
||||||
request_id: entry.requestId,
|
|
||||||
message_id: entry.messageId ?? null,
|
|
||||||
chars: entry.charCount,
|
|
||||||
cost_usd: entry.costUsd,
|
|
||||||
voice: entry.voice,
|
|
||||||
model: entry.model,
|
|
||||||
duration_ms: entry.durationMs ?? null,
|
|
||||||
logged_at: loggedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
usageList.push(usageEntry);
|
|
||||||
const MAX_USAGE_HISTORY = 20;
|
|
||||||
metadata.voice_usage = usageList.slice(-MAX_USAGE_HISTORY);
|
|
||||||
|
|
||||||
const currentCharsTotal = Number(metadata.voice_tts_chars_total) || 0;
|
|
||||||
const currentCostTotal = Number(metadata.voice_tts_cost_usd_total) || Number(metadata.voice_tts_cost_total_usd) || 0;
|
|
||||||
const currentRequestCount = Number(metadata.voice_tts_request_count) || 0;
|
|
||||||
|
|
||||||
metadata.voice_tts_chars_total = currentCharsTotal + entry.charCount;
|
|
||||||
metadata.voice_tts_cost_usd_total = parseFloat((currentCostTotal + entry.costUsd).toFixed(6));
|
|
||||||
metadata.voice_tts_request_count = currentRequestCount + 1;
|
|
||||||
|
|
||||||
metadata.voice_tts_chars = entry.charCount;
|
|
||||||
metadata.voice_tts_cost_usd = entry.costUsd;
|
|
||||||
metadata.voice_request_id = entry.requestId;
|
|
||||||
metadata.voice_tts_voice = entry.voice;
|
|
||||||
metadata.voice_tts_model = entry.model;
|
|
||||||
metadata.voice_tts_duration_ms = entry.durationMs ?? null;
|
|
||||||
metadata.voice_tts_last_logged_at = loggedAt;
|
|
||||||
metadata.voice_message_id = entry.messageId ?? null;
|
|
||||||
|
|
||||||
return metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordVoiceUsage(entry: VoiceUsageLogEntry): void {
|
|
||||||
try {
|
|
||||||
const sqlite = getSQLiteClient();
|
|
||||||
const loggedAt = new Date().toISOString();
|
|
||||||
let chatId: number | null = null;
|
|
||||||
|
|
||||||
if (entry.sessionId) {
|
|
||||||
try {
|
|
||||||
const row = sqlite
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT id, metadata
|
|
||||||
FROM chats
|
|
||||||
WHERE json_extract(metadata, '$.session_id') = ?
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.get(entry.sessionId) as ChatRow | undefined;
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
chatId = row.id;
|
|
||||||
const parsedMetadata = applyVoiceUsageMetadata(parseMetadata(row.metadata), entry, loggedAt);
|
|
||||||
sqlite
|
|
||||||
.prepare(`UPDATE chats SET metadata = ? WHERE id = ?`)
|
|
||||||
.run(JSON.stringify(parsedMetadata), row.id);
|
|
||||||
} else {
|
|
||||||
console.warn(`[VoiceUsage] No chat row found for session ${entry.sessionId}, logging standalone entry.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VoiceUsage] Failed to attach usage to chat metadata', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sqlite
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
INSERT INTO voice_usage (
|
|
||||||
chat_id,
|
|
||||||
session_id,
|
|
||||||
helper_name,
|
|
||||||
request_id,
|
|
||||||
message_id,
|
|
||||||
voice,
|
|
||||||
model,
|
|
||||||
chars,
|
|
||||||
cost_usd,
|
|
||||||
duration_ms,
|
|
||||||
text_preview,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
chatId,
|
|
||||||
entry.sessionId ?? null,
|
|
||||||
entry.helperName ?? null,
|
|
||||||
entry.requestId,
|
|
||||||
entry.messageId ?? null,
|
|
||||||
entry.voice,
|
|
||||||
entry.model,
|
|
||||||
entry.charCount,
|
|
||||||
entry.costUsd,
|
|
||||||
entry.durationMs ?? null,
|
|
||||||
entry.textPreview ?? null,
|
|
||||||
loggedAt
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VoiceUsage] Failed to insert voice usage row', error);
|
|
||||||
}
|
|
||||||
} catch (outerError) {
|
|
||||||
console.error('[VoiceUsage] Unexpected error while recording usage', outerError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: {
|
orchestration: {
|
||||||
id: 'orchestration',
|
id: 'orchestration',
|
||||||
name: 'Orchestration',
|
name: 'Orchestration',
|
||||||
description: 'Workflows, web search, and reasoning (orchestrator only)',
|
description: 'Web search and reasoning tools',
|
||||||
icon: '●',
|
icon: '●',
|
||||||
color: '#8b5cf6'
|
color: '#8b5cf6'
|
||||||
},
|
},
|
||||||
@@ -42,14 +42,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
|||||||
queryDimensionNodes: 'core',
|
queryDimensionNodes: 'core',
|
||||||
searchContentEmbeddings: 'core',
|
searchContentEmbeddings: 'core',
|
||||||
|
|
||||||
// Orchestration: Workflows and reasoning (orchestrator only)
|
// Orchestration: Web search and reasoning
|
||||||
webSearch: 'orchestration',
|
webSearch: 'orchestration',
|
||||||
think: 'orchestration',
|
think: 'orchestration',
|
||||||
delegateToWiseRAH: 'orchestration',
|
|
||||||
executeWorkflow: 'orchestration',
|
|
||||||
listWorkflows: 'orchestration',
|
|
||||||
getWorkflow: 'orchestration',
|
|
||||||
editWorkflow: 'orchestration',
|
|
||||||
|
|
||||||
// Execution: Write operations and extraction (workers only)
|
// Execution: Write operations and extraction (workers only)
|
||||||
createNode: 'execution',
|
createNode: 'execution',
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
|||||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||||
import { webSearchTool } from '../other/webSearch';
|
import { webSearchTool } from '../other/webSearch';
|
||||||
import { thinkTool } from '../other/think';
|
import { thinkTool } from '../other/think';
|
||||||
import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH';
|
|
||||||
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 { youtubeExtractTool } from '../other/youtubeExtract';
|
||||||
import { websiteExtractTool } from '../other/websiteExtract';
|
import { websiteExtractTool } from '../other/websiteExtract';
|
||||||
import { paperExtractTool } from '../other/paperExtract';
|
import { paperExtractTool } from '../other/paperExtract';
|
||||||
@@ -42,11 +37,6 @@ const CORE_TOOLS: Record<string, any> = {
|
|||||||
const ORCHESTRATION_TOOLS: Record<string, any> = {
|
const ORCHESTRATION_TOOLS: Record<string, any> = {
|
||||||
webSearch: webSearchTool,
|
webSearch: webSearchTool,
|
||||||
think: thinkTool,
|
think: thinkTool,
|
||||||
delegateToWiseRAH: delegateToWiseRAHTool,
|
|
||||||
executeWorkflow: executeWorkflowTool,
|
|
||||||
listWorkflows: listWorkflowsTool,
|
|
||||||
getWorkflow: getWorkflowTool,
|
|
||||||
editWorkflow: editWorkflowTool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execution tools for worker agents (includes write operations)
|
// Execution tools for worker agents (includes write operations)
|
||||||
@@ -79,10 +69,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
|||||||
...Object.keys(CORE_TOOLS),
|
...Object.keys(CORE_TOOLS),
|
||||||
'webSearch',
|
'webSearch',
|
||||||
'think',
|
'think',
|
||||||
'executeWorkflow',
|
|
||||||
'listWorkflows',
|
|
||||||
'getWorkflow',
|
|
||||||
'editWorkflow',
|
|
||||||
'createNode',
|
'createNode',
|
||||||
'updateNode',
|
'updateNode',
|
||||||
'createEdge',
|
'createEdge',
|
||||||
@@ -97,10 +83,7 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
|||||||
|
|
||||||
const EXECUTOR_TOOL_NAMES = [
|
const EXECUTOR_TOOL_NAMES = [
|
||||||
...Object.keys(CORE_TOOLS),
|
...Object.keys(CORE_TOOLS),
|
||||||
...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
|
...Object.keys(ORCHESTRATION_TOOLS),
|
||||||
name !== 'delegateToWiseRAH' &&
|
|
||||||
name !== 'executeWorkflow'
|
|
||||||
),
|
|
||||||
...Object.keys(EXECUTION_TOOLS),
|
...Object.keys(EXECUTION_TOOLS),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { tool } from 'ai';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
|
|
||||||
import { RequestContext } from '@/services/context/requestContext';
|
|
||||||
|
|
||||||
export const delegateToWiseRAHTool = tool({
|
|
||||||
description: 'Delegate complex workflows to wise ra-h (GPT-5)',
|
|
||||||
inputSchema: z.object({
|
|
||||||
task: z.string().describe('Complex workflow description: what needs to be planned and executed'),
|
|
||||||
context: z.array(z.string()).max(8).default([]).describe('Optional context: node IDs, URLs, or key information the planner needs'),
|
|
||||||
expectedOutcome: z.string().optional().describe('Optional: what final result or format you expect in the summary'),
|
|
||||||
workflowKey: z.string().optional().describe('Optional: workflow key if invoked via executeWorkflow'),
|
|
||||||
workflowNodeId: z.number().optional().describe('Optional: target node ID for workflow'),
|
|
||||||
}),
|
|
||||||
execute: async ({ task, context = [], expectedOutcome, workflowKey, workflowNodeId }) => {
|
|
||||||
const requestContext = RequestContext.get();
|
|
||||||
console.log('[delegateToWiseRAH] Current traceId:', requestContext.traceId);
|
|
||||||
|
|
||||||
const delegation = AgentDelegationService.createDelegation({
|
|
||||||
task,
|
|
||||||
context,
|
|
||||||
expectedOutcome,
|
|
||||||
agentType: 'wise-rah',
|
|
||||||
supabaseToken: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const execution = await WorkflowExecutor.execute({
|
|
||||||
sessionId: delegation.sessionId,
|
|
||||||
task,
|
|
||||||
context,
|
|
||||||
expectedOutcome,
|
|
||||||
traceId: requestContext.traceId,
|
|
||||||
parentChatId: requestContext.parentChatId,
|
|
||||||
workflowKey,
|
|
||||||
workflowNodeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return a simple string that Claude can directly use in conversation
|
|
||||||
const summary = execution?.summary || 'Wise ra-h delegated but no summary returned.';
|
|
||||||
return `Wise ra-h (session ${delegation.sessionId.split('_').pop()}) completed the workflow:\n\n${summary}`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,130 +0,0 @@
|
|||||||
import { tool } from 'ai';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
|
||||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
|
||||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
|
||||||
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. Delegate to wise ra-h oracle with workflow metadata
|
|
||||||
const requestContext = RequestContext.get();
|
|
||||||
console.log('[executeWorkflowTool] Current traceId:', requestContext.traceId);
|
|
||||||
|
|
||||||
const delegation = AgentDelegationService.createDelegation({
|
|
||||||
task,
|
|
||||||
context: contextLines,
|
|
||||||
expectedOutcome: workflow.expectedOutcome,
|
|
||||||
agentType: 'wise-rah',
|
|
||||||
supabaseToken: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire-and-forget execution so main ra-h stays responsive while workflow runs
|
|
||||||
void WorkflowExecutor.execute({
|
|
||||||
sessionId: delegation.sessionId,
|
|
||||||
task,
|
|
||||||
context: contextLines,
|
|
||||||
expectedOutcome: workflow.expectedOutcome,
|
|
||||||
traceId: requestContext.traceId,
|
|
||||||
parentChatId: requestContext.parentChatId,
|
|
||||||
workflowKey,
|
|
||||||
workflowNodeId: nodeId,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('[executeWorkflowTool] Wise ra-h delegation failed', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined });
|
|
||||||
|
|
||||||
const shortSessionId = delegation.sessionId.split('_').pop();
|
|
||||||
const workflowLabel = workflow.displayName || workflowKey;
|
|
||||||
return [
|
|
||||||
`Delegated **${workflowLabel}** to wise ra-h (session ${shortSessionId}).`,
|
|
||||||
'Keep working here—wise ra-h will stream every step inside its tab and post the final summary when complete.',
|
|
||||||
].join('\n');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types for Anthropic prompt caching
|
|
||||||
* https://docs.claude.com/en/docs/build-with-claude/prompt-caching
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CacheControl = { type: 'ephemeral' };
|
|
||||||
|
|
||||||
export interface CacheableBlock {
|
|
||||||
type: 'text';
|
|
||||||
text: string;
|
|
||||||
cache_control?: CacheControl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemPromptResult {
|
|
||||||
blocks: CacheableBlock[];
|
|
||||||
cacheHit: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheStats {
|
|
||||||
cacheCreationInputTokens: number;
|
|
||||||
cacheReadInputTokens: number;
|
|
||||||
inputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
savingsPercentage: number;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Scenario } from '../types';
|
|
||||||
|
|
||||||
export const scenario: Scenario = {
|
|
||||||
id: 'delegate-mini',
|
|
||||||
name: 'Delegate to Mini RAH',
|
|
||||||
description: 'Ask the system to delegate a short task to mini helper.',
|
|
||||||
tools: ['delegateToMiniRAH'],
|
|
||||||
input: {
|
|
||||||
message: 'Delegate to mini RAH: summarize my notes on plaintext productivity in 3 bullets.',
|
|
||||||
mode: 'hard',
|
|
||||||
},
|
|
||||||
expect: {
|
|
||||||
toolsCalledSoft: ['delegateToMiniRAH'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Scenario } from '../types';
|
|
||||||
|
|
||||||
export const scenario: Scenario = {
|
|
||||||
id: 'delegate-wise',
|
|
||||||
name: 'Delegate to Wise RAH',
|
|
||||||
description: 'Ask the system to delegate a research comparison task.',
|
|
||||||
tools: ['delegateToWiseRAH'],
|
|
||||||
input: {
|
|
||||||
message: 'Delegate to wise RAH: compare SQLite vs markdown storage for PKM in 5 bullets.',
|
|
||||||
mode: 'hard',
|
|
||||||
},
|
|
||||||
expect: {
|
|
||||||
toolsCalledSoft: ['delegateToWiseRAH'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -2,14 +2,11 @@ import { scenario as simpleQuery } from './simple-query';
|
|||||||
import { scenario as searchEmbeddings } from './search-embeddings';
|
import { scenario as searchEmbeddings } from './search-embeddings';
|
||||||
import { scenario as createNode } from './create-node';
|
import { scenario as createNode } from './create-node';
|
||||||
import { scenario as hardModeQuery } from './hard-mode-query';
|
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 updateNode } from './update-node';
|
||||||
import { scenario as createEdge } from './create-edge';
|
import { scenario as createEdge } from './create-edge';
|
||||||
import { scenario as queryDimensions } from './query-dimensions';
|
import { scenario as queryDimensions } from './query-dimensions';
|
||||||
import { scenario as getDimension } from './get-dimension';
|
import { scenario as getDimension } from './get-dimension';
|
||||||
import { scenario as dimensionLifecycle } from './dimension-lifecycle';
|
import { scenario as dimensionLifecycle } from './dimension-lifecycle';
|
||||||
import { scenario as delegateMini } from './delegate-mini';
|
|
||||||
import { scenario as delegateWise } from './delegate-wise';
|
|
||||||
import { scenario as youtubeExtract } from './youtube-extract';
|
import { scenario as youtubeExtract } from './youtube-extract';
|
||||||
import { scenario as websiteExtract } from './website-extract';
|
import { scenario as websiteExtract } from './website-extract';
|
||||||
import { scenario as paperExtract } from './paper-extract';
|
import { scenario as paperExtract } from './paper-extract';
|
||||||
@@ -24,9 +21,6 @@ export const scenarios = [
|
|||||||
getDimension,
|
getDimension,
|
||||||
dimensionLifecycle,
|
dimensionLifecycle,
|
||||||
hardModeQuery,
|
hardModeQuery,
|
||||||
workflowIntegrate,
|
|
||||||
delegateMini,
|
|
||||||
delegateWise,
|
|
||||||
youtubeExtract,
|
youtubeExtract,
|
||||||
websiteExtract,
|
websiteExtract,
|
||||||
paperExtract,
|
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