From 08012a8e5a26d311262a33c387715bb9253424b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:01:10 +1100 Subject: [PATCH 01/26] chore: add ralph infrastructure for rah-light strip-down - prd.json with 15 user stories for phases 1-3 - prompt.md customized for deletion workflow - progress.txt initialized Co-Authored-By: Claude Opus 4.5 --- ralph/prd.json | 211 ++++++++++++++++++++++++++++++++++++++++++++ ralph/progress.txt | 11 +++ ralph/prompt.md | 98 ++++++++++++++++++++ ralph/ralph-once.sh | 63 +++++++++++++ ralph/ralph.sh | 105 ++++++++++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 ralph/prd.json create mode 100644 ralph/progress.txt create mode 100644 ralph/prompt.md create mode 100755 ralph/ralph-once.sh create mode 100755 ralph/ralph.sh diff --git a/ralph/prd.json b/ralph/prd.json new file mode 100644 index 0000000..69d6690 --- /dev/null +++ b/ralph/prd.json @@ -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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "Check if ToolDisplay.tsx is used elsewhere before deleting" + }, + { + "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": false, + "notes": "Be conservative - some services may be used by MCP tools" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + }, + { + "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": false, + "notes": "" + } + ] +} diff --git a/ralph/progress.txt b/ralph/progress.txt new file mode 100644 index 0000000..1affe33 --- /dev/null +++ b/ralph/progress.txt @@ -0,0 +1,11 @@ +# 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 + + diff --git a/ralph/prompt.md b/ralph/prompt.md new file mode 100644 index 0000000..f210475 --- /dev/null +++ b/ralph/prompt.md @@ -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 + ``` +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: + +``` +COMPLETE +``` + +## 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. diff --git a/ralph/ralph-once.sh b/ralph/ralph-once.sh new file mode 100755 index 0000000..e12ce3e --- /dev/null +++ b/ralph/ralph-once.sh @@ -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" diff --git a/ralph/ralph.sh b/ralph/ralph.sh new file mode 100755 index 0000000..bcea037 --- /dev/null +++ b/ralph/ralph.sh @@ -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 "COMPLETE"; 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 From 6b7bb5a5f954aadc3dd20f0c7e557efd49ca8932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:11:15 +1100 Subject: [PATCH 02/26] feat(rah-light): remove agent delegation system - Delete src/services/agents/delegation.ts (AgentDelegationService) - Delete app/api/rah/delegations/ directory (all routes) - Delete src/components/agents/DelegationIndicator.tsx - Update workflowExecutor.ts to work without delegation streaming - Update executeWorkflow.ts tool to execute workflows directly - Update quickAdd.ts to execute directly without delegation tracking - Add stub AgentDelegation types to UI components for compatibility - Add stub voice hooks to RAHChat.tsx (will be removed in story 2) 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 Co-Authored-By: Claude Opus 4.5 --- app/api/rah/delegations/[sessionId]/route.ts | 70 ----- .../delegations/[sessionId]/summary/route.ts | 33 --- app/api/rah/delegations/route.ts | 20 -- app/api/rah/delegations/stream/route.ts | 179 ------------- app/api/workflows/execute/route.ts | 56 ++-- src/components/agents/AgentsPanel.tsx | 13 +- src/components/agents/DelegationIndicator.tsx | 36 --- src/components/agents/MiniRAHPanel.tsx | 14 +- src/components/agents/QuickAddStatus.tsx | 11 +- src/components/agents/RAHChat.tsx | 57 +++- src/components/agents/WiseRAHPanel.tsx | 14 +- src/components/layout/ThreePanelLayout.tsx | 66 ++--- src/components/panes/WorkflowsPane.tsx | 3 +- src/components/panes/types.ts | 14 +- src/services/agents/delegation.ts | 243 ------------------ src/services/agents/quickAdd.ts | 63 ++--- src/services/agents/workflowExecutor.ts | 89 +------ src/tools/orchestration/delegateToWiseRAH.ts | 17 +- src/tools/orchestration/executeWorkflow.ts | 55 ++-- 19 files changed, 234 insertions(+), 819 deletions(-) delete mode 100644 app/api/rah/delegations/[sessionId]/route.ts delete mode 100644 app/api/rah/delegations/[sessionId]/summary/route.ts delete mode 100644 app/api/rah/delegations/route.ts delete mode 100644 app/api/rah/delegations/stream/route.ts delete mode 100644 src/components/agents/DelegationIndicator.tsx delete mode 100644 src/services/agents/delegation.ts diff --git a/app/api/rah/delegations/[sessionId]/route.ts b/app/api/rah/delegations/[sessionId]/route.ts deleted file mode 100644 index 2fcd186..0000000 --- a/app/api/rah/delegations/[sessionId]/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/rah/delegations/[sessionId]/summary/route.ts b/app/api/rah/delegations/[sessionId]/summary/route.ts deleted file mode 100644 index e79b98e..0000000 --- a/app/api/rah/delegations/[sessionId]/summary/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/rah/delegations/route.ts b/app/api/rah/delegations/route.ts deleted file mode 100644 index daeaa54..0000000 --- a/app/api/rah/delegations/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/rah/delegations/stream/route.ts b/app/api/rah/delegations/stream/route.ts deleted file mode 100644 index 1e04e1a..0000000 --- a/app/api/rah/delegations/stream/route.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { NextRequest } from 'next/server'; - -export const runtime = 'nodejs'; -export const maxDuration = 900; - -class DelegationStreamBroadcaster { - private connections = new Map>(); - private pendingMessages = new Map(); - 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', - }, - }); -} diff --git a/app/api/workflows/execute/route.ts b/app/api/workflows/execute/route.ts index a4346a9..0bd54c3 100644 --- a/app/api/workflows/execute/route.ts +++ b/app/api/workflows/execute/route.ts @@ -1,7 +1,6 @@ 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'; @@ -107,35 +106,36 @@ ${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, - }); + const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - // Fire-and-forget execution - void WorkflowExecutor.execute({ - sessionId: delegation.sessionId, - task, - context: contextLines, - expectedOutcome: workflow.expectedOutcome, - workflowKey, - workflowNodeId: nodeId, - }).catch((error) => { + try { + // Execute workflow synchronously + const result = await WorkflowExecutor.execute({ + sessionId, + task, + context: contextLines, + expectedOutcome: workflow.expectedOutcome, + workflowKey, + workflowNodeId: nodeId, + }); + + return NextResponse.json({ + success: true, + sessionId, + workflowKey, + nodeId: nodeId || null, + status: 'completed', + summary: result.summary, + message: `Workflow '${workflow.displayName}' completed`, + }); + } catch (error) { console.error('[/api/workflows/execute] Execution failed:', error); - }); - - return NextResponse.json({ - success: true, - delegationId: delegation.sessionId, - workflowKey, - nodeId: nodeId || null, - status: 'executing', - message: `Workflow '${workflow.displayName}' started`, - }); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { success: false, error: `Workflow execution failed: ${errorMessage}` }, + { status: 500 } + ); + } } catch (error) { console.error('Error executing workflow:', error); return NextResponse.json( diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx index 5c9ddb3..61924b6 100644 --- a/src/components/agents/AgentsPanel.tsx +++ b/src/components/agents/AgentsPanel.tsx @@ -5,10 +5,21 @@ import RAHChat from './RAHChat'; import QuickAddInput from './QuickAddInput'; import QuickAddStatus from './QuickAddStatus'; import { Zap, Flame, Minimize2 } from 'lucide-react'; -import type { AgentDelegation } from '@/services/agents/delegation'; import { Node } from '@/types/database'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; +// 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; + agentType: string; + createdAt: string; + updatedAt: string; +}; + interface AgentsPanelProps { openTabsData: Node[]; activeTabId: number | null; diff --git a/src/components/agents/DelegationIndicator.tsx b/src/components/agents/DelegationIndicator.tsx deleted file mode 100644 index ab976ed..0000000 --- a/src/components/agents/DelegationIndicator.tsx +++ /dev/null @@ -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 ( -
- - {activeCount} active -
- ); -} diff --git a/src/components/agents/MiniRAHPanel.tsx b/src/components/agents/MiniRAHPanel.tsx index 590b8a0..359661e 100644 --- a/src/components/agents/MiniRAHPanel.tsx +++ b/src/components/agents/MiniRAHPanel.tsx @@ -1,5 +1,17 @@ import { ReactNode } from 'react'; -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; + context: string[]; + status: 'queued' | 'in_progress' | 'completed' | 'failed'; + summary?: string | null; + agentType: string; + createdAt: string; + updatedAt: string; +}; interface MiniRAHPanelProps { delegation: AgentDelegation; diff --git a/src/components/agents/QuickAddStatus.tsx b/src/components/agents/QuickAddStatus.tsx index e6e7ba4..679f279 100644 --- a/src/components/agents/QuickAddStatus.tsx +++ b/src/components/agents/QuickAddStatus.tsx @@ -1,6 +1,15 @@ "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 { delegations: AgentDelegation[]; diff --git a/src/components/agents/RAHChat.tsx b/src/components/agents/RAHChat.tsx index 6e421e3..f7c173e 100644 --- a/src/components/agents/RAHChat.tsx +++ b/src/components/agents/RAHChat.tsx @@ -6,15 +6,60 @@ 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'; + +// 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; + agentType: string; + createdAt: string; + updatedAt: string; +}; + +// Stub DelegationIndicator component (delegation system removed) +function DelegationIndicator({ delegations }: { delegations: AgentDelegation[] }) { + return null; +} + +// Stub voice hooks (voice system will be removed in story 2) +function useVoiceSession() { + return { + isActive: false, + amplitude: 0, + startSession: () => {}, + stopSession: () => {}, + resetTranscript: () => {}, + setStatus: (_status: string) => {}, + setAmplitude: (_amp: number) => {}, + setInterimTranscript: (_text: string) => {}, + appendFinalTranscript: (_text: string) => {}, + }; +} + +function useAssistantTTS(_options: { onSpeechStart?: () => void; onSpeechComplete?: () => void; onError?: (e: Error) => void }) { + return { + speak: (_text: string, _options?: { flush?: boolean; metadata?: Record }) => {}, + stop: () => {}, + status: 'idle' as const, + }; +} + +function useVoiceInterruption(_options: { amplitude: number; isVoiceActive: boolean; ttsStatus: string; onInterruption: () => void }) {} + +function useRealtimeVoiceClient(_handlers: { onStatusChange?: (status: string) => void; onInterimTranscript?: (text: string) => void; onFinalTranscript?: (text: string) => void; onAmplitude?: (amp: number) => void; onError?: (e: Error) => void }, _options: { getAuthToken: () => string | null }) { + return { + connect: () => {}, + disconnect: () => {}, + start: () => {}, + stop: () => {}, + }; +} const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const createVoiceRequestId = () => diff --git a/src/components/agents/WiseRAHPanel.tsx b/src/components/agents/WiseRAHPanel.tsx index 26676f3..b6cd884 100644 --- a/src/components/agents/WiseRAHPanel.tsx +++ b/src/components/agents/WiseRAHPanel.tsx @@ -1,5 +1,17 @@ import { Fragment, ReactNode, useMemo } from 'react'; -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; + context: string[]; + status: 'queued' | 'in_progress' | 'completed' | 'failed'; + summary?: string | null; + agentType: string; + createdAt: string; + updatedAt: string; +}; const statusPalette: Record = { queued: { border: '#3a2f5f', badge: '#a78bfa' }, diff --git a/src/components/layout/ThreePanelLayout.tsx b/src/components/layout/ThreePanelLayout.tsx index d3d078e..9cc765c 100644 --- a/src/components/layout/ThreePanelLayout.tsx +++ b/src/components/layout/ThreePanelLayout.tsx @@ -6,9 +6,21 @@ import SearchModal from '../nodes/SearchModal'; import { Node } from '@/types/database'; import { DatabaseEvent } from '@/services/events'; import { usePersistentState } from '@/hooks/usePersistentState'; -import type { AgentDelegation } from '@/services/agents/delegation'; 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 import LeftToolbar from './LeftToolbar'; import SplitHandle from './SplitHandle'; @@ -66,8 +78,8 @@ export default function ThreePanelLayout() { // Active dimension tracking (for chat context) const [activeDimension, setActiveDimension] = usePersistentState('ui.focus.activeDimension', null); - // Delegations state (shared between chat and workflows panes) - const [delegationsMap, setDelegationsMap] = useState>({}); + // Delegations state (deprecated - kept for component compatibility) + const [delegationsMap] = useState>({}); const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]); // Chat state (lifted to persist across pane type changes) @@ -161,32 +173,7 @@ export default function ThreePanelLayout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [openTabsKey]); - // Load delegations on mount - 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; }; - }, []); + // Delegations loading removed (delegation system removed in rah-light) // Keyboard shortcut handler useEffect(() => { @@ -278,27 +265,8 @@ export default function ThreePanelLayout() { break; 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': - if (data.data?.delegation) { - setDelegationsMap(prev => ({ - ...prev, - [data.data.delegation.sessionId]: data.data.delegation - })); - } - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('delegations:updated', { detail: data.data })); - } + // Delegation events ignored (delegation system removed in rah-light) break; case 'WORKFLOW_PROGRESS': diff --git a/src/components/panes/WorkflowsPane.tsx b/src/components/panes/WorkflowsPane.tsx index 5a10062..2bf2a89 100644 --- a/src/components/panes/WorkflowsPane.tsx +++ b/src/components/panes/WorkflowsPane.tsx @@ -3,8 +3,7 @@ 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 { WorkflowsPaneProps, PaneType, AgentDelegation } from './types'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import type { ChatMessage } from '@/components/agents/hooks/useSSEChat'; diff --git a/src/components/panes/types.ts b/src/components/panes/types.ts index 9d8c1b2..1336b02 100644 --- a/src/components/panes/types.ts +++ b/src/components/panes/types.ts @@ -1,6 +1,18 @@ import React from 'react'; import { Node } from '@/types/database'; -import type { AgentDelegation } from '@/services/agents/delegation'; + +// Stub type for delegation (delegation system removed in rah-light) +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 six pane types export type PaneType = 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views'; diff --git a/src/services/agents/delegation.ts b/src/services/agents/delegation.ts deleted file mode 100644 index b40a4ca..0000000 --- a/src/services/agents/delegation.ts +++ /dev/null @@ -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; - } -} diff --git a/src/services/agents/quickAdd.ts b/src/services/agents/quickAdd.ts index 080244c..a35d6bb 100644 --- a/src/services/agents/quickAdd.ts +++ b/src/services/agents/quickAdd.ts @@ -1,10 +1,10 @@ -import { AgentDelegationService } from './delegation'; import { summarizeToolExecution } from './toolResultUtils'; import { youtubeExtractTool } from '@/tools/other/youtubeExtract'; import { websiteExtractTool } from '@/tools/other/websiteExtract'; import { paperExtractTool } from '@/tools/other/paperExtract'; import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter'; import { summarizeTranscript } from './transcriptSummarizer'; +import { eventBroadcaster } from '@/services/events'; 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 { const inputType = detectInputType(rawInput, mode); - const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput]; const task = buildTaskPrompt(inputType, rawInput); - const expectedOutcome = buildExpectedOutcome(inputType); - const delegation = AgentDelegationService.createDelegation({ - task, - context, - expectedOutcome, - }); - - setImmediate(async () => { - try { - AgentDelegationService.markInProgress(delegation.sessionId); + try { + let summary: string; + if (inputType === 'note') { + summary = await handleNoteQuickAdd(rawInput, task, description); + } else if (inputType === 'chat') { + summary = await handleChatTranscriptQuickAdd(rawInput, task); + } else { + summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task); + } - let summary: string; - if (inputType === 'note') { - summary = await handleNoteQuickAdd(rawInput, task, description); - } else if (inputType === 'chat') { - summary = await handleChatTranscriptQuickAdd(rawInput, task); - } else { - summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task); - } + // Extract node ID from summary if present + const nodeIdMatch = summary.match(/\[NODE:(\d+)/); + const nodeId = nodeIdMatch ? parseInt(nodeIdMatch[1], 10) : undefined; - AgentDelegationService.completeDelegation(delegation.sessionId, summary, 'completed'); - } catch (error) { - const message = error instanceof Error ? error.message : 'unknown error'; - AgentDelegationService.completeDelegation( - delegation.sessionId, - `Quick Add failed: ${message}`, - 'failed' - ); + // Broadcast node created event + if (nodeId) { + eventBroadcaster.broadcast({ + type: 'NODE_CREATED', + data: { node: { id: nodeId, title: 'Quick Add' } } + }); } - }); - 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}` }; + } } diff --git a/src/services/agents/workflowExecutor.ts b/src/services/agents/workflowExecutor.ts index 5a67905..ef037ff 100644 --- a/src/services/agents/workflowExecutor.ts +++ b/src/services/agents/workflowExecutor.ts @@ -1,7 +1,6 @@ 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'; @@ -10,7 +9,6 @@ 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 { @@ -38,8 +36,7 @@ export class WorkflowExecutor { throw new Error('OPENAI_API_KEY is not set for workflow execution.'); } - AgentDelegationService.markInProgress(sessionId); - console.log('✅ [WorkflowExecutor] Delegation marked in progress'); + console.log('✅ [WorkflowExecutor] Starting workflow execution'); // Get workflow definition if available const workflow = workflowKey ? await WorkflowRegistry.getWorkflowByKey(workflowKey) : null; @@ -165,28 +162,9 @@ export class WorkflowExecutor { 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) => { - delegationStreamBroadcaster.broadcast(sessionId, payload); - }; - + // Logging helpers (no-op for lite version without delegation streaming) const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => { - emitDelegationEvent({ - type: 'tool-input-start', - toolCallId, - toolName, - input: sanitizeForBroadcast(input), - }); + console.log(`🔧 [WorkflowExecutor] Tool start: ${toolName}`); }; const emitToolCompletion = ( @@ -197,15 +175,7 @@ export class WorkflowExecutor { status: 'complete' | 'error' = 'complete', errorMessage?: string ) => { - emitDelegationEvent({ - type: 'tool-output-available', - toolCallId, - toolName, - result: sanitizeForBroadcast(rawResult), - summary, - status, - error: errorMessage, - }); + console.log(`✅ [WorkflowExecutor] Tool complete: ${toolName} - ${status}`); }; const buildToolOutput = (toolName: string, summary: string, rawResult: any): LanguageModelV2ToolResultOutput => { @@ -280,9 +250,6 @@ export class WorkflowExecutor { 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, @@ -308,12 +275,9 @@ export class WorkflowExecutor { console.log(`📊 [WorkflowExecutor] Step ${i + 1} finishReason:`, response.finishReason); - // Stream text response to delegation chat + // Log text response if (response.text && response.text.trim()) { - emitDelegationEvent({ - type: 'text-delta', - delta: response.text, - }); + console.log(`📝 [WorkflowExecutor] Response text: ${response.text.substring(0, 100)}...`); } if (response.finishReason !== 'tool-calls') { @@ -325,9 +289,9 @@ export class WorkflowExecutor { const toolCalls = response.toolCalls || []; console.log(`🔧 [WorkflowExecutor] Executing ${toolCalls.length} tool calls`); - // Broadcast new assistant message for next iteration + // Log tool calls if (toolCalls.length > 0) { - emitDelegationEvent({ type: 'assistant-message' }); + console.log(`🔧 [WorkflowExecutor] Processing ${toolCalls.length} tool calls`); } messages.push({ @@ -441,25 +405,12 @@ export class WorkflowExecutor { 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.', - }); + console.warn('[WorkflowExecutor] Empty summary received'); 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; @@ -487,16 +438,13 @@ export class WorkflowExecutor { workflowNodeId, }; - const delegation = AgentDelegationService.getDelegation(sessionId); - const delegationId = delegation?.id; - await ChatLoggingMiddleware.logChatInteraction( task, summary, { helperName: 'workflow-agent', agentType: 'planner', - delegationId: delegationId ?? null, + delegationId: null, sessionId, usageData, traceId, @@ -511,23 +459,12 @@ export class WorkflowExecutor { console.log(`💰 [WorkflowExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`); } - console.log('✅ [WorkflowExecutor] Completing delegation with summary'); - return AgentDelegationService.completeDelegation(sessionId, summary); + console.log('✅ [WorkflowExecutor] Workflow execution complete'); + return { sessionId, summary, status: 'completed' as const }; } catch (error) { console.error('❌ [WorkflowExecutor] Error during execution:', error); console.error('❌ [WorkflowExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack'); - const message = error instanceof Error ? error.message : 'Unknown 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'); + const message = error instanceof Error ? error.message : 'Unknown workflow error'; throw error; } } diff --git a/src/tools/orchestration/delegateToWiseRAH.ts b/src/tools/orchestration/delegateToWiseRAH.ts index 0f7862e..6e424cb 100644 --- a/src/tools/orchestration/delegateToWiseRAH.ts +++ b/src/tools/orchestration/delegateToWiseRAH.ts @@ -1,11 +1,10 @@ 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)', + description: 'Delegate complex workflows to workflow executor', 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'), @@ -17,16 +16,10 @@ export const delegateToWiseRAHTool = tool({ const requestContext = RequestContext.get(); console.log('[delegateToWiseRAH] Current traceId:', requestContext.traceId); - const delegation = AgentDelegationService.createDelegation({ - task, - context, - expectedOutcome, - agentType: 'wise-rah', - supabaseToken: null, - }); + const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const execution = await WorkflowExecutor.execute({ - sessionId: delegation.sessionId, + sessionId, task, context, expectedOutcome, @@ -37,7 +30,7 @@ export const delegateToWiseRAHTool = tool({ }); // 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}`; + const summary = execution?.summary || 'Workflow completed but no summary returned.'; + return `Workflow (session ${sessionId.split('_').pop()}) completed:\n\n${summary}`; }, }); diff --git a/src/tools/orchestration/executeWorkflow.ts b/src/tools/orchestration/executeWorkflow.ts index ef21f6e..41568c3 100644 --- a/src/tools/orchestration/executeWorkflow.ts +++ b/src/tools/orchestration/executeWorkflow.ts @@ -2,7 +2,6 @@ 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'; @@ -92,39 +91,37 @@ ${workflow.instructions} ${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general workflow)'}`; - // 5. Delegate to wise ra-h oracle with workflow metadata + // 5. Execute workflow directly 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, - }); + const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - // 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); - }); + try { + const result = await WorkflowExecutor.execute({ + sessionId, + task, + context: contextLines, + expectedOutcome: workflow.expectedOutcome, + traceId: requestContext.traceId, + parentChatId: requestContext.parentChatId, + workflowKey, + workflowNodeId: nodeId, + }); - RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined }); + 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'); + const workflowLabel = workflow.displayName || workflowKey; + return { + success: true, + message: `Workflow **${workflowLabel}** completed.`, + summary: result.summary, + }; + } catch (error) { + console.error('[executeWorkflowTool] Workflow execution failed', error); + RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined }); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: `Workflow execution failed: ${errorMessage}` }; + } }, }); From fa96c238c2494ec08c4f0d8f9b86bc3d2d22b5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:17:31 +1100 Subject: [PATCH 03/26] feat(rah-light): remove voice features - Delete voice hooks: useVoiceSession, useRealtimeVoiceClient, useAssistantTTS, useVoiceInterruption - Delete voice API routes: /api/voice/tts, /api/realtime/ephemeral-token - Delete voice service: src/services/voice/usageLogger.ts - Clean up RAHChat.tsx: remove voice stub hooks and voice-related state/handlers - Clean up TerminalInput.tsx: remove voice props, mic icons, amplitude visualizer Co-Authored-By: Claude Opus 4.5 --- app/api/realtime/ephemeral-token/route.ts | 56 -- app/api/voice/tts/route.ts | 101 ---- ralph/prd.json | 8 +- ralph/progress.txt | 45 ++ src/components/agents/RAHChat.tsx | 258 +------- src/components/agents/TerminalInput.tsx | 123 +--- .../agents/hooks/useAssistantTTS.ts | 300 --------- .../agents/hooks/useRealtimeVoiceClient.ts | 570 ------------------ .../agents/hooks/useVoiceInterruption.ts | 83 --- .../agents/hooks/useVoiceSession.ts | 150 ----- src/services/voice/usageLogger.ts | 153 ----- 11 files changed, 72 insertions(+), 1775 deletions(-) delete mode 100644 app/api/realtime/ephemeral-token/route.ts delete mode 100644 app/api/voice/tts/route.ts delete mode 100644 src/components/agents/hooks/useAssistantTTS.ts delete mode 100644 src/components/agents/hooks/useRealtimeVoiceClient.ts delete mode 100644 src/components/agents/hooks/useVoiceInterruption.ts delete mode 100644 src/components/agents/hooks/useVoiceSession.ts delete mode 100644 src/services/voice/usageLogger.ts diff --git a/app/api/realtime/ephemeral-token/route.ts b/app/api/realtime/ephemeral-token/route.ts deleted file mode 100644 index 7f60058..0000000 --- a/app/api/realtime/ephemeral-token/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/voice/tts/route.ts b/app/api/voice/tts/route.ts deleted file mode 100644 index 6687158..0000000 --- a/app/api/voice/tts/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/ralph/prd.json b/ralph/prd.json index 69d6690..f6d901e 100644 --- a/ralph/prd.json +++ b/ralph/prd.json @@ -13,8 +13,8 @@ "No remaining imports of 'delegation' from services/agents", "npm run type-check passes" ], - "passes": false, - "notes": "" + "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", @@ -31,8 +31,8 @@ "RAHChat.tsx no longer imports or uses voice hooks", "npm run type-check passes" ], - "passes": false, - "notes": "" + "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", diff --git a/ralph/progress.txt b/ralph/progress.txt index 1affe33..11c7411 100644 --- a/ralph/progress.txt +++ b/ralph/progress.txt @@ -8,4 +8,49 @@ Goal: Strip ra-h_os to lightweight 2-panel UI + MCP server - 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 diff --git a/src/components/agents/RAHChat.tsx b/src/components/agents/RAHChat.tsx index f7c173e..c6e7456 100644 --- a/src/components/agents/RAHChat.tsx +++ b/src/components/agents/RAHChat.tsx @@ -8,7 +8,6 @@ import TerminalInput from './TerminalInput'; import { Zap, Flame } from 'lucide-react'; import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat'; import { useQuotaHandler } from '@/hooks/useQuotaHandler'; -import { apiKeyService } from '@/services/storage/apiKeys'; // Stub type for delegation (delegation system removed in rah-light) type AgentDelegation = { @@ -27,45 +26,7 @@ function DelegationIndicator({ delegations }: { delegations: AgentDelegation[] } return null; } -// Stub voice hooks (voice system will be removed in story 2) -function useVoiceSession() { - return { - isActive: false, - amplitude: 0, - startSession: () => {}, - stopSession: () => {}, - resetTranscript: () => {}, - setStatus: (_status: string) => {}, - setAmplitude: (_amp: number) => {}, - setInterimTranscript: (_text: string) => {}, - appendFinalTranscript: (_text: string) => {}, - }; -} - -function useAssistantTTS(_options: { onSpeechStart?: () => void; onSpeechComplete?: () => void; onError?: (e: Error) => void }) { - return { - speak: (_text: string, _options?: { flush?: boolean; metadata?: Record }) => {}, - stop: () => {}, - status: 'idle' as const, - }; -} - -function useVoiceInterruption(_options: { amplitude: number; isVoiceActive: boolean; ttsStatus: string; onInterruption: () => void }) {} - -function useRealtimeVoiceClient(_handlers: { onStatusChange?: (status: string) => void; onInterimTranscript?: (text: string) => void; onFinalTranscript?: (text: string) => void; onAmplitude?: (amp: number) => void; onError?: (e: Error) => void }, _options: { getAuthToken: () => string | null }) { - return { - connect: () => {}, - disconnect: () => {}, - start: () => {}, - stop: () => {}, - }; -} - 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; @@ -110,7 +71,7 @@ export default function RAHChat({ const [internalMessages, internalSetMessages] = useState([]); const messages = externalMessages !== undefined ? externalMessages : internalMessages; const setMessages = externalSetMessages || internalSetMessages; - + const [sessionId, setSessionId] = useState(() => createSessionId()); const messagesEndRef = useRef(null); const chatMode = mode === 'hard' ? 'hard' : 'easy'; @@ -127,53 +88,6 @@ export default function RAHChat({ 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>(new Map()); - const [voiceError, setVoiceError] = useState(null); - const voiceErrorHandledRef = useRef(false); - const voiceStartTimestampRef = useRef(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, @@ -187,14 +101,6 @@ export default function RAHChat({ }, }); - useVoiceInterruption({ - amplitude: voiceAmplitude, - isVoiceActive, - ttsStatus, - onInterruption: handleVoiceInterruption, - }); - - const sendMessage = useCallback(async (text: string) => { if (delegationMode) return; // Delegation chats are read-only if (isQuotaExceeded) { @@ -211,71 +117,9 @@ export default function RAHChat({ }); }, [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]); + }, [refetchUsage]); useEffect(() => { streamCompleteHandlerRef.current = handleStreamComplete; @@ -285,17 +129,6 @@ export default function RAHChat({ 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'); @@ -315,41 +148,11 @@ export default function RAHChat({ 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 @@ -361,7 +164,7 @@ export default function RAHChat({ 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]; @@ -429,52 +232,6 @@ export default function RAHChat({ }; }, [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 (
)} - {/* Voice transcript preview removed for streamlined UI */}
@@ -569,14 +325,8 @@ export default function RAHChat({
void; @@ -10,10 +9,6 @@ interface TerminalInputProps { helperId?: number; disabledExternally?: boolean; disabledMessage?: string; - onVoiceToggle?: () => void; - isVoiceActive?: boolean; - voiceAmplitude?: number; - voiceError?: string | null; } export default function TerminalInput({ @@ -23,10 +18,6 @@ export default function TerminalInput({ helperId, disabledExternally = false, disabledMessage, - onVoiceToggle, - isVoiceActive = false, - voiceAmplitude = 0, - voiceError, }: TerminalInputProps) { const [input, setInput] = useState(''); const [rows, setRows] = useState(1); @@ -54,26 +45,26 @@ export default function TerminalInput({ 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); } @@ -135,22 +126,7 @@ export default function TerminalInput({ }, [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 }); + const buttonIsDisabled = !trimmedInput || isProcessing || disabledExternally; // Handle node drag over chat input const handleDragOver = (e: DragEvent) => { @@ -253,7 +229,6 @@ export default function TerminalInput({ gap: '8px', padding: '8px 16px 12px', background: 'transparent', - // Remove separator/border between chat area and input borderTop: 'none', fontFamily: 'inherit' }}> @@ -276,49 +251,6 @@ export default function TerminalInput({ flexDirection: 'column', gap: '4px' }}> - {isVoiceActive && ( -
-
- - RA-H is listening - -
-
- {amplitudeBars.map((_, index) => { - const level = (index + 1) / amplitudeBars.length; - const active = voiceAmplitude >= level - 0.0001; - return ( -
- ); - })} -
- {voiceError && ( - {voiceError} - )} -
- )} {/* Input Row with Textarea and Button */}
{}} onBlur={() => {}} /> @@ -382,30 +313,18 @@ export default function TerminalInput({ ))}
)} - - {/* Submit Button (minimal icon) */} + + {/* Submit Button */}
diff --git a/src/components/agents/hooks/useAssistantTTS.ts b/src/components/agents/hooks/useAssistantTTS.ts deleted file mode 100644 index f2fd95e..0000000 --- a/src/components/agents/hooks/useAssistantTTS.ts +++ /dev/null @@ -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('idle'); - const queueRef = useRef([]); - const isProcessingRef = useRef(false); - const abortRef = useRef(null); - const audioRef = useRef(null); - const objectUrlRef = useRef(null); - const mediaSourceRef = useRef(null); - const sourceBufferRef = useRef(null); - const chunkQueueRef = useRef([]); - 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 = { - 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; -} diff --git a/src/components/agents/hooks/useRealtimeVoiceClient.ts b/src/components/agents/hooks/useRealtimeVoiceClient.ts deleted file mode 100644 index 351055e..0000000 --- a/src/components/agents/hooks/useRealtimeVoiceClient.ts +++ /dev/null @@ -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 | 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('idle'); - const peerConnectionRef = useRef>(null); - const dataChannelRef = useRef>(null); - const audioContextRef = useRef>(null); - const processorNodeRef = useRef>(null); - const mediaStreamRef = useRef>(null); - const mediaSourceRef = useRef>(null); - const awaitingTranscriptRef = useRef(false); - const hasUncommittedAudioRef = useRef(false); - const lastSpeechAtRef = useRef(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 | 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((resolve) => { - channelReadyResolveRef.current = resolve; - }); - } - await channelReadyPromiseRef.current; - }, []); - - const sendEvent = useCallback( - async (event: Record) => { - 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; - if (typeof record.delta === 'string') return record.delta; - if (typeof record.text === 'string') return record.text; - - const outputText = record.output_text as Record | undefined; - if (typeof outputText?.text === 'string') return outputText.text; - - if (Array.isArray(record.output)) { - return record.output - .flatMap((item) => - Array.isArray((item as Record)?.content) - ? ((item as Record).content as unknown[]) - : [] - ) - .map((content) => { - if (!content || typeof content !== 'object') return ''; - const entry = content as Record; - const nestedText = entry.text as Record | 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; - const nestedText = entry.text as Record | 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((resolve) => { - let resolved = false; - let timeoutHandle: ReturnType | 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((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; -} diff --git a/src/components/agents/hooks/useVoiceInterruption.ts b/src/components/agents/hooks/useVoiceInterruption.ts deleted file mode 100644 index 7b21bb9..0000000 --- a/src/components/agents/hooks/useVoiceInterruption.ts +++ /dev/null @@ -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 | 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; -} diff --git a/src/components/agents/hooks/useVoiceSession.ts b/src/components/agents/hooks/useVoiceSession.ts deleted file mode 100644 index 03f091a..0000000 --- a/src/components/agents/hooks/useVoiceSession.ts +++ /dev/null @@ -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; -} diff --git a/src/services/voice/usageLogger.ts b/src/services/voice/usageLogger.ts deleted file mode 100644 index 7f9e9d5..0000000 --- a/src/services/voice/usageLogger.ts +++ /dev/null @@ -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 { - 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) || {}; - } - return {}; -} - -function applyVoiceUsageMetadata( - metadata: Record, - entry: VoiceUsageLogEntry, - loggedAt: string -): Record { - 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); - } -} From 844a9b57ab96d4ed692d2a6c3eda755a23e33810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:20:35 +1100 Subject: [PATCH 04/26] feat(rah-light): remove delegateToWiseRAH orchestration tool - Deleted src/tools/orchestration/delegateToWiseRAH.ts - Removed delegateToWiseRAH from registry.ts and groups.ts - Deleted tests/evals/scenarios/delegate-wise.ts and delegate-mini.ts - Updated tests/evals/scenarios/index.ts to remove deleted test imports Co-Authored-By: Claude Opus 4.5 --- src/tools/infrastructure/groups.ts | 1 - src/tools/infrastructure/registry.ts | 3 -- src/tools/orchestration/delegateToWiseRAH.ts | 36 -------------------- tests/evals/scenarios/delegate-mini.ts | 15 -------- tests/evals/scenarios/delegate-wise.ts | 15 -------- tests/evals/scenarios/index.ts | 4 --- 6 files changed, 74 deletions(-) delete mode 100644 src/tools/orchestration/delegateToWiseRAH.ts delete mode 100644 tests/evals/scenarios/delegate-mini.ts delete mode 100644 tests/evals/scenarios/delegate-wise.ts diff --git a/src/tools/infrastructure/groups.ts b/src/tools/infrastructure/groups.ts index 9dedac1..13813a7 100644 --- a/src/tools/infrastructure/groups.ts +++ b/src/tools/infrastructure/groups.ts @@ -45,7 +45,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record = { // Orchestration: Workflows and reasoning (orchestrator only) webSearch: 'orchestration', think: 'orchestration', - delegateToWiseRAH: 'orchestration', executeWorkflow: 'orchestration', listWorkflows: 'orchestration', getWorkflow: 'orchestration', diff --git a/src/tools/infrastructure/registry.ts b/src/tools/infrastructure/registry.ts index ee050a9..1af6883 100644 --- a/src/tools/infrastructure/registry.ts +++ b/src/tools/infrastructure/registry.ts @@ -16,7 +16,6 @@ import { queryDimensionNodesTool } from '../database/queryDimensionNodes'; import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings'; import { webSearchTool } from '../other/webSearch'; 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'; @@ -42,7 +41,6 @@ const CORE_TOOLS: Record = { const ORCHESTRATION_TOOLS: Record = { webSearch: webSearchTool, think: thinkTool, - delegateToWiseRAH: delegateToWiseRAHTool, executeWorkflow: executeWorkflowTool, listWorkflows: listWorkflowsTool, getWorkflow: getWorkflowTool, @@ -98,7 +96,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([ const EXECUTOR_TOOL_NAMES = [ ...Object.keys(CORE_TOOLS), ...Object.keys(ORCHESTRATION_TOOLS).filter(name => - name !== 'delegateToWiseRAH' && name !== 'executeWorkflow' ), ...Object.keys(EXECUTION_TOOLS), diff --git a/src/tools/orchestration/delegateToWiseRAH.ts b/src/tools/orchestration/delegateToWiseRAH.ts deleted file mode 100644 index 6e424cb..0000000 --- a/src/tools/orchestration/delegateToWiseRAH.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { tool } from 'ai'; -import { z } from 'zod'; -import { WorkflowExecutor } from '@/services/agents/workflowExecutor'; -import { RequestContext } from '@/services/context/requestContext'; - -export const delegateToWiseRAHTool = tool({ - description: 'Delegate complex workflows to workflow executor', - 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 sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - - const execution = await WorkflowExecutor.execute({ - 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 || 'Workflow completed but no summary returned.'; - return `Workflow (session ${sessionId.split('_').pop()}) completed:\n\n${summary}`; - }, -}); diff --git a/tests/evals/scenarios/delegate-mini.ts b/tests/evals/scenarios/delegate-mini.ts deleted file mode 100644 index 3391cc2..0000000 --- a/tests/evals/scenarios/delegate-mini.ts +++ /dev/null @@ -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'], - }, -}; diff --git a/tests/evals/scenarios/delegate-wise.ts b/tests/evals/scenarios/delegate-wise.ts deleted file mode 100644 index 90aa6ac..0000000 --- a/tests/evals/scenarios/delegate-wise.ts +++ /dev/null @@ -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'], - }, -}; diff --git a/tests/evals/scenarios/index.ts b/tests/evals/scenarios/index.ts index bd2e80a..0c317a0 100644 --- a/tests/evals/scenarios/index.ts +++ b/tests/evals/scenarios/index.ts @@ -8,8 +8,6 @@ import { scenario as createEdge } from './create-edge'; import { scenario as queryDimensions } from './query-dimensions'; import { scenario as getDimension } from './get-dimension'; 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 websiteExtract } from './website-extract'; import { scenario as paperExtract } from './paper-extract'; @@ -25,8 +23,6 @@ export const scenarios = [ dimensionLifecycle, hardModeQuery, workflowIntegrate, - delegateMini, - delegateWise, youtubeExtract, websiteExtract, paperExtract, From 92e48a21756b635527955ad9213550273497975f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:22:14 +1100 Subject: [PATCH 05/26] feat(rah-light): remove chat agent panels - Deleted src/components/agents/AgentsPanel.tsx - Deleted src/components/agents/MiniRAHPanel.tsx - Deleted src/components/agents/WiseRAHPanel.tsx - These components were not imported anywhere Co-Authored-By: Claude Opus 4.5 --- src/components/agents/AgentsPanel.tsx | 1121 ------------------------ src/components/agents/MiniRAHPanel.tsx | 249 ------ src/components/agents/WiseRAHPanel.tsx | 340 ------- 3 files changed, 1710 deletions(-) delete mode 100644 src/components/agents/AgentsPanel.tsx delete mode 100644 src/components/agents/MiniRAHPanel.tsx delete mode 100644 src/components/agents/WiseRAHPanel.tsx diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx deleted file mode 100644 index 61924b6..0000000 --- a/src/components/agents/AgentsPanel.tsx +++ /dev/null @@ -1,1121 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from 'react'; -import RAHChat from './RAHChat'; -import QuickAddInput from './QuickAddInput'; -import QuickAddStatus from './QuickAddStatus'; -import { Zap, Flame, Minimize2 } from 'lucide-react'; -import { Node } from '@/types/database'; -import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; - -// 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; - agentType: string; - createdAt: string; - updatedAt: string; -}; - -interface AgentsPanelProps { - openTabsData: Node[]; - activeTabId: number | null; - activeDimension?: string | null; - onNodeClick?: (nodeId: number) => void; - onCollapse?: () => void; -} - -type ActiveTab = 'ra-h' | 'workflows' | string; // 'ra-h', 'workflows', or delegation sessionId -type Mode = 'quickadd' | 'session'; - -export default function AgentsPanel({ openTabsData, activeTabId, activeDimension, onNodeClick, onCollapse }: AgentsPanelProps) { - const [delegationsMap, setDelegationsMap] = useState>({}); - const [activeAgentTab, setActiveAgentTab] = useState('ra-h'); - const [mode, setMode] = useState('quickadd'); - const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy'); - const [modelDropdownOpen, setModelDropdownOpen] = useState(false); - // Lift messages state to prevent losing it on tab switch - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [rahMessages, setRahMessages] = useState([]); - - // Store delegation messages per sessionId - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [delegationMessages, setDelegationMessages] = useState>({}); - - const getDelegationMessages = useCallback((sessionId: string) => { - return delegationMessages[sessionId] || []; - }, [delegationMessages]); - - const setDelegationMessagesFor = useCallback((sessionId: string) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (updater: (prev: any[]) => any[]) => { - setDelegationMessages(prev => ({ - ...prev, - [sessionId]: updater(prev[sessionId] || []) - })); - }; - }, []); - - const upsertDelegation = useCallback((delegation: AgentDelegation) => { - setDelegationsMap((prev) => ({ - ...prev, - [delegation.sessionId]: delegation, - })); - }, []); - - 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('[AgentsPanel] Failed to load delegations:', error); - } - }; - - loadExisting(); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (typeof window === 'undefined') return; - const stored = window.localStorage.getItem('rah-mode'); - if (stored === 'easy' || stored === 'hard') { - setRahMode(stored); - } - }, []); - - useEffect(() => { - if (typeof window === 'undefined') return; - window.localStorage.setItem('rah-mode', rahMode); - }, [rahMode]); - - useEffect(() => { - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail; - if (detail?.mode) { - setRahMode(detail.mode); - } - }; - const quickAddHandler = () => setMode('quickadd'); - window.addEventListener('rah:mode-toggle', handler as EventListener); - window.addEventListener('rah:switch-quickadd', quickAddHandler); - return () => { - window.removeEventListener('rah:mode-toggle', handler as EventListener); - window.removeEventListener('rah:switch-quickadd', quickAddHandler); - }; - }, []); - - - useEffect(() => { - const handleCreated = (event: Event) => { - const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail; - console.log('[AgentsPanel] Delegation created:', detail?.delegation); - if (detail?.delegation) upsertDelegation(detail.delegation); - }; - const handleUpdated = (event: Event) => { - const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail; - console.log('[AgentsPanel] Delegation updated:', detail?.delegation); - if (detail?.delegation) upsertDelegation(detail.delegation); - }; - - window.addEventListener('delegations:created', handleCreated as EventListener); - window.addEventListener('delegations:updated', handleUpdated as EventListener); - return () => { - window.removeEventListener('delegations:created', handleCreated as EventListener); - window.removeEventListener('delegations:updated', handleUpdated as EventListener); - }; - }, [upsertDelegation]); - - const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]); - - const orderedDelegations = useMemo(() => { - const statusWeight = (status: AgentDelegation['status']) => { - switch (status) { - case 'queued': - return 0; - case 'in_progress': - return 1; - case 'completed': - return 2; - case 'failed': - default: - return 3; - } - }; - - // No staleness filtering - delegations persist until user closes them - const ordered = delegations.sort((a, b) => { - const diff = statusWeight(a.status) - statusWeight(b.status); - if (diff !== 0) return diff; - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); - }); - - const wise = ordered.filter((d) => d.agentType === 'wise-rah'); - const mini = ordered.filter((d) => d.agentType !== 'wise-rah'); - - return [...wise, ...mini]; - }, [delegations]); - - // Don't auto-switch tabs - user stays in ra-h to see responses - - const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab); - - const handleQuickAddSubmit = async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => { - try { - const response = await fetch('/api/quick-add', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input, mode, description }) - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to submit Quick Add'); - } - } catch (error) { - console.error('[AgentsPanel] Quick Add error:', error); - } - }; - - const handleDelegationClick = (sessionId: string) => { - setMode('session'); - setActiveAgentTab(sessionId); - }; - - return ( -
- {/* Mode Header */} - {mode === 'quickadd' ? ( -
- {/* Top Bar - collapse button + Add Stuff */} -
- {onCollapse && ( - - )} - {/* Add Stuff - top right */} -
- -
-
- - {/* Center Section - Start button centered */} -
- -
-
- ) : null} - - {/* Tab Bar (only show in session mode) */} - {mode === 'session' && ( -
- {/* Collapse button - first item */} - {onCollapse && ( - - )} - {/* Capture button - positioned at far right */} -
- -
- - {/* RA-H Main Tab */} - - - {/* Workflows Tab */} - {orderedDelegations.length > 0 && ( - - )} -
- )} - - {/* Active Panel */} -
- {mode === 'quickadd' ? ( -
- -
- ) : ( - <> - {/* Keep RAHChat always mounted, just hide when not active */} -
- -
- - {/* Workflows list view */} - {activeAgentTab === 'workflows' && ( -
- setActiveAgentTab(sessionId)} - onDeleteDelegation={async (sessionId) => { - try { - await fetch(`/api/rah/delegations/${sessionId}`, { method: 'DELETE' }); - } catch (error) { - console.error(`Failed to delete delegation ${sessionId}:`, error); - } - setDelegationsMap((prev) => { - const { [sessionId]: _ignored, ...rest } = prev; - return rest; - }); - setDelegationMessages((prev) => { - const { [sessionId]: _ignored, ...rest } = prev; - return rest; - }); - }} - onNodeClick={onNodeClick} - /> -
- )} - - {/* Show delegation detail when a specific delegation is selected */} - {selectedDelegation && activeAgentTab !== 'ra-h' && activeAgentTab !== 'workflows' && ( -
- {/* Show summary view if completed/failed with no messages */} - {(selectedDelegation.status === 'completed' || selectedDelegation.status === 'failed') - && getDelegationMessages(selectedDelegation.sessionId).length === 0 ? ( - setActiveAgentTab('workflows')} - onNodeClick={onNodeClick} - /> - ) : ( - setActiveAgentTab('workflows')} - /> - )} -
- )} - - )} -
- -
- ); -} - -// Summary view for completed delegations with no messages -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 ( -
- {/* Header with back button */} -
- {onBack && ( - - )} -
- - {statusLabel} - - - {new Date(delegation.updatedAt).toLocaleString()} - -
- - {/* Task */} -
-
- Task -
-
- {delegation.task} -
-
- - {/* Summary */} - {delegation.summary && ( -
-
- Result -
-
- {parseAndRenderContent(delegation.summary || '', onNodeClick)} -
-
- )} - - {/* No summary fallback */} - {!delegation.summary && ( -
- No details available -
- )} -
- ); -} - -// Workflows list view - shows all delegations in a nice list -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 ( -
- {/* Header */} -
- - Workflows - - {activeDelegations.length > 0 && ( - - {activeDelegations.length} running - - )} -
- - {/* List */} -
- {delegations.length === 0 ? ( -
- No workflows yet. Use Quick Capture or ask RA-H to run a workflow. -
- ) : ( -
- {/* Active workflows first */} - {activeDelegations.map((delegation) => { - const { color, label } = getStatusInfo(delegation); - return ( - onSelectDelegation(delegation.sessionId)} - onDelete={() => onDeleteDelegation(delegation.sessionId)} - onNodeClick={onNodeClick} - /> - ); - })} - - {/* Divider if both active and completed exist */} - {activeDelegations.length > 0 && completedDelegations.length > 0 && ( -
- )} - - {/* Completed workflows */} - {completedDelegations.map((delegation) => { - const { color, label } = getStatusInfo(delegation); - return ( - onSelectDelegation(delegation.sessionId)} - onDelete={() => onDeleteDelegation(delegation.sessionId)} - onNodeClick={onNodeClick} - /> - ); - })} -
- )} -
-
- ); -} - -// 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 ( -
{ - e.currentTarget.style.background = '#1a1a1a'; - e.currentTarget.style.borderColor = '#2a2a2a'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = '#151515'; - e.currentTarget.style.borderColor = '#1f1f1f'; - }} - > - {/* Top row: status + time + delete */} -
-
- - {statusLabel} - - - {new Date(delegation.createdAt).toLocaleTimeString()} - - -
- - {/* Task description */} -
- {delegation.task} -
- - {/* Summary preview if completed */} - {delegation.summary && delegation.status === 'completed' && ( -
- {parseAndRenderContent(delegation.summary, onNodeClick)} -
- )} -
- ); -} - -// Delegation detail view with back button - -function DelegationDetailView({ - delegation, - openTabsData, - activeTabId, - activeDimension, - onNodeClick, - delegations, - messages, - setMessages, - onBack -}: { - delegation: AgentDelegation; - openTabsData: Node[]; - activeTabId: number | null; - activeDimension?: string | null; - onNodeClick?: (nodeId: number) => void; - delegations: AgentDelegation[]; - messages: any[]; - setMessages: (updater: (prev: any[]) => any[]) => void; - onBack: () => void; -}) { - return ( -
- {/* Back button header */} -
- - - | - - - {delegation.status === 'in_progress' ? 'Running' : delegation.status} - -
- - {/* RAHChat for streaming */} -
- -
-
- ); -} diff --git a/src/components/agents/MiniRAHPanel.tsx b/src/components/agents/MiniRAHPanel.tsx deleted file mode 100644 index 359661e..0000000 --- a/src/components/agents/MiniRAHPanel.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { ReactNode } from 'react'; - -// 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; -}; - -interface MiniRAHPanelProps { - delegation: AgentDelegation; - onNodeClick?: (nodeId: number) => void; -} - -const statusPalette: Record = { - 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({text.slice(lastIndex, offset)}); - } - - const nodeId = Number(id); - const handleClick = () => { - if (onNodeClick) onNodeClick(nodeId); - }; - - parts.push( - - ); - - lastIndex = offset + match.length; - return match; - }); - - if (lastIndex < text.length) { - parts.push({text.slice(lastIndex)}); - } - - 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 ( -
-
- - MINI RA-H - · {formatStatus(delegation.status)} - {new Date(delegation.updatedAt).toLocaleTimeString()} -
- -
-

