Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

Features:
- 3-panel UI (Nodes | Focus | Helpers)
- SQLite + sqlite-vec for vector search
- Agent system (Easy/Hard mode orchestrators)
- Content extraction (YouTube, PDF, web)
- Integrate workflow for connection discovery
- Dimension system with auto-assignment

Tech stack:
- Next.js 15 + TypeScript + Tailwind CSS
- Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK

Setup:
  npm install && npm rebuild better-sqlite3
  scripts/dev/bootstrap-local.sh
  npm run dev

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useState } from 'react';
import { parseAndRenderContent } from './NodeLabelRenderer';
interface MarkdownRendererProps {
content: string;
streaming?: boolean;
onNodeClick?: (nodeId: number) => void;
}
export default function MarkdownRenderer({ content, streaming, onNodeClick }: MarkdownRendererProps) {
if (!content) return null;
const segments = splitCodeBlocks(content);
return (
<div style={{ color: '#e5e5e5', fontSize: 16, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{segments.map((seg, i) =>
seg.type === 'code' ? (
<CodeBlock key={i} language={seg.lang} code={seg.text} />
) : (
<span key={i}>{renderTextWithFormatting(transformMarkdownNodeLinks(seg.text), onNodeClick)}</span>
)
)}
{streaming ? (
<span style={{ display: 'inline-block', width: 3, height: 12, marginLeft: 2, background: 'rgba(200,200,200,0.5)', verticalAlign: 'baseline' }} />
) : null}
</div>
);
}
function CodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
} catch {}
};
return (
<div style={{ margin: '8px 0' }}>
<div style={{
background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: 6,
padding: 8, overflowX: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
<span style={{ color: '#8a8a8a', fontSize: 11 }}>{language || 'code'}</span>
<button onClick={handleCopy} style={{ marginLeft: 'auto', fontSize: 11, color: '#8a8a8a', background: 'transparent', border: '1px solid #2a2a2a', borderRadius: 4, padding: '1px 6px', cursor: 'pointer' }}>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre style={{ margin: 0 }}>
<code>{code}</code>
</pre>
</div>
</div>
);
}
function renderTextWithFormatting(text: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
// Extract source quotes ONLY (pattern: > "quote text")
const quoteSegments = splitSourceQuotes(text);
return quoteSegments.flatMap((segment, segIdx) => {
if (segment.type === 'quote') {
return (
<div key={`quote-${segIdx}`} style={{
margin: '12px 0',
padding: '10px 14px',
borderLeft: '3px solid #333',
background: '#0f0f0f',
fontStyle: 'italic',
color: '#b8b8b8',
position: 'relative'
}}>
<span style={{
position: 'absolute',
top: 8,
left: 8,
fontSize: 24,
color: '#3a3a3a',
lineHeight: 1
}}>"</span>
<div style={{ paddingLeft: 12 }}>
{parseInlineFormatting(segment.text, onNodeClick)}
</div>
</div>
);
} else {
return <span key={`text-${segIdx}`}>{parseInlineFormatting(segment.text, onNodeClick)}</span>;
}
});
}
function splitSourceQuotes(input: string): Array<{ type: 'text' | 'quote'; text: string }> {
const out: Array<{ type: 'text' | 'quote'; text: string }> = [];
// ONLY match quotes that start with "> " (blockquote syntax from search tool)
// This ensures we don't break [NODE:ID:"title"] patterns
const quotePattern = /^>\s+"(.+?)"$/gm;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = quotePattern.exec(input)) !== null) {
// Add text before quote
if (match.index > lastIndex) {
out.push({ type: 'text', text: input.slice(lastIndex, match.index) });
}
// Add the quote content (without the > and quote marks)
out.push({ type: 'quote', text: match[1] });
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < input.length) {
out.push({ type: 'text', text: input.slice(lastIndex) });
}
return out.length > 0 ? out : [{ type: 'text', text: input }];
}
function parseInlineFormatting(text: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
// Pattern for **bold** text
const boldPattern = /\*\*(.+?)\*\*/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
// First pass: handle bold text
const textParts: Array<{ type: 'text' | 'bold'; text: string }> = [];
while ((match = boldPattern.exec(text)) !== null) {
if (match.index > lastIndex) {
textParts.push({ type: 'text', text: text.slice(lastIndex, match.index) });
}
textParts.push({ type: 'bold', text: match[1] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
textParts.push({ type: 'text', text: text.slice(lastIndex) });
}
// If no bold text found, just use the original text
if (textParts.length === 0) {
textParts.push({ type: 'text', text });
}
// Second pass: render each part with node label parsing
return textParts.flatMap((part, idx) => {
if (part.type === 'bold') {
return (
<strong key={`bold-${idx}`} style={{
fontWeight: 600,
textDecoration: 'underline',
textDecorationColor: '#4a4a4a',
textDecorationThickness: '1px',
textUnderlineOffset: '2px',
color: '#f0f0f0'
}}>
{parseAndRenderContent(part.text, onNodeClick)}
</strong>
);
} else {
return <span key={`text-${idx}`}>{parseAndRenderContent(part.text, onNodeClick)}</span>;
}
});
}
function splitCodeBlocks(input: string): Array<{ type: 'text' | 'code'; text: string; lang?: string }>
{
const out: Array<{ type: 'text' | 'code'; text: string; lang?: string }> = [];
const codeFence = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g;
let lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = codeFence.exec(input)) !== null) {
if (m.index > lastIndex) out.push({ type: 'text', text: input.slice(lastIndex, m.index) });
out.push({ type: 'code', lang: m[1], text: m[2] });
lastIndex = m.index + m[0].length;
}
if (lastIndex < input.length) out.push({ type: 'text', text: input.slice(lastIndex) });
return out;
}
// Convert markdown links that point to #NODE:ID:"Title" into the inline token [NODE:ID:"Title"]
function transformMarkdownNodeLinks(input: string): string {
if (!input) return input;
// Use non-greedy match (.+?) to handle quotes inside titles
const nodeLink = /\[[^\]]*\]\(\s*#NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\)/g;
return input.replace(nodeLink, (_m, id, title) => `[NODE:${id}:"${title}"]`);
}
@@ -0,0 +1,105 @@
"use client";
import React from 'react';
import { FileText } from 'lucide-react';
interface NodeLabelProps {
id: string;
title: string;
dimensions: string[];
onNodeClick?: (nodeId: number) => void;
}
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
const handleClick = (e: React.MouseEvent) => {
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
e.stopPropagation();
if (onNodeClick) {
onNodeClick(parseInt(id));
}
};
const maxTitleLength = 40;
const truncatedTitle = title.length > maxTitleLength
? `${title.substring(0, maxTitleLength)}...`
: title;
const showTooltip = title.length > maxTitleLength;
return (
<>
{/* Clickable ID badge - inline with existing line height */}
<span
onClick={handleClick}
title={showTooltip ? title : undefined}
style={{
display: 'inline',
padding: '2px 6px',
background: '#22c55e',
color: '#000',
borderRadius: '3px',
fontSize: '11px',
fontWeight: '600',
cursor: 'pointer',
marginRight: '4px',
lineHeight: '1', /* prevent line height issues */
verticalAlign: 'baseline'
}}
>
#{id}
</span>
{/* Non-clickable title - bold and underlined */}
<span style={{
fontWeight: 'bold',
textDecoration: 'underline',
color: '#e5e5e5'
}}>
{truncatedTitle}
</span>
</>
);
}
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
if (!content) return [content];
// Pattern to match [NODE:id:"title"] (dimensions removed)
// Be tolerant of spaces and curly quotes
// Use non-greedy match (.+?) to handle quotes inside titles
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = nodePattern.exec(content)) !== null) {
// Add text before the node
if (match.index > lastIndex) {
parts.push(content.substring(lastIndex, match.index));
}
// Parse the node data
const id = match[1];
const title = match[2];
const dimensions: string[] = []; // No dimensions in new format
// Add the node label
parts.push(
<NodeLabel
key={`node-${id}-${match.index}`}
id={id}
title={title}
dimensions={dimensions}
onNodeClick={onNodeClick}
/>
);
lastIndex = match.index + match[0].length;
}
// Add any remaining text
if (lastIndex < content.length) {
parts.push(content.substring(lastIndex));
}
return parts.length > 0 ? parts : [content];
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from 'react';
interface ReasoningTraceProps {
trace?: {
purpose?: string;
thoughts?: string;
next_action?: string;
step?: number;
done?: boolean;
timestamp?: string;
} | null;
collapsible?: boolean;
}
export default function ReasoningTrace({ trace, collapsible = true }: ReasoningTraceProps) {
const [expanded, setExpanded] = useState(false);
if (!trace) return null;
const short = (s?: string, n = 180) => (s || '').slice(0, n) + ((s || '').length > n ? '…' : '');
return (
<div style={{
margin: '6px 0', padding: '8px 10px',
background: '#0e0e12', border: '1px solid #2a2a2a',
borderLeft: '2px solid #6b6b7f', borderRadius: 6,
fontSize: 12, color: '#cfcfcf', lineHeight: 1.5
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ color: '#8b8b9f', fontWeight: 600 }}>Reasoning trace</span>
{trace.timestamp ? (
<span style={{ color: '#4a4a4a', fontSize: 11 }}>{new Date(trace.timestamp).toLocaleTimeString()}</span>
) : null}
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{trace.done ? 'final' : 'in-progress'}</span>
</div>
{/* Collapsed: show only the purpose as the title */}
{trace.purpose ? (
<div style={{ color: '#9adbc2' }}>Goal: {expanded ? trace.purpose : short(trace.purpose, 80)}</div>
) : null}
{/* Expanded: show details */}
{expanded && trace.thoughts ? <div style={{ marginTop: 6 }}>{trace.thoughts}</div> : null}
{expanded && trace.next_action ? (
<div style={{ marginTop: 6, color: '#a5b4fc' }}>Next: {trace.next_action}</div>
) : null}
{collapsible ? (
<div style={{ marginTop: 6 }}>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
style={{
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
}}
>
{expanded ? 'Hide reasoning' : 'Show full reasoning'}
</button>
</div>
) : null}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
interface SourceChipProps {
url?: string;
domain?: string;
}
function extractDomain(input?: string) {
if (!input) return '';
try {
const d = input.includes('://') ? new URL(input).hostname : input;
return d.replace(/^www\./, '');
} catch {
return input.replace(/^www\./, '');
}
}
export default function SourceChip({ url, domain }: SourceChipProps) {
const d = extractDomain(domain || url);
const favicon = d ? `https://icons.duckduckgo.com/ip3/${d}.ico` : '';
return (
<span
title={url || d}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: 999,
padding: '2px 8px',
fontSize: 11,
color: '#bdbdbd',
lineHeight: 1
}}
>
{d ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={favicon}
alt=""
width={12}
height={12}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
style={{ borderRadius: 2 }}
/>
) : null}
<span>{d || 'source'}</span>
</span>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import SourceChip from './SourceChip';
import { useState } from 'react';
interface ToolDisplayProps {
name: string;
status: 'starting' | 'running' | 'complete' | 'error';
context?: string;
sources?: Array<{ url?: string; domain?: string }> | string[];
result?: any;
error?: string;
onNodeClick?: (nodeId: number) => void;
}
export default function ToolDisplay({ name, status, context, sources, result, error, onNodeClick }: ToolDisplayProps) {
const isRunning = status === 'starting' || status === 'running';
const [expanded, setExpanded] = useState(false);
const accentColor = '#22c55e';
const bgTint = '#0b0b0b';
const displayName = name;
// Normalize sources to array of objects
const normalizedSources: Array<{ url?: string; domain?: string }> = Array.isArray(sources)
? sources.map((s: any) => (typeof s === 'string' ? { domain: s } : s))
: [];
// Web search simple preview renderer
const renderWebSearchPreview = () => {
const items = result?.data?.results || result?.results || [];
if (!Array.isArray(items) || items.length === 0) return null;
return (
<div style={{ marginTop: 6 }}>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
style={{
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
}}
>
{expanded ? 'Hide results' : `Show results (${Math.min(items.length, 3)})`}
</button>
{expanded ? (
<div style={{ display: 'grid', gap: 6, marginTop: 6 }}>
{items.slice(0, 3).map((r: any, i: number) => (
<div key={i} style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
borderRadius: 6,
padding: '6px 8px',
fontSize: 12,
color: '#cfcfcf'
}}>
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{r.title || 'Result'}</div>
{r.snippet ? <div style={{ color: '#9a9a9a', marginTop: 2 }}>{r.snippet}</div> : null}
{r.url ? (
<div style={{ marginTop: 4 }}>
<a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff', fontSize: 11 }}>
{r.url}
</a>
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
);
};
return (
<div style={{
margin: '8px 0',
padding: '10px 12px',
background: bgTint,
border: '1px solid #2a2a2a',
borderLeft: `2px solid ${accentColor}`,
borderRadius: 6,
fontSize: 12,
color: '#cfcfcf'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
{isRunning ? (
<span style={{
display: 'inline-block', width: 12, height: 12,
border: '2px solid #2a2a2a', borderTopColor: accentColor,
borderRadius: '50%', animation: 'spin 1s linear infinite'
}} />
) : (
<span style={{ color: accentColor, fontSize: 12 }}>{status === 'error' ? '✗' : '✓'}</span>
)}
<span style={{ color: accentColor, fontWeight: 600 }}>{displayName}</span>
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{status}</span>
</div>
{context ? <div style={{ color: '#9adbc2' }}>{context}</div> : null}
{normalizedSources.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{normalizedSources.slice(0, 6).map((s, i) => (
<SourceChip key={i} url={s.url} domain={s.domain} />
))}
</div>
) : null}
{/* Specific previews */}
{name === 'webSearch' ? renderWebSearchPreview() : null}
{name === 'websiteExtract' ? (
<WebsiteExtractSummary result={result} onNodeClick={onNodeClick} />
) : null}
{error ? (
<div style={{ color: '#ff6b6b', marginTop: 6 }}>Error: {error}</div>
) : null}
</div>
);
}
function WebsiteExtractSummary({ result, onNodeClick }: { result?: any; onNodeClick?: (id: number) => void }) {
const data = result?.data || {};
const title = data.title || 'Website added';
const url = data.url || '';
const nodeId = data.nodeId as number | undefined;
return (
<div style={{ marginTop: 8, border: '1px solid #2a2a2a', background: '#0f0f0f', borderRadius: 6, padding: 8 }}>
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{title}</div>
{url ? (
<div style={{ marginTop: 4, fontSize: 11 }}>
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>
{url}
</a>
</div>
) : null}
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
{typeof nodeId === 'number' && onNodeClick ? (
<button
type="button"
onClick={() => onNodeClick(nodeId)}
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
color: '#cfcfcf',
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
cursor: 'pointer'
}}
>
Open node #{nodeId}
</button>
) : null}
{typeof data.contentLength === 'number' ? (
<span style={{ fontSize: 11, color: '#7a7a7a' }}>
content ~{Math.round((data.contentLength as number) / 1000)}k chars
</span>
) : null}
</div>
</div>
);
}