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:
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user