Task

-

{delegation.task}

-
- - {delegation.context.length > 0 && ( -
-

Context

-
    - {delegation.context.map((item, idx) => ( -
  • {renderNodeAwareLine(item, onNodeClick)}
  • - ))} -
-
- )} - - {summaryLines.length > 0 && ( -
-

Summary

-
- {summaryLines.map((line, idx) => ( -

{renderNodeAwareLine(line, onNodeClick)}

- ))} -
-
- )} - - -
- ); -} diff --git a/src/components/agents/WiseRAHPanel.tsx b/src/components/agents/WiseRAHPanel.tsx deleted file mode 100644 index b6cd884..0000000 --- a/src/components/agents/WiseRAHPanel.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { Fragment, ReactNode, useMemo } from 'react'; - -// 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; -}; - -const statusPalette: Record = { - 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({text.slice(lastIndex, offset)}); - } - const nodeId = Number(id); - const handleClick = () => { - if (onNodeClick) { - onNodeClick(nodeId); - } - }; - segments.push( - - ); - lastIndex = offset + match.length; - return match; - }); - - if (lastIndex < text.length) { - segments.push({text.slice(lastIndex)}); - } - - 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 ( -
    - {lines.map((line, idx) => { - const text = line.replace(/^[-*•]\s*/, '').trim(); - return ( -
  • - {renderNodeAwareText(text, onNodeClick)} -
  • - ); - })} -
- ); - } - - return ( -
- {lines.map((line, idx) => ( -

{renderNodeAwareText(line, onNodeClick)}

- ))} -
- ); -} - -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 ( -
-
- - WISE RA-H - · {formatStatus(delegation.status)} - {new Date(delegation.updatedAt).toLocaleTimeString()} -
- -
-

Goal

-

{delegation.task}

-
- - {delegation.context.length > 0 && ( -
-

Context

-
    - {delegation.context.map((item, idx) => ( -
  • {renderNodeAwareText(item, onNodeClick)}
  • - ))} -
-
- )} - - {parsedSummary.length > 0 && ( -
-

Summary

-
- {parsedSummary.map(section => ( -
-
{section.title}
- {renderSectionLines(section.lines, onNodeClick)} -
- ))} -
-
- )} - - -
- ); -} From d9c0ba89fe84e1dfb265983587a58e4f98b38ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:24:03 +1100 Subject: [PATCH 06/26] feat(rah-light): remove chat API routes - Deleted app/api/rah/chat/route.ts - Deleted app/api/rah/usage/route.ts - Deleted empty app/api/rah/ directory - RAHChat.tsx references removed in Story 7 Co-Authored-By: Claude Opus 4.5 --- app/api/rah/chat/route.ts | 465 ------------------------------------- app/api/rah/usage/route.ts | 28 --- 2 files changed, 493 deletions(-) delete mode 100644 app/api/rah/chat/route.ts delete mode 100644 app/api/rah/usage/route.ts diff --git a/app/api/rah/chat/route.ts b/app/api/rah/chat/route.ts deleted file mode 100644 index d10bd45..0000000 --- a/app/api/rah/chat/route.ts +++ /dev/null @@ -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 = { - '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' } - } - ); - } -} diff --git a/app/api/rah/usage/route.ts b/app/api/rah/usage/route.ts deleted file mode 100644 index 1092cf3..0000000 --- a/app/api/rah/usage/route.ts +++ /dev/null @@ -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 }); - } -} From a398819e260c3e78d3cd68c48bd20fa0a46edbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:27:53 +1100 Subject: [PATCH 07/26] feat(rah-light): remove chat pane, simplify to two-panel layout - Deleted src/components/panes/ChatPane.tsx - Removed 'chat' from PaneType in types.ts - Updated ThreePanelLayout.tsx to remove chat pane case and references - Updated LeftToolbar.tsx to remove chat icon and type - Default slot B is now null (closed) instead of chat - Cmd+\ now opens node pane instead of chat Co-Authored-By: Claude Opus 4.5 --- src/components/layout/LeftToolbar.tsx | 9 +- src/components/layout/ThreePanelLayout.tsx | 79 +++------ src/components/panes/ChatPane.tsx | 183 --------------------- src/components/panes/index.ts | 1 - src/components/panes/types.ts | 23 +-- 5 files changed, 30 insertions(+), 265 deletions(-) delete mode 100644 src/components/panes/ChatPane.tsx diff --git a/src/components/layout/LeftToolbar.tsx b/src/components/layout/LeftToolbar.tsx index 76824a6..ec852e5 100644 --- a/src/components/layout/LeftToolbar.tsx +++ b/src/components/layout/LeftToolbar.tsx @@ -5,7 +5,6 @@ import { Search, Plus, LayoutList, - MessageSquare, Map, Folder, Workflow, @@ -23,10 +22,9 @@ interface LeftToolbarProps { 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 = { views: LayoutList, - chat: MessageSquare, map: Map, dimensions: Folder, workflows: Workflow, @@ -34,14 +32,13 @@ const PANE_TYPE_ICONS: Record = { const PANE_TYPE_LABELS: Record = { views: 'Feed', - chat: 'Chat', map: 'Map', dimensions: 'Dimensions', workflows: 'Workflows', }; -// Pane types shown in the toolbar (excludes 'node' which is opened via Feed) -const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'chat', 'map', 'dimensions', 'workflows']; +// Pane types shown in the toolbar (excludes 'node' which is opened via Feed, chat removed in rah-light) +const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'workflows']; interface ToolbarButtonProps { icon: typeof Search; diff --git a/src/components/layout/ThreePanelLayout.tsx b/src/components/layout/ThreePanelLayout.tsx index 9cc765c..d5a9ff5 100644 --- a/src/components/layout/ThreePanelLayout.tsx +++ b/src/components/layout/ThreePanelLayout.tsx @@ -6,7 +6,7 @@ import SearchModal from '../nodes/SearchModal'; import { Node } from '@/types/database'; import { DatabaseEvent } from '@/services/events'; import { usePersistentState } from '@/hooks/usePersistentState'; -import type { ChatMessage } from '@/components/agents/hooks/useSSEChat'; +// ChatMessage import removed - chat disabled in rah-light // Stub type for delegation (delegation system removed in rah-light) type AgentDelegation = { @@ -25,8 +25,8 @@ type AgentDelegation = { import LeftToolbar from './LeftToolbar'; import SplitHandle from './SplitHandle'; -// Pane components -import { NodePane, ChatPane, WorkflowsPane, DimensionsPane, MapPane, ViewsPane } from '../panes'; +// Pane components (ChatPane removed in rah-light) +import { NodePane, WorkflowsPane, DimensionsPane, MapPane, ViewsPane } from '../panes'; import QuickAddInput from '../agents/QuickAddInput'; import type { PaneType, SlotState, PaneAction } from '../panes/types'; @@ -35,14 +35,14 @@ export default function ThreePanelLayout() { const containerRef = useRef(null); // Slot states - the core of the flexible pane system - // Default: Feed on left, Chat on right - const [slotA, setSlotA] = usePersistentState('ui.slotA.v3', { + // Default: Feed on left, closed on right (chat removed in rah-light) + const [slotA, setSlotA] = usePersistentState('ui.slotA.v4', { type: 'views', }); // SlotB can be null (closed) or a SlotState - // Default: Chat on the right - const [slotB, setSlotB] = usePersistentState('ui.slotB.v3', { type: 'chat' }); + // Default: closed (chat removed in rah-light) + const [slotB, setSlotB] = usePersistentState('ui.slotB.v4', null); // SlotB width as percentage (when open) const [slotBWidth, setSlotBWidth] = usePersistentState('ui.slotBWidth', 50); @@ -75,17 +75,14 @@ export default function ThreePanelLayout() { const [focusPanelRefresh, setFocusPanelRefresh] = useState(0); const [folderViewRefresh, setFolderViewRefresh] = useState(0); - // Active dimension tracking (for chat context) + // Active dimension tracking const [activeDimension, setActiveDimension] = usePersistentState('ui.focus.activeDimension', null); // Delegations state (deprecated - kept for component compatibility) const [delegationsMap] = useState>({}); const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]); - // Chat state (lifted to persist across pane type changes) - const [chatMessages, setChatMessages] = useState([]); - - // Source awareness - highlighted passage context for agent + // Source awareness - highlighted passage context const [highlightedPassage, setHighlightedPassage] = useState<{ nodeId: number; nodeTitle: string; @@ -95,29 +92,13 @@ export default function ThreePanelLayout() { // Ref to get current openTabs value in SSE handler const openTabsRef = useRef([]); - // 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 const { openTabs, activeTab } = useMemo(() => { const slotAHasNodes = slotA?.type === 'node'; const slotBHasNodes = slotB?.type === 'node'; - // If chat is in Slot A, use Slot B's 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 + // Use Slot A if it has nodes if (slotAHasNodes && slotA) { return { openTabs: slotA.nodeTabs ?? [], @@ -125,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 }; }, [slotA, slotB]); @@ -189,8 +178,8 @@ export default function ThreePanelLayout() { if (slotB) { setSlotB(null); } else { - // Open with chat by default - setSlotB({ type: 'chat' }); + // Open with node pane by default (chat removed in rah-light) + setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null }); } } // Cmd+N - open Add Stuff modal @@ -749,7 +738,7 @@ export default function ThreePanelLayout() { // Split handle callbacks 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'); }, [setSlotB]); @@ -816,29 +805,7 @@ export default function ThreePanelLayout() { /> ); - case 'chat': - return ( - setActiveDimension(null)} - onNodeClick={(nodeId) => { - handleNodeSelect(nodeId, false); - setActivePane(slot); - }} - delegations={delegations} - chatMessages={chatMessages as unknown[]} - setChatMessages={setChatMessages as React.Dispatch>} - highlightedPassage={highlightedPassage} - onClearPassage={() => setHighlightedPassage(null)} - /> - ); + // case 'chat' removed in rah-light case 'workflows': return ( diff --git a/src/components/panes/ChatPane.tsx b/src/components/panes/ChatPane.tsx deleted file mode 100644 index b07105b..0000000 --- a/src/components/panes/ChatPane.tsx +++ /dev/null @@ -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([]); - - 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 ( -
- - -
- {!hasStarted ? ( -
- {/* Start Chat button */} -
- -
-
- ) : ( - - )} -
-
- ); -} diff --git a/src/components/panes/index.ts b/src/components/panes/index.ts index 6f45346..1c24365 100644 --- a/src/components/panes/index.ts +++ b/src/components/panes/index.ts @@ -1,5 +1,4 @@ export { default as NodePane } from './NodePane'; -export { default as ChatPane } from './ChatPane'; export { default as WorkflowsPane } from './WorkflowsPane'; export { default as DimensionsPane } from './DimensionsPane'; export { default as MapPane } from './MapPane'; diff --git a/src/components/panes/types.ts b/src/components/panes/types.ts index 1336b02..8461676 100644 --- a/src/components/panes/types.ts +++ b/src/components/panes/types.ts @@ -14,8 +14,8 @@ export type AgentDelegation = { updatedAt: string; }; -// The six pane types -export type PaneType = 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views'; +// The five pane types (chat removed in rah-light) +export type PaneType = 'node' | 'workflows' | 'dimensions' | 'map' | 'views'; // State for each slot export interface SlotState { @@ -66,21 +66,7 @@ export interface HighlightedPassage { selectedText: string; } -// ChatPane specific props -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>; - // Source awareness - highlightedPassage?: HighlightedPassage | null; - onClearPassage?: () => void; -} +// ChatPaneProps removed in rah-light // WorkflowsPane specific props export interface WorkflowsPaneProps extends BasePaneProps { @@ -124,7 +110,6 @@ export interface PaneHeaderProps { // Labels for pane types export const PANE_LABELS: Record = { node: 'Nodes', - chat: 'Chat', workflows: 'Workflows', dimensions: 'Dimensions', map: 'Map', @@ -139,5 +124,5 @@ export const DEFAULT_SLOT_A: SlotState = { }; export const DEFAULT_SLOT_B: SlotState = { - type: 'chat', + type: 'workflows', }; From 2661be7c803a85703640f3e49ff46950cc584df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Thu, 29 Jan 2026 15:30:05 +1100 Subject: [PATCH 08/26] feat(rah-light): remove RAHChat and related components - Deleted 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 src/components/helpers/ReasoningTrace.tsx - Deleted src/components/helpers/ToolDisplay.tsx - Simplified WorkflowsPane to show MCP integration message (no chat) Co-Authored-By: Claude Opus 4.5 --- src/components/agents/RAHChat.tsx | 501 --------------------- src/components/agents/TerminalInput.tsx | 399 ----------------- src/components/agents/TerminalMessage.tsx | 148 ------ src/components/agents/hooks/useSSEChat.ts | 226 ---------- src/components/helpers/ReasoningTrace.tsx | 60 --- src/components/helpers/ToolDisplay.tsx | 157 ------- src/components/panes/WorkflowsPane.tsx | 523 +--------------------- 7 files changed, 19 insertions(+), 1995 deletions(-) delete mode 100644 src/components/agents/RAHChat.tsx delete mode 100644 src/components/agents/TerminalInput.tsx delete mode 100644 src/components/agents/TerminalMessage.tsx delete mode 100644 src/components/agents/hooks/useSSEChat.ts delete mode 100644 src/components/helpers/ReasoningTrace.tsx delete mode 100644 src/components/helpers/ToolDisplay.tsx diff --git a/src/components/agents/RAHChat.tsx b/src/components/agents/RAHChat.tsx deleted file mode 100644 index c6e7456..0000000 --- a/src/components/agents/RAHChat.tsx +++ /dev/null @@ -1,501 +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 { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat'; -import { useQuotaHandler } from '@/hooks/useQuotaHandler'; - -// 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; - agentType: string; - createdAt: string; - updatedAt: string; -}; - -// Stub DelegationIndicator component (delegation system removed) -function DelegationIndicator({ delegations }: { delegations: AgentDelegation[] }) { - return null; -} - -const createSessionId = () => `session_${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([]); - const messages = externalMessages !== undefined ? externalMessages : internalMessages; - const setMessages = externalSetMessages || internalSetMessages; - - const [sessionId, setSessionId] = useState(() => createSessionId()); - const messagesEndRef = useRef(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>(async () => { - await refetchUsage(); - }); - const setMessagesRef = useRef(setMessages); - - const sse = useSSEChat('/api/rah/chat', setMessages, { - getAuthToken: () => null, - beforeRequest: checkQuotaBeforeRequest, - onRequestError: handleQuotaApiError, - onStreamComplete: async () => { - const handler = streamCompleteHandlerRef.current; - if (handler) { - await handler(); - } - }, - }); - - 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 handleStreamComplete = useCallback(async () => { - await refetchUsage(); - }, [refetchUsage]); - - useEffect(() => { - streamCompleteHandlerRef.current = handleStreamComplete; - }, [handleStreamComplete]); - - useEffect(() => { - setMessagesRef.current = setMessages; - }, [setMessages]); - - 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]); - - const handleNewChat = () => { - if (delegationMode) return; - sse.abort(); - setMessages((_prev) => []); - setSessionId(createSessionId()); - }; - - // 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]); - - return ( -
- {focusSummary && ( -
- {/* Focused node info */} -
- - Focused Node ({focusSummary.total}) - - #{focusSummary.id} - - {focusSummary.title} - -
- -
- )} - -
- {messages.length === 0 ? ( - - ) : ( - <> - {messages.map((message) => ( - - ))} - - )} -
-
- - {!delegationMode && ( -
- {(quotaError || isQuotaExceeded) && ( -
- {quotaError?.message ?? 'Rate limit reached. Please wait a moment and try again.'} -
- )} - -
-
- -
- -
-
- )} -
- ); -} - -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 ( -
- - - {dropdownOpen && ( -
- {options.map((option) => { - const OptionIcon = option.icon; - const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard'); - - return ( - - ); - })} -
- )} -
- ); -} diff --git a/src/components/agents/TerminalInput.tsx b/src/components/agents/TerminalInput.tsx deleted file mode 100644 index d4529cc..0000000 --- a/src/components/agents/TerminalInput.tsx +++ /dev/null @@ -1,399 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, type DragEvent } from 'react'; - -interface TerminalInputProps { - onSubmit: (text: string) => void; - isProcessing: boolean; - placeholder?: string; - helperId?: number; - disabledExternally?: boolean; - disabledMessage?: string; -} - -export default function TerminalInput({ - onSubmit, - isProcessing, - placeholder, - helperId, - disabledExternally = false, - disabledMessage, -}: TerminalInputProps) { - const [input, setInput] = useState(''); - const [rows, setRows] = useState(1); - const textareaRef = useRef(null); - const [prompts, setPrompts] = useState>([]); - 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 buttonIsDisabled = !trimmedInput || isProcessing || disabledExternally; - - // Handle node drag over chat input - const handleDragOver = (e: DragEvent) => { - // 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) => { - // Only reset if actually leaving the textarea (not entering a child) - if (e.currentTarget === e.target) { - setIsDragOver(false); - } - }; - - const handleDrop = (e: DragEvent) => { - 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 ( - <> - -
- {/* Terminal Prompt Symbol */} - - {'>'} - - - {/* Input Wrapper */} -
- {/* Input Row with Textarea and Button */} -
-