feat: port source-first ingestion to os repo
- make source the canonical field across os routes, tools, ui, and mcp - align fresh-install schema, search, and embedding flows with source-first ingestion Generated with Claude Code
This commit is contained in:
@@ -57,10 +57,8 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
// Give the description generator and UI a readable summary plus a short excerpt.
|
description: `PDF document imported from ${file.name} — ${extraction.metadata.pages} pages of extracted text. Useful as a searchable local source.`,
|
||||||
notes: `Imported PDF document: ${file.name} (${extraction.metadata.pages} pages, ${Math.round(extraction.metadata.text_length / 1000)}k characters).\n\nPreview:\n${extraction.chunk.slice(0, 1200)}`,
|
source: extraction.chunk,
|
||||||
// Full extracted text goes in chunk for universal chunking/embedding
|
|
||||||
chunk: extraction.chunk,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'pdf_upload',
|
source: 'pdf_upload',
|
||||||
original_filename: file.name,
|
original_filename: file.name,
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function createRAHServer(): McpServer {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
description: node.description ?? null,
|
description: node.description ?? null,
|
||||||
notes: node.notes ?? null,
|
source: node.source ?? node.notes ?? null,
|
||||||
link: node.link ?? null,
|
link: node.link ?? null,
|
||||||
dimensions: node.dimensions || [],
|
dimensions: node.dimensions || [],
|
||||||
metadata: node.metadata || {},
|
metadata: node.metadata || {},
|
||||||
@@ -263,21 +263,22 @@ function createRAHServer(): McpServer {
|
|||||||
description: 'Create a new node in the knowledge graph.',
|
description: 'Create a new node in the knowledge graph.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
title: z.string().min(1).max(160).describe('Node title'),
|
title: z.string().min(1).max(160).describe('Node title'),
|
||||||
content: z.string().max(20000).optional().describe('Node content/notes'),
|
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
|
||||||
|
source: z.string().max(50000).optional().describe('Full source text'),
|
||||||
link: z.string().url().optional().describe('Source URL'),
|
link: z.string().url().optional().describe('Source URL'),
|
||||||
description: z.string().max(2000).optional().describe('Short description'),
|
description: z.string().max(2000).optional().describe('Short description'),
|
||||||
dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (at least 1)'),
|
dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (at least 1)'),
|
||||||
metadata: z.record(z.any()).optional().describe('Additional metadata'),
|
metadata: z.record(z.any()).optional().describe('Additional metadata'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ title, content, link, description, dimensions, metadata }) => {
|
async ({ title, content, source, link, description, dimensions, metadata }) => {
|
||||||
// Call the nodes API internally
|
// Call the nodes API internally
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content?.trim(),
|
source: source?.trim() || content?.trim(),
|
||||||
link: link?.trim(),
|
link: link?.trim(),
|
||||||
description: description?.trim(),
|
description: description?.trim(),
|
||||||
dimensions,
|
dimensions,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export async function POST(
|
|||||||
// Generate new description using the description service
|
// Generate new description using the description service
|
||||||
const newDescription = await generateDescription({
|
const newDescription = await generateDescription({
|
||||||
title: node.title,
|
title: node.title,
|
||||||
notes: node.notes || undefined,
|
notes: node.source || node.description || undefined,
|
||||||
link: node.link || undefined,
|
link: node.link || undefined,
|
||||||
metadata: enrichedMetadata,
|
metadata: enrichedMetadata,
|
||||||
|
|
||||||
|
|||||||
@@ -85,13 +85,15 @@ export async function PUT(
|
|||||||
updates.dimensions = normalizeDimensions(body.dimensions, 5);
|
updates.dimensions = normalizeDimensions(body.dimensions, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingChunk = typeof body.chunk === 'string' ? body.chunk : undefined;
|
delete updates.notes;
|
||||||
const incomingNotes = typeof body.notes === 'string' ? body.notes : undefined;
|
delete updates.chunk;
|
||||||
const existingChunk = existingNode.chunk ?? '';
|
|
||||||
|
|
||||||
if (incomingChunk !== undefined) {
|
const incomingSource = typeof body.source === 'string' ? body.source : undefined;
|
||||||
const trimmedIncoming = incomingChunk.trim();
|
const existingSource = existingNode.source ?? '';
|
||||||
const trimmedExisting = existingChunk.trim();
|
|
||||||
|
if (incomingSource !== undefined) {
|
||||||
|
const trimmedIncoming = incomingSource.trim();
|
||||||
|
const trimmedExisting = existingSource.trim();
|
||||||
|
|
||||||
if (!trimmedIncoming) {
|
if (!trimmedIncoming) {
|
||||||
updates.chunk_status = null;
|
updates.chunk_status = null;
|
||||||
@@ -101,10 +103,6 @@ export async function PUT(
|
|||||||
} else {
|
} else {
|
||||||
delete updates.chunk_status;
|
delete updates.chunk_status;
|
||||||
}
|
}
|
||||||
} else if (!existingChunk.trim() && hasSufficientContent(incomingNotes)) {
|
|
||||||
updates.chunk = incomingNotes;
|
|
||||||
updates.chunk_status = 'not_chunked';
|
|
||||||
shouldQueueEmbed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = await nodeService.updateNode(nodeId, updates);
|
const node = await nodeService.updateNode(nodeId, updates);
|
||||||
|
|||||||
+7
-21
@@ -92,15 +92,15 @@ export async function POST(request: NextRequest) {
|
|||||||
// Sanitize title (strip extraction artifacts)
|
// Sanitize title (strip extraction artifacts)
|
||||||
body.title = sanitizeTitle(body.title);
|
body.title = sanitizeTitle(body.title);
|
||||||
|
|
||||||
const rawNotes = typeof body.notes === 'string' ? body.notes : null;
|
const rawSource = typeof body.source === 'string' ? body.source.trim() : null;
|
||||||
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
|
|
||||||
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
|
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
|
||||||
|
|
||||||
// Process provided dimensions first (needed for description generation)
|
// Process provided dimensions first (needed for description generation)
|
||||||
const trimmedProvidedDimensions = normalizeDimensions(body.dimensions, 5);
|
const trimmedProvidedDimensions = normalizeDimensions(body.dimensions, 5);
|
||||||
|
|
||||||
// Use provided description if present, otherwise auto-generate
|
// Use provided description if present, otherwise auto-generate
|
||||||
let nodeDescription: string | undefined = typeof body.description === 'string' && body.description.trim()
|
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
|
||||||
|
let nodeDescription: string | undefined = isUserSuppliedDescription
|
||||||
? body.description.trim().slice(0, 280)
|
? body.description.trim().slice(0, 280)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
nodeDescription = await generateDescription({
|
nodeDescription = await generateDescription({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
notes: rawNotes || rawChunk?.slice(0, 2000) || undefined,
|
notes: rawSource?.slice(0, 2000) || undefined,
|
||||||
link: body.link || undefined,
|
link: body.link || undefined,
|
||||||
metadata: body.metadata,
|
metadata: body.metadata,
|
||||||
dimensions: trimmedProvidedDimensions
|
dimensions: trimmedProvidedDimensions
|
||||||
@@ -127,7 +127,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const descriptionError = validateExplicitDescription(finalDescription);
|
const descriptionError = validateExplicitDescription(finalDescription);
|
||||||
if (descriptionError) {
|
if (descriptionError) {
|
||||||
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
|
|
||||||
if (isUserSuppliedDescription) {
|
if (isUserSuppliedDescription) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -148,33 +147,20 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Use only provided dimensions (no auto-assignment)
|
// Use only provided dimensions (no auto-assignment)
|
||||||
const finalDimensions = trimmedProvidedDimensions;
|
const finalDimensions = trimmedProvidedDimensions;
|
||||||
let chunkToStore = rawChunk;
|
const sourceToStore = rawSource || [body.title, nodeDescription].filter(Boolean).join('\n\n').trim() || null;
|
||||||
let chunkStatus: Node['chunk_status'];
|
let chunkStatus: Node['chunk_status'];
|
||||||
|
|
||||||
if (chunkToStore && chunkToStore.trim().length > 0) {
|
if (sourceToStore && sourceToStore.trim().length > 0) {
|
||||||
chunkStatus = 'not_chunked';
|
chunkStatus = 'not_chunked';
|
||||||
} else {
|
|
||||||
// Build chunk from all available notes if not provided
|
|
||||||
// This ensures every node gets at least one chunk for search
|
|
||||||
const fallbackContent = [body.title, nodeDescription, rawNotes]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (fallbackContent) {
|
|
||||||
chunkToStore = fallbackContent;
|
|
||||||
chunkStatus = 'not_chunked';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = await nodeService.createNode({
|
const node = await nodeService.createNode({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
description: finalDescription,
|
description: finalDescription,
|
||||||
notes: rawNotes ?? undefined,
|
source: sourceToStore ?? undefined,
|
||||||
event_date: eventDate ?? undefined,
|
event_date: eventDate ?? undefined,
|
||||||
link: body.link,
|
link: body.link,
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
chunk: chunkToStore ?? undefined,
|
|
||||||
chunk_status: chunkStatus,
|
chunk_status: chunkStatus,
|
||||||
metadata: body.metadata || {}
|
metadata: body.metadata || {}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ All data stays on this device.`;
|
|||||||
// Tool schemas
|
// Tool schemas
|
||||||
const addNodeInputSchema = {
|
const addNodeInputSchema = {
|
||||||
title: z.string().min(1).max(160).describe('Clear, descriptive title'),
|
title: z.string().min(1).max(160).describe('Clear, descriptive title'),
|
||||||
content: z.string().max(20000).optional().describe('Node content/notes'),
|
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
|
||||||
|
source: z.string().max(50000).optional().describe('Full source text'),
|
||||||
link: z.string().url().optional().describe('Source URL'),
|
link: z.string().url().optional().describe('Source URL'),
|
||||||
description: z.string().min(24).max(280).describe('REQUIRED. One-sentence summary: WHAT this is (explicit, concrete) + WHY it matters. No weak verbs (discusses, explores, examines). Example: "Podcast — Lex Fridman interviews Sam Altman on AGI timelines. First public comments since board drama."'),
|
description: z.string().min(24).max(280).describe('REQUIRED. One-sentence summary: WHAT this is (explicit, concrete) + WHY it matters. No weak verbs (discusses, explores, examines). Example: "Podcast — Lex Fridman interviews Sam Altman on AGI timelines. First public comments since board drama."'),
|
||||||
dimensions: z.array(z.string()).min(1).max(5).describe('1-5 categories. Call queryDimensions first to use existing ones.'),
|
dimensions: z.array(z.string()).min(1).max(5).describe('1-5 categories. Call queryDimensions first to use existing ones.'),
|
||||||
@@ -354,7 +355,7 @@ async function main() {
|
|||||||
// Note: MCP schema uses "content" for external API compat; mapped to "notes" internally
|
// Note: MCP schema uses "content" for external API compat; mapped to "notes" internally
|
||||||
inputSchema: addNodeInputSchema
|
inputSchema: addNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||||
if (normalizedDimensions.length === 0) {
|
if (normalizedDimensions.length === 0) {
|
||||||
throw new Error('At least one dimension is required.');
|
throw new Error('At least one dimension is required.');
|
||||||
@@ -366,12 +367,11 @@ async function main() {
|
|||||||
|
|
||||||
const node = nodeService.createNode({
|
const node = nodeService.createNode({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
notes: content?.trim(),
|
source: source?.trim() || chunk?.trim() || content?.trim(),
|
||||||
link: link?.trim(),
|
link: link?.trim(),
|
||||||
description: description?.trim(),
|
description: description?.trim(),
|
||||||
dimensions: normalizedDimensions,
|
dimensions: normalizedDimensions,
|
||||||
metadata: metadata || {},
|
metadata: metadata || {}
|
||||||
chunk: chunk?.trim()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`;
|
const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`;
|
||||||
@@ -425,7 +425,7 @@ async function main() {
|
|||||||
WITH fts_matches AS (
|
WITH fts_matches AS (
|
||||||
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100
|
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100
|
||||||
)
|
)
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link,
|
SELECT n.id, n.title, n.description, n.source, n.link,
|
||||||
n.created_at, n.updated_at, n.event_date,
|
n.created_at, n.updated_at, n.event_date,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
@@ -446,7 +446,7 @@ async function main() {
|
|||||||
WITH fts_matches AS (
|
WITH fts_matches AS (
|
||||||
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ?
|
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ?
|
||||||
)
|
)
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link,
|
SELECT n.id, n.title, n.description, n.source, n.link,
|
||||||
n.created_at, n.updated_at, n.event_date,
|
n.created_at, n.updated_at, n.event_date,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
@@ -462,7 +462,7 @@ async function main() {
|
|||||||
nodes = rows.map(row => ({
|
nodes = rows.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
notes: row.notes ?? null,
|
source: row.source ?? null,
|
||||||
description: row.description ?? null,
|
description: row.description ?? null,
|
||||||
link: row.link ?? null,
|
link: row.link ?? null,
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||||
@@ -482,7 +482,7 @@ async function main() {
|
|||||||
const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0);
|
const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link,
|
SELECT n.id, n.title, n.description, n.source, n.link,
|
||||||
n.created_at, n.updated_at, n.event_date,
|
n.created_at, n.updated_at, n.event_date,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
@@ -492,7 +492,7 @@ async function main() {
|
|||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
|
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
params.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
params.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +518,7 @@ async function main() {
|
|||||||
nodes = rows.map(row => ({
|
nodes = rows.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
notes: row.notes ?? null,
|
source: row.source ?? null,
|
||||||
description: row.description ?? null,
|
description: row.description ?? null,
|
||||||
link: row.link ?? null,
|
link: row.link ?? null,
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||||
@@ -560,13 +560,13 @@ async function main() {
|
|||||||
for (const id of uniqueIds) {
|
for (const id of uniqueIds) {
|
||||||
const node = nodeService.getNodeById(id);
|
const node = nodeService.getNodeById(id);
|
||||||
if (node) {
|
if (node) {
|
||||||
const rawChunk = node.chunk ?? null;
|
const rawChunk = node.source ?? node.chunk ?? null;
|
||||||
const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false;
|
const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false;
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
notes: node.notes ?? null,
|
source: node.source ?? node.notes ?? null,
|
||||||
description: node.description ?? null,
|
description: node.description ?? null,
|
||||||
link: node.link ?? null,
|
link: node.link ?? null,
|
||||||
chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk,
|
chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk,
|
||||||
@@ -610,14 +610,18 @@ async function main() {
|
|||||||
throw new Error(descriptionError);
|
throw new Error(descriptionError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map MCP 'content' field → internal 'notes' field
|
// Map MCP legacy fields to canonical source
|
||||||
const mappedUpdates = { ...updates };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
if (mappedUpdates.content !== undefined) {
|
||||||
mappedUpdates.notes = mappedUpdates.content;
|
mappedUpdates.source = mappedUpdates.content;
|
||||||
delete mappedUpdates.content;
|
|
||||||
}
|
}
|
||||||
|
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||||
|
mappedUpdates.source = mappedUpdates.chunk;
|
||||||
|
}
|
||||||
|
delete mappedUpdates.content;
|
||||||
|
delete mappedUpdates.chunk;
|
||||||
|
|
||||||
const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: true });
|
const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: false });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Updated node #${id}` }],
|
content: [{ type: 'text', text: `Updated node #${id}` }],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function getNodes(filters = {}) {
|
|||||||
const { dimensions, search, limit = 100, offset = 0 } = filters;
|
const { dimensions, search, limit = 100, offset = 0 } = filters;
|
||||||
|
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
@@ -30,7 +30,7 @@ function getNodes(filters = {}) {
|
|||||||
|
|
||||||
// Text search
|
// Text search
|
||||||
if (search) {
|
if (search) {
|
||||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
|
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ function getNodes(filters = {}) {
|
|||||||
*/
|
*/
|
||||||
function getNodeById(id) {
|
function getNodeById(id) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
@@ -108,11 +108,10 @@ function createNode(nodeData) {
|
|||||||
const {
|
const {
|
||||||
title: rawTitle,
|
title: rawTitle,
|
||||||
description,
|
description,
|
||||||
notes,
|
source,
|
||||||
link,
|
link,
|
||||||
event_date,
|
event_date,
|
||||||
dimensions = [],
|
dimensions = [],
|
||||||
chunk,
|
|
||||||
metadata = {}
|
metadata = {}
|
||||||
} = nodeData;
|
} = nodeData;
|
||||||
|
|
||||||
@@ -121,30 +120,23 @@ function createNode(nodeData) {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Build chunk fallback from available fields when no explicit chunk provided
|
const sourceToStore = source && source.trim()
|
||||||
let chunkToStore = chunk ?? null;
|
? source
|
||||||
if (!chunkToStore || !chunkToStore.trim()) {
|
: [title, description].filter(Boolean).join('\n\n').trim() || null;
|
||||||
const fallbackParts = [title, description, notes].filter(Boolean);
|
|
||||||
const fallback = fallbackParts.join('\n\n').trim();
|
|
||||||
if (fallback) {
|
|
||||||
chunkToStore = fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeId = transaction(() => {
|
const nodeId = transaction(() => {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO nodes (title, description, notes, link, event_date, metadata, chunk, created_at, updated_at)
|
INSERT INTO nodes (title, description, source, link, event_date, metadata, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
title,
|
title,
|
||||||
description ?? null,
|
description ?? null,
|
||||||
notes ?? null,
|
sourceToStore,
|
||||||
link ?? null,
|
link ?? null,
|
||||||
event_date ?? null,
|
event_date ?? null,
|
||||||
JSON.stringify(metadata),
|
JSON.stringify(metadata),
|
||||||
chunkToStore,
|
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -169,11 +161,11 @@ function createNode(nodeData) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing node.
|
* Update an existing node.
|
||||||
* Note: notes is APPENDED by default (MCP tool behavior), not replaced.
|
* Source-first update path.
|
||||||
*/
|
*/
|
||||||
function updateNode(id, updates, options = {}) {
|
function updateNode(id, updates, options = {}) {
|
||||||
const { appendNotes = true } = options;
|
const { appendNotes = true } = options;
|
||||||
const { title, description, notes, link, event_date, dimensions, chunk, metadata } = updates;
|
const { title, description, source, notes, link, event_date, dimensions, chunk, metadata } = updates;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -195,15 +187,15 @@ function updateNode(id, updates, options = {}) {
|
|||||||
setFields.push('description = ?');
|
setFields.push('description = ?');
|
||||||
params.push(description);
|
params.push(description);
|
||||||
}
|
}
|
||||||
if (notes !== undefined) {
|
if (source !== undefined) {
|
||||||
if (appendNotes && existing.notes) {
|
setFields.push('source = ?');
|
||||||
// Append to existing notes
|
params.push(source);
|
||||||
setFields.push('notes = ?');
|
} else if (notes !== undefined) {
|
||||||
params.push(existing.notes + '\n\n' + notes);
|
const nextSource = appendNotes && existing.source
|
||||||
} else {
|
? `${existing.source}\n\n${notes}`
|
||||||
setFields.push('notes = ?');
|
: notes;
|
||||||
params.push(notes);
|
setFields.push('source = ?');
|
||||||
}
|
params.push(nextSource);
|
||||||
}
|
}
|
||||||
if (link !== undefined) {
|
if (link !== undefined) {
|
||||||
setFields.push('link = ?');
|
setFields.push('link = ?');
|
||||||
@@ -213,8 +205,8 @@ function updateNode(id, updates, options = {}) {
|
|||||||
setFields.push('event_date = ?');
|
setFields.push('event_date = ?');
|
||||||
params.push(event_date);
|
params.push(event_date);
|
||||||
}
|
}
|
||||||
if (chunk !== undefined) {
|
if (chunk !== undefined && source === undefined) {
|
||||||
setFields.push('chunk = ?');
|
setFields.push('source = ?');
|
||||||
params.push(chunk);
|
params.push(chunk);
|
||||||
}
|
}
|
||||||
if (metadata !== undefined) {
|
if (metadata !== undefined) {
|
||||||
|
|||||||
+15
-11
@@ -86,6 +86,7 @@ const sanitizeDimensions = (raw) => {
|
|||||||
const addNodeInputSchema = {
|
const addNodeInputSchema = {
|
||||||
title: z.string().min(1).max(160),
|
title: z.string().min(1).max(160),
|
||||||
content: z.string().max(20000).optional(),
|
content: z.string().max(20000).optional(),
|
||||||
|
source: z.string().max(50000).optional(),
|
||||||
link: z.string().url().optional(),
|
link: z.string().url().optional(),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
dimensions: z.array(z.string()).min(1).max(5),
|
dimensions: z.array(z.string()).min(1).max(5),
|
||||||
@@ -112,7 +113,7 @@ const searchNodesOutputSchema = {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
notes: z.string().nullable(),
|
source: z.string().nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
link: z.string().nullable(),
|
link: z.string().nullable(),
|
||||||
dimensions: z.array(z.string()),
|
dimensions: z.array(z.string()),
|
||||||
@@ -151,7 +152,7 @@ const getNodesOutputSchema = {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
notes: z.string().nullable(),
|
source: z.string().nullable(),
|
||||||
link: z.string().nullable(),
|
link: z.string().nullable(),
|
||||||
dimensions: z.array(z.string()),
|
dimensions: z.array(z.string()),
|
||||||
updated_at: z.string()
|
updated_at: z.string()
|
||||||
@@ -349,7 +350,7 @@ mcpServer.registerTool(
|
|||||||
inputSchema: addNodeInputSchema,
|
inputSchema: addNodeInputSchema,
|
||||||
outputSchema: addNodeOutputSchema
|
outputSchema: addNodeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||||
if (normalizedDimensions.length === 0) {
|
if (normalizedDimensions.length === 0) {
|
||||||
throw new McpError(
|
throw new McpError(
|
||||||
@@ -360,12 +361,11 @@ mcpServer.registerTool(
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
notes: content?.trim() || undefined,
|
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
|
||||||
link: link?.trim() || undefined,
|
link: link?.trim() || undefined,
|
||||||
description: description?.trim() || undefined,
|
description: description?.trim() || undefined,
|
||||||
dimensions: normalizedDimensions,
|
dimensions: normalizedDimensions,
|
||||||
metadata: metadata || {},
|
metadata: metadata || {}
|
||||||
chunk: chunk?.trim() || undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await callRaHApi('/api/nodes', {
|
const result = await callRaHApi('/api/nodes', {
|
||||||
@@ -422,7 +422,7 @@ mcpServer.registerTool(
|
|||||||
nodes: nodes.map((node) => ({
|
nodes: nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
notes: node.notes ?? null,
|
source: node.source ?? node.notes ?? null,
|
||||||
description: node.description ?? null,
|
description: node.description ?? null,
|
||||||
link: node.link ?? null,
|
link: node.link ?? null,
|
||||||
dimensions: node.dimensions || [],
|
dimensions: node.dimensions || [],
|
||||||
@@ -446,12 +446,16 @@ mcpServer.registerTool(
|
|||||||
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
|
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map MCP 'content' field → internal 'notes' field
|
// Map MCP legacy fields to canonical source
|
||||||
const mappedUpdates = { ...updates };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
if (mappedUpdates.content !== undefined) {
|
||||||
mappedUpdates.notes = mappedUpdates.content;
|
mappedUpdates.source = mappedUpdates.content;
|
||||||
delete mappedUpdates.content;
|
|
||||||
}
|
}
|
||||||
|
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||||
|
mappedUpdates.source = mappedUpdates.chunk;
|
||||||
|
}
|
||||||
|
delete mappedUpdates.content;
|
||||||
|
delete mappedUpdates.chunk;
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -492,7 +496,7 @@ mcpServer.registerTool(
|
|||||||
nodes.push({
|
nodes.push({
|
||||||
id: result.node.id,
|
id: result.node.id,
|
||||||
title: result.node.title,
|
title: result.node.title,
|
||||||
notes: result.node.notes ?? null,
|
source: result.node.source ?? result.node.notes ?? null,
|
||||||
link: result.node.link ?? null,
|
link: result.node.link ?? null,
|
||||||
dimensions: result.node.dimensions || [],
|
dimensions: result.node.dimensions || [],
|
||||||
updated_at: result.node.updated_at
|
updated_at: result.node.updated_at
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const STATUS_PATH = path.join(
|
|||||||
const addNodeInputSchema = {
|
const addNodeInputSchema = {
|
||||||
title: z.string().min(1).max(160),
|
title: z.string().min(1).max(160),
|
||||||
content: z.string().max(20000).optional(),
|
content: z.string().max(20000).optional(),
|
||||||
|
source: z.string().max(50000).optional(),
|
||||||
link: z.string().url().optional(),
|
link: z.string().url().optional(),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
dimensions: z.array(z.string()).min(1).max(5),
|
dimensions: z.array(z.string()).min(1).max(5),
|
||||||
@@ -322,7 +323,7 @@ server.registerTool(
|
|||||||
inputSchema: addNodeInputSchema,
|
inputSchema: addNodeInputSchema,
|
||||||
outputSchema: addNodeOutputSchema
|
outputSchema: addNodeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||||
if (normalizedDimensions.length === 0) {
|
if (normalizedDimensions.length === 0) {
|
||||||
throw new Error('At least one dimension/tag is required when creating a node.');
|
throw new Error('At least one dimension/tag is required when creating a node.');
|
||||||
@@ -330,12 +331,11 @@ server.registerTool(
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
notes: content?.trim() || undefined,
|
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
|
||||||
link: link?.trim() || undefined,
|
link: link?.trim() || undefined,
|
||||||
description: description?.trim() || undefined,
|
description: description?.trim() || undefined,
|
||||||
dimensions: normalizedDimensions,
|
dimensions: normalizedDimensions,
|
||||||
metadata: metadata || {},
|
metadata: metadata || {}
|
||||||
chunk: chunk?.trim() || undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await callRaHApi('/api/nodes', {
|
const result = await callRaHApi('/api/nodes', {
|
||||||
@@ -393,7 +393,7 @@ server.registerTool(
|
|||||||
nodes: nodes.map((node) => ({
|
nodes: nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
notes: node.notes ?? null,
|
source: node.source ?? node.notes ?? null,
|
||||||
description: node.description ?? null,
|
description: node.description ?? null,
|
||||||
link: node.link ?? null,
|
link: node.link ?? null,
|
||||||
dimensions: node.dimensions || [],
|
dimensions: node.dimensions || [],
|
||||||
@@ -417,12 +417,16 @@ server.registerTool(
|
|||||||
throw new Error('At least one field must be provided in updates.');
|
throw new Error('At least one field must be provided in updates.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map MCP 'content' field → internal 'notes' field
|
// Map MCP legacy fields to canonical source
|
||||||
const mappedUpdates = { ...updates };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
if (mappedUpdates.content !== undefined) {
|
||||||
mappedUpdates.notes = mappedUpdates.content;
|
mappedUpdates.source = mappedUpdates.content;
|
||||||
delete mappedUpdates.content;
|
|
||||||
}
|
}
|
||||||
|
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||||
|
mappedUpdates.source = mappedUpdates.chunk;
|
||||||
|
}
|
||||||
|
delete mappedUpdates.content;
|
||||||
|
delete mappedUpdates.chunk;
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -463,7 +467,7 @@ server.registerTool(
|
|||||||
nodes.push({
|
nodes.push({
|
||||||
id: result.node.id,
|
id: result.node.id,
|
||||||
title: result.node.title,
|
title: result.node.title,
|
||||||
notes: result.node.notes ?? null,
|
source: result.node.source ?? result.node.notes ?? null,
|
||||||
link: result.node.link ?? null,
|
link: result.node.link ?? null,
|
||||||
dimensions: result.node.dimensions || [],
|
dimensions: result.node.dimensions || [],
|
||||||
updated_at: result.node.updated_at
|
updated_at: result.node.updated_at
|
||||||
|
|||||||
+39
-31
@@ -1,5 +1,7 @@
|
|||||||
# Database Schema
|
# Database Schema
|
||||||
|
|
||||||
|
This page describes the database as it exists on disk, including some legacy tables and columns that remain for compatibility and historical records. The current shipped product uses a single RA-H assistant; old delegation-era fields should not be read as active UX concepts.
|
||||||
|
|
||||||
## Entity Relationship Diagram
|
## Entity Relationship Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -8,13 +10,16 @@ erDiagram
|
|||||||
nodes ||--o{ edges : "from"
|
nodes ||--o{ edges : "from"
|
||||||
nodes ||--o{ edges : "to"
|
nodes ||--o{ edges : "to"
|
||||||
nodes ||--o{ chunks : "contains"
|
nodes ||--o{ chunks : "contains"
|
||||||
|
nodes ||--o{ chats : "focused_on"
|
||||||
dimensions ||--o{ node_dimensions : "tagged_with"
|
dimensions ||--o{ node_dimensions : "tagged_with"
|
||||||
|
chats }o--|| agent_delegations : "belongs_to"
|
||||||
|
|
||||||
nodes {
|
nodes {
|
||||||
INTEGER id PK
|
INTEGER id PK
|
||||||
TEXT title
|
TEXT title
|
||||||
TEXT content
|
TEXT source
|
||||||
TEXT type
|
TEXT description
|
||||||
|
TEXT event_date
|
||||||
BLOB embedding
|
BLOB embedding
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ erDiagram
|
|||||||
dimensions {
|
dimensions {
|
||||||
TEXT name PK
|
TEXT name PK
|
||||||
INTEGER is_priority
|
INTEGER is_priority
|
||||||
|
TEXT icon
|
||||||
}
|
}
|
||||||
|
|
||||||
node_dimensions {
|
node_dimensions {
|
||||||
@@ -41,6 +47,19 @@ erDiagram
|
|||||||
INTEGER node_id FK
|
INTEGER node_id FK
|
||||||
TEXT text
|
TEXT text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chats {
|
||||||
|
INTEGER id PK
|
||||||
|
INTEGER focused_node_id FK
|
||||||
|
TEXT user_message
|
||||||
|
TEXT assistant_message
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_delegations {
|
||||||
|
INTEGER id PK
|
||||||
|
TEXT task
|
||||||
|
TEXT status
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,25 +99,24 @@ Primary knowledge storage. Each row is a discrete knowledge item.
|
|||||||
**Columns:**
|
**Columns:**
|
||||||
- `id` (INTEGER PK) - Unique identifier
|
- `id` (INTEGER PK) - Unique identifier
|
||||||
- `title` (TEXT) - Node title
|
- `title` (TEXT) - Node title
|
||||||
- `content` (TEXT) - Full content
|
- `description` (TEXT) - WHAT this is + WHY it matters (primary identity field)
|
||||||
- `type` (TEXT) - Node type (memory, paper, idea, person, etc)
|
- `source` (TEXT) - Canonical source content used for chunking and embedding
|
||||||
- `link` (TEXT) - External source URL (only for source nodes, not derived ideas)
|
- `link` (TEXT) - External source URL (only for source nodes, not derived ideas)
|
||||||
- `description` (TEXT) - Brief summary
|
- `event_date` (TEXT) - When the thing actually happened (vs `created_at` = when it entered the graph)
|
||||||
- `metadata` (TEXT) - JSON metadata
|
- `metadata` (TEXT) - JSON metadata
|
||||||
- `chunk` (TEXT) - Source text for chunking
|
|
||||||
- `chunk_status` (TEXT) - Chunking status (not_chunked, chunked)
|
- `chunk_status` (TEXT) - Chunking status (not_chunked, chunked)
|
||||||
- `embedding` (BLOB) - Node-level embedding vector
|
- `embedding` (BLOB) - Node-level embedding vector
|
||||||
- `embedding_text` (TEXT) - Text that was embedded
|
- `embedding_text` (TEXT) - Text that was embedded
|
||||||
- `embedding_updated_at` (TEXT) - Embedding timestamp
|
- `embedding_updated_at` (TEXT) - Embedding timestamp
|
||||||
- `is_pinned` (INTEGER) - Legacy pin flag (kept for migration; not surfaced in UI)
|
|
||||||
- `created_at`, `updated_at` (TEXT) - Timestamps
|
- `created_at`, `updated_at` (TEXT) - Timestamps
|
||||||
|
|
||||||
**Indexes:**
|
**Temporal dimensions:** Each node has three timestamps:
|
||||||
- `idx_nodes_type` - Fast type filtering
|
- `created_at` - When the node entered the graph (transaction time)
|
||||||
- `idx_nodes_pinned` - Legacy partial index (no longer recreated, safe to drop later)
|
- `updated_at` - When the node was last modified
|
||||||
|
- `event_date` - When the thing actually happened (valid time)
|
||||||
|
|
||||||
**FTS:**
|
**FTS:**
|
||||||
- `nodes_fts` - Full-text search on title + content
|
- `nodes_fts` - Full-text search on title + source + description
|
||||||
|
|
||||||
### edges
|
### edges
|
||||||
Directed relationships between nodes (knowledge graph).
|
Directed relationships between nodes (knowledge graph).
|
||||||
@@ -117,7 +135,6 @@ Directed relationships between nodes (knowledge graph).
|
|||||||
- `created_at` (TEXT)
|
- `created_at` (TEXT)
|
||||||
- `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below)
|
- `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below)
|
||||||
- `explanation` (TEXT) — legacy column (currently not the canonical source of truth)
|
- `explanation` (TEXT) — legacy column (currently not the canonical source of truth)
|
||||||
- `user_feedback` (INTEGER) — user rating (not currently used in core flows)
|
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
- `idx_edges_from` — fast “outgoing edges” queries
|
- `idx_edges_from` — fast “outgoing edges” queries
|
||||||
@@ -200,7 +217,8 @@ Master list of categorization tags.
|
|||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
- `name` (TEXT PK) - Dimension name
|
- `name` (TEXT PK) - Dimension name
|
||||||
- `is_priority` (INTEGER) - Priority dimension flag
|
- `is_priority` (INTEGER) - Legacy compatibility field retained in schema
|
||||||
|
- `icon` (TEXT) - Icon identifier (persisted in database)
|
||||||
- `updated_at` (TEXT)
|
- `updated_at` (TEXT)
|
||||||
|
|
||||||
### node_dimensions
|
### node_dimensions
|
||||||
@@ -218,12 +236,14 @@ Many-to-many junction table (nodes ↔ dimensions).
|
|||||||
### chats
|
### chats
|
||||||
Conversation history with token/cost tracking.
|
Conversation history with token/cost tracking.
|
||||||
|
|
||||||
|
The chat schema still carries some legacy multi-agent fields. Current product framing is simpler: one RA-H assistant, with these columns mainly retained for older records, analytics, and backwards compatibility.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
- `id` (INTEGER PK)
|
- `id` (INTEGER PK)
|
||||||
- `chat_type` (TEXT) - Conversation type
|
- `chat_type` (TEXT) - Conversation type
|
||||||
- `helper_name` (TEXT) - Agent key (ra-h, ra-h-easy, mini-rah, wise-rah)
|
- `helper_name` (TEXT) - Legacy runtime label; current app sessions use `ra-h`
|
||||||
- `agent_type` (TEXT) - Role category (orchestrator, executor, planner)
|
- `agent_type` (TEXT) - Legacy role field retained for historical rows/analytics
|
||||||
- `delegation_id` (INTEGER FK)
|
- `delegation_id` (INTEGER FK) - Legacy link to delegation records
|
||||||
- `user_message` (TEXT)
|
- `user_message` (TEXT)
|
||||||
- `assistant_message` (TEXT)
|
- `assistant_message` (TEXT)
|
||||||
- `thread_id` (TEXT) - Conversation thread
|
- `thread_id` (TEXT) - Conversation thread
|
||||||
@@ -235,7 +255,7 @@ Conversation history with token/cost tracking.
|
|||||||
- `idx_chats_thread` - Fast thread retrieval
|
- `idx_chats_thread` - Fast thread retrieval
|
||||||
|
|
||||||
### agent_delegations
|
### agent_delegations
|
||||||
Task queue for mini-rah workers.
|
Legacy delegation queue from the older multi-agent runtime. Retained so old rows and analytics remain readable; not an active user-facing feature in the current product.
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
- `id` (INTEGER PK)
|
- `id` (INTEGER PK)
|
||||||
@@ -248,18 +268,6 @@ Task queue for mini-rah workers.
|
|||||||
- `summary` (TEXT) - Result summary
|
- `summary` (TEXT) - Result summary
|
||||||
- `created_at`, `updated_at` (TEXT)
|
- `created_at`, `updated_at` (TEXT)
|
||||||
|
|
||||||
### chat_memory_state
|
|
||||||
Checkpoint tracker for memory pipeline.
|
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `thread_id` (TEXT PK)
|
|
||||||
- `helper_name` (TEXT) - Agent that owns the thread
|
|
||||||
- `last_processed_chat_id` (INTEGER) - Last chat processed
|
|
||||||
- `last_processed_at` (TEXT)
|
|
||||||
|
|
||||||
**Indexes:**
|
|
||||||
- `idx_chat_memory_thread` - Fast state lookup
|
|
||||||
|
|
||||||
### logs
|
### logs
|
||||||
Activity audit trail (auto-pruned to last 10k).
|
Activity audit trail (auto-pruned to last 10k).
|
||||||
|
|
||||||
@@ -316,8 +324,8 @@ Nodes with dimensions aggregated as JSON array.
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT
|
SELECT
|
||||||
n.id, n.title, n.content, n.link, n.metadata, n.chunk,
|
n.id, n.title, n.description, n.source, n.link, n.metadata,
|
||||||
n.created_at, n.updated_at,
|
n.event_date, n.created_at, n.updated_at,
|
||||||
COALESCE(JSON_GROUP_ARRAY(d.dimension), '[]') AS dimensions_json
|
COALESCE(JSON_GROUP_ARRAY(d.dimension), '[]') AS dimensions_json
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
LEFT JOIN node_dimensions d ON d.node_id = n.id
|
LEFT JOIN node_dimensions d ON d.node_id = n.id
|
||||||
|
|||||||
@@ -118,13 +118,12 @@ CREATE TABLE nodes (
|
|||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
notes TEXT,
|
source TEXT,
|
||||||
link TEXT,
|
link TEXT,
|
||||||
event_date TEXT,
|
event_date TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
chunk TEXT,
|
|
||||||
embedding BLOB,
|
embedding BLOB,
|
||||||
embedding_updated_at TEXT,
|
embedding_updated_at TEXT,
|
||||||
embedding_text TEXT,
|
embedding_text TEXT,
|
||||||
@@ -281,9 +280,9 @@ if has_table nodes; then
|
|||||||
echo "Adding nodes.metadata"
|
echo "Adding nodes.metadata"
|
||||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN metadata TEXT;"
|
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN metadata TEXT;"
|
||||||
fi
|
fi
|
||||||
if ! has_col nodes chunk; then
|
if ! has_col nodes source; then
|
||||||
echo "Adding nodes.chunk"
|
echo "Adding nodes.source"
|
||||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN chunk TEXT;"
|
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN source TEXT;"
|
||||||
fi
|
fi
|
||||||
# is_pinned removed in final schema pass
|
# is_pinned removed in final schema pass
|
||||||
fi
|
fi
|
||||||
|
|||||||
+132
-139
@@ -9,10 +9,10 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
|
|||||||
import { Node, NodeConnection, Chunk } from '@/types/database';
|
import { Node, NodeConnection, Chunk } from '@/types/database';
|
||||||
import DimensionTags from './dimensions/DimensionTags';
|
import DimensionTags from './dimensions/DimensionTags';
|
||||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||||
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
|
||||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||||
import ConfirmDialog from '../common/ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import { SourceReader } from './source';
|
import { SourceReader } from './source';
|
||||||
|
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
||||||
|
|
||||||
|
|
||||||
interface NodeSearchResult {
|
interface NodeSearchResult {
|
||||||
@@ -36,8 +36,8 @@ interface FocusPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
|
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
|
||||||
const { dimensionIcons } = useDimensionIcons();
|
|
||||||
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
||||||
|
const { dimensionIcons } = useDimensionIcons();
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
|
||||||
@@ -97,10 +97,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({});
|
const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({});
|
||||||
|
|
||||||
// Edges expand/collapse state
|
// Edges expand/collapse state
|
||||||
// edgesExpanded removed — all connections shown in Edges tab
|
const [edgesExpanded, setEdgesExpanded] = useState<{ [key: number]: boolean }>({});
|
||||||
|
|
||||||
// Connections section collapsed state (default closed)
|
|
||||||
const [edgeSearchOpen, setEdgeSearchOpen] = useState(false);
|
|
||||||
|
|
||||||
// Title expanded state for click-to-expand full title
|
// Title expanded state for click-to-expand full title
|
||||||
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
|
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
|
||||||
@@ -108,7 +105,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
// Description regeneration state
|
// Description regeneration state
|
||||||
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
|
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
|
||||||
|
|
||||||
// Content tab state: 'notes', 'desc', or 'source'
|
// Content tab state: 'desc', 'notes', 'edges', or 'source'
|
||||||
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc');
|
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc');
|
||||||
|
|
||||||
// Desc (description) edit mode state
|
// Desc (description) edit mode state
|
||||||
@@ -135,6 +132,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
|
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
|
||||||
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
|
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// Edge creation search toggle
|
||||||
|
const [edgeSearchOpen, setEdgeSearchOpen] = useState(false);
|
||||||
|
|
||||||
// Helper: preview edge type based on heuristics (mirrors backend logic)
|
// Helper: preview edge type based on heuristics (mirrors backend logic)
|
||||||
const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
|
const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
|
||||||
const norm = (explanation || '').trim().toLowerCase();
|
const norm = (explanation || '').trim().toLowerCase();
|
||||||
@@ -230,13 +230,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setEditingField(null);
|
setEditingField(null);
|
||||||
setEditingValue('');
|
setEditingValue('');
|
||||||
setEditingNodeId(null);
|
setEditingNodeId(null);
|
||||||
// Also clear notes/desc/source edit modes
|
// Also clear notes/desc/source/edges edit modes
|
||||||
setNotesEditMode(false);
|
setNotesEditMode(false);
|
||||||
setNotesEditValue('');
|
setNotesEditValue('');
|
||||||
setDescEditMode(false);
|
setDescEditMode(false);
|
||||||
setDescEditValue('');
|
setDescEditValue('');
|
||||||
setSourceEditMode(false);
|
setSourceEditMode(false);
|
||||||
setSourceEditValue('');
|
setSourceEditValue('');
|
||||||
|
setEdgeSearchOpen(false);
|
||||||
|
setNodeSearchQuery('');
|
||||||
|
setNodeSearchSuggestions([]);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const fetchNodeData = async (id: number) => {
|
const fetchNodeData = async (id: number) => {
|
||||||
@@ -356,7 +359,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const updateData: Record<string, string> = {};
|
const updateData: Record<string, string> = {};
|
||||||
|
|
||||||
if (editingField === 'notes') {
|
if (editingField === 'notes') {
|
||||||
updateData.content = editingValue;
|
updateData.notes = editingValue;
|
||||||
} else {
|
} else {
|
||||||
updateData[editingField] = editingValue;
|
updateData[editingField] = editingValue;
|
||||||
}
|
}
|
||||||
@@ -378,7 +381,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety net: ensure edges exist for any tokens present in saved content
|
// Safety net: ensure edges exist for any tokens present in saved notes
|
||||||
if (editingField === 'notes' && typeof editingValue === 'string') {
|
if (editingField === 'notes' && typeof editingValue === 'string') {
|
||||||
try {
|
try {
|
||||||
const tokens = parseNodeMarkers(editingValue);
|
const tokens = parseNodeMarkers(editingValue);
|
||||||
@@ -421,7 +424,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const response = await fetch(`/api/nodes/${nodeId}`, {
|
const response = await fetch(`/api/nodes/${nodeId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ notes: contentValue }),
|
body: JSON.stringify({ content: contentValue }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to save');
|
if (!response.ok) throw new Error('Failed to save');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -497,16 +500,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setDescEditMode(true);
|
setDescEditMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync description to source (chunk) and re-embed
|
// Sync description to source and re-embed
|
||||||
const syncDescToSource = async () => {
|
const syncDescToSource = async () => {
|
||||||
if (!activeTab) return;
|
if (!activeTab) return;
|
||||||
setDescSaving(true);
|
setDescSaving(true);
|
||||||
try {
|
try {
|
||||||
// Save description to chunk field
|
// Save description to source field
|
||||||
const response = await fetch(`/api/nodes/${activeTab}`, {
|
const response = await fetch(`/api/nodes/${activeTab}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ chunk: descEditValue }),
|
body: JSON.stringify({ source: descEditValue }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to sync');
|
if (!response.ok) throw new Error('Failed to sync');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -533,7 +536,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save notes (content) with explicit Save button
|
// Save notes-tab content into canonical source
|
||||||
const saveNotes = async () => {
|
const saveNotes = async () => {
|
||||||
if (!activeTab) return;
|
if (!activeTab) return;
|
||||||
setNotesSaving(true);
|
setNotesSaving(true);
|
||||||
@@ -541,7 +544,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const response = await fetch(`/api/nodes/${activeTab}`, {
|
const response = await fetch(`/api/nodes/${activeTab}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ notes: notesEditValue }),
|
body: JSON.stringify({ source: notesEditValue }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to save');
|
if (!response.ok) throw new Error('Failed to save');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -571,8 +574,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setNotesEditMode(false);
|
setNotesEditMode(false);
|
||||||
setNotesEditValue('');
|
setNotesEditValue('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error saving notes:', e);
|
console.error('Error saving source content:', e);
|
||||||
alert('Failed to save notes. Please try again.');
|
alert('Failed to save content. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setNotesSaving(false);
|
setNotesSaving(false);
|
||||||
}
|
}
|
||||||
@@ -587,11 +590,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
// Start editing notes
|
// Start editing notes
|
||||||
const startNotesEdit = () => {
|
const startNotesEdit = () => {
|
||||||
if (!activeTab || !nodesData[activeTab]) return;
|
if (!activeTab || !nodesData[activeTab]) return;
|
||||||
setNotesEditValue(nodesData[activeTab].notes || '');
|
setNotesEditValue(nodesData[activeTab].source || nodesData[activeTab].notes || '');
|
||||||
setNotesEditMode(true);
|
setNotesEditMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save source (chunk) with explicit Save button
|
// Save source with explicit Save button
|
||||||
const saveSource = async () => {
|
const saveSource = async () => {
|
||||||
if (!activeTab) return;
|
if (!activeTab) return;
|
||||||
setSourceSaving(true);
|
setSourceSaving(true);
|
||||||
@@ -599,7 +602,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const response = await fetch(`/api/nodes/${activeTab}`, {
|
const response = await fetch(`/api/nodes/${activeTab}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ chunk: sourceEditValue }),
|
body: JSON.stringify({ source: sourceEditValue }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to save');
|
if (!response.ok) throw new Error('Failed to save');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -625,7 +628,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
// Start editing source
|
// Start editing source
|
||||||
const startSourceEdit = () => {
|
const startSourceEdit = () => {
|
||||||
if (!activeTab || !nodesData[activeTab]) return;
|
if (!activeTab || !nodesData[activeTab]) return;
|
||||||
setSourceEditValue(nodesData[activeTab].chunk || '');
|
setSourceEditValue(nodesData[activeTab].source || nodesData[activeTab].chunk || '');
|
||||||
setSourceEditMode(true);
|
setSourceEditMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -641,11 +644,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
setShowSyncConfirm(false);
|
setShowSyncConfirm(false);
|
||||||
try {
|
try {
|
||||||
// First, save notes content to chunk field
|
// First, save notes content to source field
|
||||||
const response = await fetch(`/api/nodes/${activeTab}`, {
|
const response = await fetch(`/api/nodes/${activeTab}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ chunk: notesEditValue }),
|
body: JSON.stringify({ source: notesEditValue }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to sync');
|
if (!response.ok) throw new Error('Failed to sync');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -997,14 +1000,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const embedContent = async (nodeId: number) => {
|
const embedContent = async (nodeId: number) => {
|
||||||
const node = nodesData[nodeId];
|
const node = nodesData[nodeId];
|
||||||
const hasNotes = node?.notes?.trim();
|
const hasNotes = node?.notes?.trim();
|
||||||
const hasChunk = node?.chunk?.trim();
|
const hasSource = (node?.source || node?.chunk)?.trim();
|
||||||
// If chunk is empty but content exists, auto-populate chunk from content
|
// If source is empty but notes exist, auto-populate source from notes
|
||||||
if (!hasChunk && hasNotes) {
|
if (!hasSource && hasNotes) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/nodes/${nodeId}`, {
|
const response = await fetch(`/api/nodes/${nodeId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ chunk: hasNotes })
|
body: JSON.stringify({ source: hasNotes })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1014,13 +1017,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to auto-populate chunk for embedding:', error);
|
console.error('Failed to auto-populate source for embedding:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If neither content nor chunk exist, require content
|
// If neither notes nor source exist, require notes
|
||||||
if (!hasNotes && !hasChunk) {
|
if (!hasNotes && !hasSource) {
|
||||||
startEdit('content', '');
|
startEdit('notes', '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEmbeddingNode(nodeId);
|
setEmbeddingNode(nodeId);
|
||||||
@@ -1119,7 +1122,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setAddingEdge(null);
|
setAddingEdge(null);
|
||||||
setEdgeExplanation('');
|
setEdgeExplanation('');
|
||||||
setPendingEdgeTarget(null);
|
setPendingEdgeTarget(null);
|
||||||
setEdgeSearchOpen(false);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating edge:', error);
|
console.error('Error creating edge:', error);
|
||||||
@@ -1157,7 +1159,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setPendingEdgeTarget(null);
|
setPendingEdgeTarget(null);
|
||||||
setNodeSearchQuery('');
|
setNodeSearchQuery('');
|
||||||
setNodeSearchSuggestions([]);
|
setNodeSearchSuggestions([]);
|
||||||
setEdgeSearchOpen(false);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating edge:', error);
|
console.error('Error creating edge:', error);
|
||||||
@@ -1277,63 +1278,55 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
maxHeight: '200px',
|
maxHeight: '200px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
zIndex: 10,
|
zIndex: 100,
|
||||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)'
|
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)',
|
||||||
}}>
|
}}>
|
||||||
{nodeSearchSuggestions.map((suggestion, index) => (
|
{nodeSearchSuggestions.map((suggestion, index) => (
|
||||||
<div
|
<div
|
||||||
key={suggestion.id}
|
key={suggestion.id}
|
||||||
onClick={() => handleSelectNodeSuggestion(suggestion)}
|
onClick={() => {
|
||||||
|
handleSelectNodeSuggestion(suggestion);
|
||||||
|
setNodeSearchQuery('');
|
||||||
|
setNodeSearchSuggestions([]);
|
||||||
|
setEdgeSearchOpen(false);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 14px',
|
padding: '8px 12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid var(--rah-bg-active)' : 'none',
|
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid var(--rah-border)' : 'none',
|
||||||
background: index === selectedSearchIndex ? 'var(--rah-bg-active)' : 'transparent',
|
background: index === selectedSearchIndex ? 'var(--rah-bg-active)' : 'transparent',
|
||||||
transition: 'background 100ms ease',
|
transition: 'background 100ms ease',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '10px'
|
gap: '8px',
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (index !== selectedSearchIndex) {
|
|
||||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (index !== selectedSearchIndex) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--rah-bg-active)'; }}
|
||||||
|
onMouseLeave={(e) => { if (index !== selectedSearchIndex) e.currentTarget.style.background = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: 'var(--rah-bg-base)',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
padding: '3px 6px',
|
padding: '2px 5px',
|
||||||
borderRadius: '6px',
|
borderRadius: '4px',
|
||||||
minWidth: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
||||||
}}>
|
}}>
|
||||||
{suggestion.id}
|
{suggestion.id}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '13px',
|
fontSize: '12px',
|
||||||
color: 'var(--rah-text-base)',
|
color: 'var(--rah-text-base)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
flex: 1
|
flex: 1,
|
||||||
}}>
|
}}>
|
||||||
{suggestion.title}
|
{suggestion.title}
|
||||||
</span>
|
</span>
|
||||||
{index === selectedSearchIndex && (
|
{index === selectedSearchIndex && (
|
||||||
<span style={{ color: 'var(--rah-text-muted)', fontSize: '13px' }}>↵</span>
|
<span style={{ color: 'var(--rah-text-muted)', fontSize: '12px' }}>↵</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1342,10 +1335,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connections List */}
|
{/* Connections list */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{loadingEdges.has(activeTab) ? (
|
{loadingEdges.has(activeTab) ? (
|
||||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', fontStyle: 'italic', padding: '16px 0', textAlign: 'center' }}>Loading connections…</div>
|
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', fontStyle: 'italic', padding: '8px 0' }}>Loading connections…</div>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
const list = edgesData[activeTab] || [];
|
const list = edgesData[activeTab] || [];
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -1358,18 +1351,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
key={connection.id}
|
key={connection.id}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 0',
|
padding: '6px 0',
|
||||||
borderBottom: '1px solid var(--rah-bg-panel)',
|
borderBottom: '1px solid var(--rah-border)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '2px',
|
gap: '2px',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Row 1: arrow + ID + icon + title + delete */}
|
{/* Row 1: arrow + ID + title + delete */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
|
color: connection.edge.from_node_id === activeTab ? 'var(--rah-accent-green)' : '#f59e0b',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
width: '14px',
|
width: '14px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -1380,8 +1373,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: 'var(--rah-bg-base)',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
padding: '1px 5px',
|
padding: '1px 5px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -1405,7 +1398,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#22c55e'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-accent-green)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-base)'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-base)'; }}
|
||||||
>
|
>
|
||||||
{connection.connected_node.title}
|
{connection.connected_node.title}
|
||||||
@@ -1414,7 +1407,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
onClick={() => deleteEdge(connection.edge.id)}
|
onClick={() => deleteEdge(connection.edge.id)}
|
||||||
disabled={deletingEdge === connection.edge.id}
|
disabled={deletingEdge === connection.edge.id}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--rah-border-stronger)',
|
color: 'var(--rah-text-muted)',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -1424,7 +1417,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = '#dc2626'; }}
|
onMouseEnter={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = '#dc2626'; }}
|
||||||
onMouseLeave={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = 'var(--rah-border-stronger)'; }}
|
onMouseLeave={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||||
>
|
>
|
||||||
{deletingEdge === connection.edge.id ? '...' : '×'}
|
{deletingEdge === connection.edge.id ? '...' : '×'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1464,8 +1457,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
disabled={edgeSavingId === connection.edge.id}
|
disabled={edgeSavingId === connection.edge.id}
|
||||||
style={{
|
style={{
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: '#000',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '3px 8px',
|
padding: '3px 8px',
|
||||||
@@ -1508,9 +1501,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
{typeof connection.edge.context?.type === 'string' && (
|
{typeof connection.edge.context?.type === 'string' && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'var(--rah-bg-active)',
|
background: 'var(--rah-bg-active)',
|
||||||
border: '1px solid var(--rah-bg-active)',
|
border: '1px solid var(--rah-border)',
|
||||||
padding: '1px 5px',
|
padding: '1px 5px',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -1522,7 +1515,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: connection.edge.context?.explanation ? '#777' : '#444',
|
color: connection.edge.context?.explanation ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
|
||||||
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
|
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@@ -1594,7 +1587,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderBottom: '1px solid var(--rah-border)',
|
borderBottom: '1px solid var(--rah-border)',
|
||||||
background: 'var(--rah-bg-surface)',
|
background: 'var(--rah-bg-base)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
overflowY: 'hidden'
|
overflowY: 'hidden'
|
||||||
@@ -1631,7 +1624,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRight: '1px solid var(--rah-border)',
|
borderRight: '1px solid var(--rah-border)',
|
||||||
background: isActive ? 'var(--rah-bg-subtle)' : 'var(--rah-bg-surface)',
|
background: isActive ? 'var(--rah-bg-subtle)' : 'var(--rah-bg-base)',
|
||||||
borderBottom: isActive ? '2px solid var(--rah-text-muted)' : 'none',
|
borderBottom: isActive ? '2px solid var(--rah-text-muted)' : 'none',
|
||||||
paddingBottom: isActive ? '0' : '2px',
|
paddingBottom: isActive ? '0' : '2px',
|
||||||
minWidth: '120px',
|
minWidth: '120px',
|
||||||
@@ -1646,7 +1639,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
color: isActive ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
color: isActive ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -1708,7 +1701,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
) : !currentNode ? (
|
) : !currentNode ? (
|
||||||
<div style={{ color: 'var(var(--rah-text-muted))', fontSize: '13px' }}>Node not found.</div>
|
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px' }}>Node not found.</div>
|
||||||
) : nodesData[activeTab] ? (
|
) : nodesData[activeTab] ? (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* URL Row - Above Title */}
|
{/* URL Row - Above Title */}
|
||||||
@@ -1791,8 +1784,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = nodesData[activeTab].link;
|
const link = nodesData[activeTab].link || '';
|
||||||
if (!link || !shouldOpenExternally(link)) {
|
if (!shouldOpenExternally(link)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1819,7 +1812,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<span
|
<span
|
||||||
onClick={() => startEdit('link', '')}
|
onClick={() => startEdit('link', '')}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--rah-text-muted)',
|
color: '#555',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontStyle: 'italic'
|
fontStyle: 'italic'
|
||||||
@@ -1850,8 +1843,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
color: 'var(--rah-bg-base)',
|
color: 'var(--rah-text-inverse)',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
@@ -1864,7 +1857,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
{activeTab}
|
{activeTab}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Node type icon */}
|
{/* Node icon */}
|
||||||
{nodesData[activeTab] && (
|
{nodesData[activeTab] && (
|
||||||
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
{getNodeIcon(nodesData[activeTab], dimensionIcons, 18)}
|
{getNodeIcon(nodesData[activeTab], dimensionIcons, 18)}
|
||||||
@@ -1938,7 +1931,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')}
|
title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')}
|
||||||
>
|
>
|
||||||
{nodesData[activeTab].title || 'Untitled'}
|
{nodesData[activeTab].title || 'Untitled'}
|
||||||
{savingField === 'title' && <span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
|
{savingField === 'title' && <span style={{ color: '#555', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1948,14 +1941,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px',
|
gap: '6px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: activeContentTab === 'edges' ? '#22c55e' : 'var(--rah-text-muted)',
|
color: activeContentTab === 'edges' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||||
background: activeContentTab === 'edges' ? '#0f2417' : 'transparent',
|
background: activeContentTab === 'edges' ? '#0f2417' : 'transparent',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: activeContentTab === 'edges' ? '#22c55e' : 'var(--rah-border-strong)',
|
borderColor: activeContentTab === 'edges' ? 'var(--rah-accent-green)' : 'var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
@@ -1963,7 +1956,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (activeContentTab !== 'edges') {
|
if (activeContentTab !== 'edges') {
|
||||||
e.currentTarget.style.color = '#22c55e';
|
e.currentTarget.style.color = 'var(--rah-accent-green)';
|
||||||
e.currentTarget.style.borderColor = '#166534';
|
e.currentTarget.style.borderColor = '#166534';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1983,18 +1976,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
style={{
|
style={{
|
||||||
minWidth: '18px',
|
minWidth: '18px',
|
||||||
height: '18px',
|
height: '18px',
|
||||||
|
padding: '0 6px',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '0 6px',
|
background: activeContentTab === 'edges' ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
|
||||||
background: activeContentTab === 'edges' ? 'rgba(34, 197, 94, 0.18)' : 'var(--rah-bg-active)',
|
color: activeContentTab === 'edges' ? 'var(--rah-accent-green)' : 'var(--rah-text-soft)',
|
||||||
color: activeContentTab === 'edges' ? '#a7f3b8' : 'var(--rah-text-muted)',
|
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(edgesData[activeTab] || []).length}
|
{edgesData[activeTab]?.length ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -2084,7 +2077,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
borderBottom: '1px solid var(--rah-border)'
|
borderBottom: '1px solid var(--rah-border)'
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }}
|
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); setEdgeSearchOpen(false); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
@@ -2092,7 +2085,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
color: activeContentTab === 'desc' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
color: activeContentTab === 'desc' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderBottom: activeContentTab === 'desc' ? '2px solid #22c55e' : '2px solid transparent',
|
borderBottom: activeContentTab === 'desc' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
marginBottom: '-1px'
|
marginBottom: '-1px'
|
||||||
@@ -2101,7 +2094,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
Desc
|
Desc
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
|
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); setEdgeSearchOpen(false); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
@@ -2109,7 +2102,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
color: activeContentTab === 'notes' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
color: activeContentTab === 'notes' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
|
borderBottom: activeContentTab === 'notes' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
marginBottom: '-1px'
|
marginBottom: '-1px'
|
||||||
@@ -2126,13 +2119,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
color: activeContentTab === 'edges' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
color: activeContentTab === 'edges' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderBottom: activeContentTab === 'edges' ? '2px solid #22c55e' : '2px solid transparent',
|
borderBottom: activeContentTab === 'edges' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
marginBottom: '-1px'
|
marginBottom: '-1px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edges{activeTab && edgesData[activeTab]?.length ? ` (${edgesData[activeTab].length})` : ''}
|
Edges
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveContentTab('source'); setDescEditMode(false); setNotesEditMode(false); setEdgeSearchOpen(false); }}
|
onClick={() => { setActiveContentTab('source'); setDescEditMode(false); setNotesEditMode(false); setEdgeSearchOpen(false); }}
|
||||||
@@ -2143,7 +2136,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
color: activeContentTab === 'source' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
color: activeContentTab === 'source' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderBottom: activeContentTab === 'source' ? '2px solid #22c55e' : '2px solid transparent',
|
borderBottom: activeContentTab === 'source' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
marginBottom: '-1px'
|
marginBottom: '-1px'
|
||||||
@@ -2164,7 +2157,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2183,7 +2176,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2208,7 +2201,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: '#22c55e',
|
color: 'var(--rah-accent-green)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid #166534',
|
border: '1px solid #166534',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2227,7 +2220,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2268,8 +2261,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: edgeSearchOpen ? '#000' : '#22c55e',
|
color: edgeSearchOpen ? 'var(--rah-text-inverse)' : 'var(--rah-accent-green)',
|
||||||
background: edgeSearchOpen ? '#22c55e' : 'transparent',
|
background: edgeSearchOpen ? 'var(--rah-accent-green)' : 'transparent',
|
||||||
border: '1px solid #166534',
|
border: '1px solid #166534',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -2383,7 +2376,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2402,8 +2395,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#000',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -2433,7 +2426,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<div
|
<div
|
||||||
onClick={startDescEdit}
|
onClick={startDescEdit}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--rah-text-muted)',
|
color: '#555',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -2536,7 +2529,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
top: '50px',
|
top: '50px',
|
||||||
left: '12px',
|
left: '12px',
|
||||||
background: 'var(--rah-bg-active)',
|
background: 'var(--rah-bg-active)',
|
||||||
border: '1px solid var(--rah-border-stronger)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
maxHeight: '200px',
|
maxHeight: '200px',
|
||||||
@@ -2566,11 +2559,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: 'var(--rah-text-base)',
|
color: 'var(--rah-text-base)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: idx === mentionIndex ? 'var(--rah-bg-hover)' : 'transparent',
|
background: idx === mentionIndex ? 'var(--rah-bg-elevated)' : 'transparent',
|
||||||
borderBottom: idx < mentionResults.length - 1 ? '1px solid var(--rah-border-strong)' : 'none'
|
borderBottom: idx < mentionResults.length - 1 ? '1px solid var(--rah-border-strong)' : 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: '#22c55e', marginRight: '8px', fontWeight: 600 }}>{n.id}</span>
|
<span style={{ color: 'var(--rah-accent-green)', marginRight: '8px', fontWeight: 600 }}>{n.id}</span>
|
||||||
<span>{n.title.length > 50 ? n.title.slice(0, 50) + '…' : n.title}</span>
|
<span>{n.title.length > 50 ? n.title.slice(0, 50) + '…' : n.title}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -2621,7 +2614,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2640,8 +2633,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#000',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -2671,7 +2664,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
style={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2685,7 +2678,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
style={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#000',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#f59e0b',
|
background: '#f59e0b',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2711,7 +2704,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarkdownWithNodeTokens
|
<MarkdownWithNodeTokens
|
||||||
content={nodesData[activeTab].notes || ''}
|
content={nodesData[activeTab].notes}
|
||||||
onNodeClick={onNodeClick || onTabSelect}
|
onNodeClick={onNodeClick || onTabSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2719,7 +2712,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<div
|
<div
|
||||||
onClick={startNotesEdit}
|
onClick={startNotesEdit}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--rah-text-muted)',
|
color: '#555',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -2814,7 +2807,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-soft)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -2833,8 +2826,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#000',
|
color: 'var(--rah-text-inverse)',
|
||||||
background: '#22c55e',
|
background: 'var(--rah-accent-green)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -2861,7 +2854,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
background: 'var(--rah-bg-surface)',
|
background: 'var(--rah-bg-surface)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: '1px solid var(--rah-bg-active)',
|
border: '1px solid var(--rah-border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
@@ -2871,7 +2864,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: sourceReaderMode === 'raw' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
color: sourceReaderMode === 'raw' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||||
background: sourceReaderMode === 'raw' ? 'var(--rah-border-strong)' : 'transparent',
|
background: sourceReaderMode === 'raw' ? 'var(--rah-bg-active)' : 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s ease',
|
transition: 'all 0.15s ease',
|
||||||
@@ -2886,7 +2879,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: sourceReaderMode === 'reader' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
color: sourceReaderMode === 'reader' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||||
background: sourceReaderMode === 'reader' ? 'var(--rah-border-strong)' : 'transparent',
|
background: sourceReaderMode === 'reader' ? 'var(--rah-bg-active)' : 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s ease',
|
transition: 'all 0.15s ease',
|
||||||
@@ -2907,17 +2900,17 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-bg-active)',
|
border: '1px solid var(--rah-border)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||||
e.currentTarget.style.borderColor = 'var(--rah-bg-active)';
|
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={10} />
|
<Pencil size={10} />
|
||||||
@@ -2927,10 +2920,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
|
|
||||||
{/* Content display */}
|
{/* Content display */}
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{nodesData[activeTab]?.chunk ? (
|
{(nodesData[activeTab]?.source || nodesData[activeTab]?.chunk) ? (
|
||||||
sourceReaderMode === 'reader' ? (
|
sourceReaderMode === 'reader' ? (
|
||||||
<SourceReader
|
<SourceReader
|
||||||
content={nodesData[activeTab].chunk}
|
content={nodesData[activeTab].source || nodesData[activeTab].chunk || ''}
|
||||||
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
|
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
|
||||||
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
|
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
|
||||||
/>
|
/>
|
||||||
@@ -2949,7 +2942,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodesData[activeTab].chunk}
|
{nodesData[activeTab].source || nodesData[activeTab].chunk}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -3036,7 +3029,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'var(--rah-border-strong)';
|
e.currentTarget.style.background = 'var(--rah-bg-elevated)';
|
||||||
e.currentTarget.style.color = 'var(--rah-text-active)';
|
e.currentTarget.style.color = 'var(--rah-text-active)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
@@ -3068,7 +3061,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'var(--rah-border-strong)';
|
e.currentTarget.style.background = 'var(--rah-bg-elevated)';
|
||||||
e.currentTarget.style.color = 'var(--rah-text-active)';
|
e.currentTarget.style.color = 'var(--rah-text-active)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
|||||||
@@ -172,12 +172,12 @@ export default function ThreePanelLayout() {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
link: node.link,
|
link: node.link,
|
||||||
|
source: node.source,
|
||||||
notes: node.notes,
|
notes: node.notes,
|
||||||
dimensions: node.dimensions,
|
dimensions: node.dimensions,
|
||||||
created_at: node.created_at,
|
created_at: node.created_at,
|
||||||
updated_at: node.updated_at,
|
updated_at: node.updated_at,
|
||||||
chunk_status: node.chunk_status,
|
chunk_status: node.chunk_status,
|
||||||
chunk: node.chunk,
|
|
||||||
metadata: node.metadata,
|
metadata: node.metadata,
|
||||||
}));
|
}));
|
||||||
setOpenTabsData(validNodes);
|
setOpenTabsData(validNodes);
|
||||||
|
|||||||
@@ -175,13 +175,13 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
background: 'var(var(--rah-bg-surface))',
|
background: 'var(--rah-bg-base)',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid var(--rah-border)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
gap: '6px',
|
gap: '6px',
|
||||||
}}>
|
}}>
|
||||||
<Search size={12} style={{ color: 'var(var(--rah-text-muted))', flexShrink: 0 }} />
|
<Search size={12} style={{ color: 'var(--rah-text-muted)', flexShrink: 0 }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -190,7 +190,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: 'var(var(--rah-text-active))',
|
color: 'var(--rah-text-active)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
padding: '5px 0',
|
padding: '5px 0',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
@@ -201,7 +201,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
|
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(var(--rah-text-muted))', cursor: 'pointer', padding: 0, display: 'flex' }}
|
style={{ background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, display: 'flex' }}
|
||||||
>
|
>
|
||||||
<X size={11} />
|
<X size={11} />
|
||||||
</button>
|
</button>
|
||||||
@@ -236,8 +236,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '4px',
|
display: 'flex', alignItems: 'center', gap: '4px',
|
||||||
padding: '4px 7px', background: 'transparent',
|
padding: '4px 7px', background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)', borderRadius: '5px',
|
border: '1px solid var(--rah-border)', borderRadius: '5px',
|
||||||
color: 'var(var(--rah-text-muted))', fontSize: '11px', cursor: 'pointer',
|
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
@@ -249,7 +249,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{showFilterPicker && (
|
{showFilterPicker && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
|
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
|
||||||
background: 'var(var(--rah-bg-panel))', border: '1px solid var(--rah-border-strong)', borderRadius: '10px',
|
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
|
||||||
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
|
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
|
||||||
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||||
}}>
|
}}>
|
||||||
@@ -260,13 +260,13 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
placeholder="Search dimensions..."
|
placeholder="Search dimensions..."
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '7px 10px', background: 'var(var(--rah-bg-surface))',
|
width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
|
||||||
border: '1px solid transparent', borderRadius: '6px',
|
border: '1px solid transparent', borderRadius: '6px',
|
||||||
color: 'var(var(--rah-text-active))', fontSize: '12px', marginBottom: '4px', outline: 'none',
|
color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{filterPickerDimensions.length === 0 ? (
|
{filterPickerDimensions.length === 0 ? (
|
||||||
<div style={{ padding: '12px', color: 'var(var(--rah-text-muted))', fontSize: '12px', textAlign: 'center' }}>
|
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||||
No matching dimensions
|
No matching dimensions
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -283,14 +283,14 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
width: '100%', padding: '7px 10px', background: 'transparent',
|
width: '100%', padding: '7px 10px', background: 'transparent',
|
||||||
border: 'none', borderRadius: '5px', color: 'var(var(--rah-text-secondary))',
|
border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
|
||||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<span>{d.dimension}</span>
|
<span>{d.dimension}</span>
|
||||||
<span style={{ color: 'var(var(--rah-text-muted))', fontSize: '10px', background: 'var(var(--rah-bg-active))', padding: '1px 6px', borderRadius: '10px' }}>
|
<span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
|
||||||
{d.count}
|
{d.count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -303,9 +303,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{selectedFilters.length > 0 && (
|
{selectedFilters.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedFilters([])}
|
onClick={() => setSelectedFilters([])}
|
||||||
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(var(--rah-text-muted))', fontSize: '11px', cursor: 'pointer' }}
|
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -319,8 +319,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '4px',
|
display: 'flex', alignItems: 'center', gap: '4px',
|
||||||
padding: '4px 7px', background: 'transparent',
|
padding: '4px 7px', background: 'transparent',
|
||||||
border: '1px solid var(--rah-border-strong)', borderRadius: '5px',
|
border: '1px solid var(--rah-border)', borderRadius: '5px',
|
||||||
color: 'var(var(--rah-text-muted))', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
|
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
@@ -333,7 +333,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{showSortDropdown && (
|
{showSortDropdown && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
||||||
background: 'var(var(--rah-bg-panel))', border: '1px solid var(--rah-border-strong)', borderRadius: '10px',
|
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
|
||||||
padding: '4px', minWidth: '160px', zIndex: 1000,
|
padding: '4px', minWidth: '160px', zIndex: 1000,
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||||
}}>
|
}}>
|
||||||
@@ -346,13 +346,13 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
width: '100%', padding: '7px 10px',
|
width: '100%', padding: '7px 10px',
|
||||||
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
|
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||||
border: 'none', borderRadius: '5px',
|
border: 'none', borderRadius: '5px',
|
||||||
color: sortOrder === key ? '#f0f0f0' : '#999',
|
color: sortOrder === key ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
|
||||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
|
||||||
>
|
>
|
||||||
{sortOrder === key && <span style={{ color: '#22c55e', fontSize: '12px' }}>✓</span>}
|
{sortOrder === key && <span style={{ color: 'var(--rah-accent-green)', fontSize: '12px' }}>✓</span>}
|
||||||
{SORT_LABELS[key]}
|
{SORT_LABELS[key]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -363,15 +363,15 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
fontSize: '11px', color: 'var(var(--rah-text-muted))', whiteSpace: 'nowrap',
|
fontSize: '11px', color: 'var(--rah-text-muted)', whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
|
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent', border: '1px solid var(--rah-border-strong)', borderRadius: '4px',
|
background: 'transparent', border: '1px solid var(--rah-border)', borderRadius: '4px',
|
||||||
color: page <= 1 ? '#333' : '#888', cursor: page <= 1 ? 'default' : 'pointer',
|
color: page <= 1 ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)', cursor: page <= 1 ? 'default' : 'pointer',
|
||||||
padding: '2px 4px', display: 'flex',
|
padding: '2px 4px', display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -381,8 +381,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent', border: '1px solid var(--rah-border-strong)', borderRadius: '4px',
|
background: 'transparent', border: '1px solid var(--rah-border)', borderRadius: '4px',
|
||||||
color: page >= totalPages ? '#333' : '#888',
|
color: page >= totalPages ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
|
||||||
cursor: page >= totalPages ? 'default' : 'pointer',
|
cursor: page >= totalPages ? 'default' : 'pointer',
|
||||||
padding: '2px 4px', display: 'flex',
|
padding: '2px 4px', display: 'flex',
|
||||||
}}
|
}}
|
||||||
@@ -410,9 +410,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
||||||
) : nodes.length === 0 ? (
|
) : nodes.length === 0 ? (
|
||||||
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center', fontSize: '13px' }}>
|
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
|
||||||
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
|
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -450,15 +450,15 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
height: '44px',
|
height: '44px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: hoveredRow === node.id
|
background: hoveredRow === node.id
|
||||||
? '#141414'
|
? 'var(--rah-bg-panel)'
|
||||||
: i % 2 === 0 ? '#080808' : '#0d0d0d',
|
: i % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-subtle)',
|
||||||
transition: 'background 0.1s ease',
|
transition: 'background 0.1s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<div style={truncCell}>
|
<div style={truncCell}>
|
||||||
<span style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 400 }}>
|
<span style={{ fontSize: '13px', color: 'var(--rah-text-base)', fontWeight: 400 }}>
|
||||||
{node.title || 'Untitled'}
|
{node.title || 'Untitled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,7 +468,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
<td style={tdStyle({ textAlign: 'right' })}>
|
<td style={tdStyle({ textAlign: 'right' })}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||||
fontSize: '11px', color: 'var(var(--rah-text-muted))',
|
fontSize: '11px', color: 'var(--rah-text-muted)',
|
||||||
}}>
|
}}>
|
||||||
{node.id}
|
{node.id}
|
||||||
</span>
|
</span>
|
||||||
@@ -477,7 +477,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<div style={truncCell}>
|
<div style={truncCell}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
|
||||||
{node.description || '\u2014'}
|
{node.description || '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,8 +486,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<div style={truncCell}>
|
<div style={truncCell}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
|
||||||
{node.notes ? node.notes.slice(0, 120) : '\u2014'}
|
{(node.source || node.notes) ? (node.source || node.notes || '').slice(0, 120) : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -503,7 +503,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '11px', color: '#333' }}>{'\u2014'}</span>
|
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -516,49 +516,49 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{node.dimensions.slice(0, 3).map(d => (
|
{node.dimensions.slice(0, 3).map(d => (
|
||||||
<span key={d} style={{
|
<span key={d} style={{
|
||||||
fontSize: '9px', padding: '1px 5px',
|
fontSize: '9px', padding: '1px 5px',
|
||||||
background: '#111914', border: '1px solid #1f2f24',
|
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||||
color: '#bbf7d0', borderRadius: '3px',
|
color: 'var(--rah-accent-green)', borderRadius: '3px',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
{d}
|
{d}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{node.dimensions.length > 3 && (
|
{node.dimensions.length > 3 && (
|
||||||
<span style={{ fontSize: '9px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
|
||||||
+{node.dimensions.length - 3}
|
+{node.dimensions.length - 3}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '10px', color: '#333' }}>{'\u2014'}</span>
|
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Edges */}
|
{/* Edges */}
|
||||||
<td style={tdStyle({ textAlign: 'right' })}>
|
<td style={tdStyle({ textAlign: 'right' })}>
|
||||||
<span style={{ fontSize: '12px', color: node.edge_count ? '#888' : '#333' }}>
|
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||||
{node.edge_count ?? 0}
|
{node.edge_count ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Event Date */}
|
{/* Event Date */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<span style={{ fontSize: '11px', color: node.event_date ? '#888' : '#333' }}>
|
<span style={{ fontSize: '11px', color: node.event_date ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||||
{formatDate(node.event_date)}
|
{formatDate(node.event_date)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Updated */}
|
{/* Updated */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||||
{formatRelativeTime(node.updated_at)}
|
{formatRelativeTime(node.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Created */}
|
{/* Created */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||||
{formatRelativeTime(node.created_at)}
|
{formatRelativeTime(node.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -566,7 +566,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<div style={truncCell}>
|
<div style={truncCell}>
|
||||||
<span style={{ fontSize: '10px', color: 'var(var(--rah-text-muted))', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
|
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
|
||||||
{metaStr || '\u2014'}
|
{metaStr || '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,8 +575,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
{/* Chunk */}
|
{/* Chunk */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<div style={truncCell}>
|
<div style={truncCell}>
|
||||||
<span style={{ fontSize: '10px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>
|
||||||
{node.chunk ? node.chunk.slice(0, 100) : '\u2014'}
|
{(node.source || node.chunk) ? (node.source || node.chunk || '').slice(0, 100) : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -585,7 +585,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: node.chunk_status === 'chunked' ? '#4a9' : node.chunk_status === 'error' ? '#e55' : '#555',
|
color: node.chunk_status === 'chunked' ? 'var(--rah-accent-green)' : node.chunk_status === 'error' ? '#e55' : 'var(--rah-text-muted)',
|
||||||
}}>
|
}}>
|
||||||
{node.chunk_status || '\u2014'}
|
{node.chunk_status || '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
@@ -593,7 +593,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
|||||||
|
|
||||||
{/* Embedding Updated */}
|
{/* Embedding Updated */}
|
||||||
<td style={tdStyle()}>
|
<td style={tdStyle()}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}>
|
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||||
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
|
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -612,15 +612,15 @@ function thStyle(extra: React.CSSProperties = {}): React.CSSProperties {
|
|||||||
return {
|
return {
|
||||||
position: 'sticky' as const,
|
position: 'sticky' as const,
|
||||||
top: 0,
|
top: 0,
|
||||||
background: 'var(var(--rah-bg-base))',
|
background: 'var(--rah-bg-base)',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(var(--rah-text-muted))',
|
color: 'var(--rah-text-muted)',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
borderBottom: '1px solid var(--rah-border)',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@@ -630,7 +630,7 @@ function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties {
|
|||||||
return {
|
return {
|
||||||
padding: '0 12px',
|
padding: '0 12px',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
borderBottom: '1px solid #111',
|
borderBottom: '1px solid var(--rah-border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description or Content Preview */}
|
{/* Description or Content Preview */}
|
||||||
{(node.description || node.notes) && (
|
{(node.description || node.source || node.notes) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
@@ -116,7 +116,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
marginBottom: '10px'
|
marginBottom: '10px'
|
||||||
}}>
|
}}>
|
||||||
{node.description || truncateContent(node.notes)}
|
{node.description || truncateContent(node.source || node.notes)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description or Content Preview */}
|
{/* Description or Content Preview */}
|
||||||
{(node.description || node.notes) && (
|
{(node.description || node.source || node.notes) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
@@ -116,7 +116,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
{node.description || truncateContent(node.notes)}
|
{node.description || truncateContent(node.source || node.notes)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: DB Operations
|
name: DB Operations
|
||||||
description: "Use this for all graph read/write operations with strict data quality standards."
|
description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
|
||||||
when_to_use: "Any request to read, create, update, connect, classify, or traverse graph data."
|
|
||||||
when_not_to_use: "Pure conversation with no graph interaction needed."
|
|
||||||
success_criteria: "Writes are explicit and correct; descriptions are concrete; edges and dimensions are high-signal."
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# DB Operations
|
# DB Operations
|
||||||
@@ -20,8 +17,9 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
|
|||||||
|
|
||||||
- `title`: clear and specific.
|
- `title`: clear and specific.
|
||||||
- `description`: concrete object-level description, not vague summaries.
|
- `description`: concrete object-level description, not vague summaries.
|
||||||
- `notes/content`: extra context, analysis, supporting detail.
|
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
|
||||||
- `link`: external source URL only.
|
- `link`: external source URL only.
|
||||||
|
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||||
|
|
||||||
## Execution Pattern
|
## Execution Pattern
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
|||||||
const title = deriveFallbackLinkTitle(url);
|
const title = deriveFallbackLinkTitle(url);
|
||||||
const description =
|
const description =
|
||||||
`Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`;
|
`Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`;
|
||||||
const notes = [
|
const source = [
|
||||||
`Original URL: ${url}`,
|
`Original URL: ${url}`,
|
||||||
`Ingestion failure: ${message}`,
|
`Ingestion failure: ${message}`,
|
||||||
`Attempted pipeline: ${type}`,
|
`Attempted pipeline: ${type}`,
|
||||||
@@ -248,7 +248,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
notes,
|
source,
|
||||||
link: url,
|
link: url,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'quick-add-link-fallback',
|
source: 'quick-add-link-fallback',
|
||||||
@@ -292,7 +292,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
|||||||
const title = deriveNoteTitle(content);
|
const title = deriveNoteTitle(content);
|
||||||
const nodePayload: Record<string, unknown> = {
|
const nodePayload: Record<string, unknown> = {
|
||||||
title,
|
title,
|
||||||
notes: content,
|
source: content,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'quick-add-note',
|
source: 'quick-add-note',
|
||||||
refined_at: new Date().toISOString(),
|
refined_at: new Date().toISOString(),
|
||||||
@@ -396,8 +396,8 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
notes: content,
|
source: transcript,
|
||||||
chunk: transcript,
|
description: content,
|
||||||
metadata,
|
metadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -478,17 +478,13 @@ export class EdgeService {
|
|||||||
ELSE n_from.title
|
ELSE n_from.title
|
||||||
END as connected_node_title,
|
END as connected_node_title,
|
||||||
CASE
|
CASE
|
||||||
WHEN e.from_node_id = ? THEN n_to.notes
|
|
||||||
ELSE n_from.notes
|
|
||||||
END as connected_node_notes,
|
|
||||||
CASE
|
|
||||||
WHEN e.from_node_id = ? THEN n_to.link
|
WHEN e.from_node_id = ? THEN n_to.link
|
||||||
ELSE n_from.link
|
ELSE n_from.link
|
||||||
END as connected_node_link,
|
END as connected_node_link,
|
||||||
CASE
|
CASE
|
||||||
WHEN e.from_node_id = ? THEN n_to.chunk
|
WHEN e.from_node_id = ? THEN n_to.source
|
||||||
ELSE n_from.chunk
|
ELSE n_from.source
|
||||||
END as connected_node_chunk,
|
END as connected_node_source,
|
||||||
CASE
|
CASE
|
||||||
WHEN e.from_node_id = ? THEN n_to.metadata
|
WHEN e.from_node_id = ? THEN n_to.metadata
|
||||||
ELSE n_from.metadata
|
ELSE n_from.metadata
|
||||||
@@ -547,11 +543,10 @@ export class EdgeService {
|
|||||||
const connected_node: Node = {
|
const connected_node: Node = {
|
||||||
id: row.connected_node_id,
|
id: row.connected_node_id,
|
||||||
title: row.connected_node_title,
|
title: row.connected_node_title,
|
||||||
notes: row.connected_node_notes,
|
|
||||||
link: row.connected_node_link,
|
link: row.connected_node_link,
|
||||||
dimensions: row.connected_node_dimensions,
|
dimensions: row.connected_node_dimensions,
|
||||||
embedding: undefined, // Not needed for display
|
embedding: undefined, // Not needed for display
|
||||||
chunk: row.connected_node_chunk,
|
source: row.connected_node_source,
|
||||||
metadata: row.connected_node_metadata,
|
metadata: row.connected_node_metadata,
|
||||||
created_at: row.connected_node_created_at,
|
created_at: row.connected_node_created_at,
|
||||||
updated_at: row.connected_node_updated_at
|
updated_at: row.connected_node_updated_at
|
||||||
@@ -592,11 +587,10 @@ export class EdgeService {
|
|||||||
const connected_node: Node = {
|
const connected_node: Node = {
|
||||||
id: row.connected_node_id,
|
id: row.connected_node_id,
|
||||||
title: row.connected_node_title,
|
title: row.connected_node_title,
|
||||||
notes: row.connected_node_notes,
|
|
||||||
link: row.connected_node_link,
|
link: row.connected_node_link,
|
||||||
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
|
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
|
||||||
embedding: undefined, // Not needed for display
|
embedding: undefined, // Not needed for display
|
||||||
chunk: row.connected_node_chunk,
|
source: row.connected_node_source,
|
||||||
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
|
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
|
||||||
created_at: row.connected_node_created_at,
|
created_at: row.connected_node_created_at,
|
||||||
updated_at: row.connected_node_updated_at
|
updated_at: row.connected_node_updated_at
|
||||||
|
|||||||
+119
-25
@@ -16,6 +16,38 @@ function sanitizeFtsQuery(input: string): string {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractRelaxedSearchTerms(query: string): string[] {
|
||||||
|
const stopWords = new Set([
|
||||||
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find',
|
||||||
|
'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on',
|
||||||
|
'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rawTerms = query
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]+/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(term => term.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const expanded = new Set<string>();
|
||||||
|
|
||||||
|
for (const term of rawTerms) {
|
||||||
|
if (!stopWords.has(term) && term.length >= 3) {
|
||||||
|
expanded.add(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
|
||||||
|
for (const part of alphaParts) {
|
||||||
|
if (!stopWords.has(part) && part.length >= 3) {
|
||||||
|
expanded.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(expanded).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
function reciprocalRankFuse<T extends { id: number }>(
|
function reciprocalRankFuse<T extends { id: number }>(
|
||||||
rankedLists: T[][],
|
rankedLists: T[][],
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -48,7 +80,7 @@ export class NodeService {
|
|||||||
|
|
||||||
async countNodes(filters: NodeFilters = {}): Promise<number> {
|
async countNodes(filters: NodeFilters = {}): Promise<number> {
|
||||||
const { dimensions, search, dimensionsMatch = 'any',
|
const { dimensions, search, dimensionsMatch = 'any',
|
||||||
createdAfter, createdBefore, eventAfter, eventBefore } = filters;
|
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
|
||||||
|
|
||||||
if (search?.trim()) {
|
if (search?.trim()) {
|
||||||
return this.countSearchNodesSQLite(filters);
|
return this.countSearchNodesSQLite(filters);
|
||||||
@@ -78,7 +110,7 @@ export class NodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
|
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +118,7 @@ export class NodeService {
|
|||||||
if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); }
|
if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); }
|
||||||
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
|
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
|
||||||
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
|
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
|
||||||
|
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); }
|
||||||
|
|
||||||
const result = sqlite.query<{ total: number }>(query, params);
|
const result = sqlite.query<{ total: number }>(query, params);
|
||||||
return result.rows[0]?.total ?? 0;
|
return result.rows[0]?.total ?? 0;
|
||||||
@@ -95,7 +128,7 @@ export class NodeService {
|
|||||||
|
|
||||||
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
||||||
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
|
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
|
||||||
createdAfter, createdBefore, eventAfter, eventBefore } = filters;
|
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
|
||||||
|
|
||||||
if (search?.trim()) {
|
if (search?.trim()) {
|
||||||
return this.searchNodesSQLite(filters);
|
return this.searchNodesSQLite(filters);
|
||||||
@@ -105,7 +138,7 @@ export class NodeService {
|
|||||||
|
|
||||||
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
|
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
|
||||||
let query = `
|
let query = `
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
@@ -137,9 +170,9 @@ export class NodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text search in title, description, and notes (SQLite LIKE with COLLATE NOCASE)
|
// Text search in title, description, and source (SQLite LIKE with COLLATE NOCASE)
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
|
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,23 +193,27 @@ export class NodeService {
|
|||||||
query += ` AND n.event_date < ?`;
|
query += ` AND n.event_date < ?`;
|
||||||
params.push(eventBefore);
|
params.push(eventBefore);
|
||||||
}
|
}
|
||||||
|
if (chunkStatus) {
|
||||||
|
query += ` AND n.chunk_status = ?`;
|
||||||
|
params.push(chunkStatus);
|
||||||
|
}
|
||||||
|
|
||||||
// Sorting logic
|
// Sorting logic
|
||||||
if (search) {
|
if (search) {
|
||||||
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → notes
|
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source
|
||||||
query += ` ORDER BY
|
query += ` ORDER BY
|
||||||
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
|
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
|
||||||
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
|
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
|
||||||
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
|
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
|
||||||
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
|
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
|
||||||
CASE WHEN n.notes LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
|
CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
|
||||||
n.updated_at DESC`;
|
n.updated_at DESC`;
|
||||||
params.push(
|
params.push(
|
||||||
search, // Exact match (case-insensitive)
|
search, // Exact match (case-insensitive)
|
||||||
`${search}%`, // Starts with search term
|
`${search}%`, // Starts with search term
|
||||||
`%${search}%`, // Contains in title
|
`%${search}%`, // Contains in title
|
||||||
`%${search}%`, // Contains in description
|
`%${search}%`, // Contains in description
|
||||||
`%${search}%` // Contains in notes
|
`%${search}%` // Contains in source
|
||||||
);
|
);
|
||||||
} else if (sortBy === 'edges') {
|
} else if (sortBy === 'edges') {
|
||||||
// Sort by edge count (most connected first)
|
// Sort by edge count (most connected first)
|
||||||
@@ -215,7 +252,7 @@ export class NodeService {
|
|||||||
private async getNodeByIdSQLite(id: number): Promise<Node | null> {
|
private async getNodeByIdSQLite(id: number): Promise<Node | null> {
|
||||||
const sqlite = getSQLiteClient();
|
const sqlite = getSQLiteClient();
|
||||||
const query = `
|
const query = `
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
@@ -241,11 +278,10 @@ export class NodeService {
|
|||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
notes,
|
source,
|
||||||
link,
|
link,
|
||||||
event_date,
|
event_date,
|
||||||
dimensions = [],
|
dimensions = [],
|
||||||
chunk,
|
|
||||||
chunk_status,
|
chunk_status,
|
||||||
metadata = {}
|
metadata = {}
|
||||||
} = nodeData;
|
} = nodeData;
|
||||||
@@ -255,16 +291,15 @@ export class NodeService {
|
|||||||
const nodeId = sqlite.transaction(() => {
|
const nodeId = sqlite.transaction(() => {
|
||||||
// Insert node using prepare/run for lastInsertRowid access
|
// Insert node using prepare/run for lastInsertRowid access
|
||||||
const nodeResult = sqlite.prepare(`
|
const nodeResult = sqlite.prepare(`
|
||||||
INSERT INTO nodes (title, description, notes, link, event_date, metadata, chunk, chunk_status, created_at, updated_at)
|
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
title,
|
title,
|
||||||
description ?? null,
|
description ?? null,
|
||||||
notes ?? null,
|
source ?? null,
|
||||||
link ?? null,
|
link ?? null,
|
||||||
event_date ?? null,
|
event_date ?? null,
|
||||||
JSON.stringify(metadata),
|
JSON.stringify(metadata),
|
||||||
chunk ?? null,
|
|
||||||
chunk_status ?? null,
|
chunk_status ?? null,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
@@ -308,7 +343,7 @@ export class NodeService {
|
|||||||
// PostgreSQL path removed in SQLite-only consolidation
|
// PostgreSQL path removed in SQLite-only consolidation
|
||||||
|
|
||||||
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
|
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
|
||||||
const { title, description, notes, link, event_date, dimensions, chunk, metadata } = updates;
|
const { title, description, source, link, event_date, dimensions, metadata } = updates;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const sqlite = getSQLiteClient();
|
const sqlite = getSQLiteClient();
|
||||||
|
|
||||||
@@ -327,10 +362,9 @@ export class NodeService {
|
|||||||
|
|
||||||
if (title !== undefined) { setFields.push('title = ?'); params.push(title); }
|
if (title !== undefined) { setFields.push('title = ?'); params.push(title); }
|
||||||
if (description !== undefined) { setFields.push('description = ?'); params.push(description); }
|
if (description !== undefined) { setFields.push('description = ?'); params.push(description); }
|
||||||
if (notes !== undefined) { setFields.push('notes = ?'); params.push(notes); }
|
if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
|
||||||
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
|
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
|
||||||
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); }
|
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); }
|
||||||
if (chunk !== undefined) { setFields.push('chunk = ?'); params.push(chunk); }
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
|
||||||
setFields.push('chunk_status = ?');
|
setFields.push('chunk_status = ?');
|
||||||
params.push(updates.chunk_status ?? null);
|
params.push(updates.chunk_status ?? null);
|
||||||
@@ -469,6 +503,10 @@ export class NodeService {
|
|||||||
rankedRows = this.searchNodesLike(sqlite, search, filters, searchLimit);
|
rankedRows = this.searchNodesLike(sqlite, search, filters, searchLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rankedRows.length === 0) {
|
||||||
|
rankedRows = this.searchNodesLikeRelaxed(sqlite, search, filters, searchLimit);
|
||||||
|
}
|
||||||
|
|
||||||
if ((filters.searchMode ?? 'standard') === 'hybrid') {
|
if ((filters.searchMode ?? 'standard') === 'hybrid') {
|
||||||
const vectorRows = await this.searchNodesVector(sqlite, search, filters, searchLimit);
|
const vectorRows = await this.searchNodesVector(sqlite, search, filters, searchLimit);
|
||||||
if (vectorRows.length > 0) {
|
if (vectorRows.length > 0) {
|
||||||
@@ -518,7 +556,7 @@ export class NodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
|
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +589,7 @@ export class NodeService {
|
|||||||
WHERE nodes_fts MATCH ?
|
WHERE nodes_fts MATCH ?
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
@@ -580,7 +618,7 @@ export class NodeService {
|
|||||||
const words = search.split(/\s+/).filter(Boolean);
|
const words = search.split(/\s+/).filter(Boolean);
|
||||||
const { clauses, params } = this.buildNodeFilterClauses(filters);
|
const { clauses, params } = this.buildNodeFilterClauses(filters);
|
||||||
let query = `
|
let query = `
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
@@ -595,7 +633,7 @@ export class NodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
|
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +642,7 @@ export class NodeService {
|
|||||||
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
|
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
|
||||||
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
|
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
|
||||||
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
|
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
|
||||||
CASE WHEN n.notes LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
|
CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
|
||||||
n.updated_at DESC
|
n.updated_at DESC
|
||||||
LIMIT ?`;
|
LIMIT ?`;
|
||||||
|
|
||||||
@@ -614,6 +652,62 @@ export class NodeService {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private searchNodesLikeRelaxed(
|
||||||
|
sqlite: ReturnType<typeof getSQLiteClient>,
|
||||||
|
search: string,
|
||||||
|
filters: NodeFilters,
|
||||||
|
limit: number,
|
||||||
|
): NodeSearchRow[] {
|
||||||
|
const terms = extractRelaxedSearchTerms(search);
|
||||||
|
if (terms.length === 0) return [];
|
||||||
|
|
||||||
|
const { clauses, params } = this.buildNodeFilterClauses(filters);
|
||||||
|
let query = `
|
||||||
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
|
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
|
n.created_at, n.updated_at,
|
||||||
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
|
FROM nodes n
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const queryParams = [...params];
|
||||||
|
|
||||||
|
if (clauses.length > 0) {
|
||||||
|
query += ` AND ${clauses.join(' AND ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const termClauses: string[] = [];
|
||||||
|
for (const term of terms) {
|
||||||
|
termClauses.push(`n.title LIKE ? COLLATE NOCASE`);
|
||||||
|
termClauses.push(`n.description LIKE ? COLLATE NOCASE`);
|
||||||
|
termClauses.push(`n.source LIKE ? COLLATE NOCASE`);
|
||||||
|
queryParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` AND (${termClauses.join(' OR ')})`;
|
||||||
|
|
||||||
|
const scoreClauses: string[] = [];
|
||||||
|
const scoreParams: string[] = [];
|
||||||
|
for (const term of terms) {
|
||||||
|
scoreClauses.push(`CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 0 END`);
|
||||||
|
scoreClauses.push(`CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 2 ELSE 0 END`);
|
||||||
|
scoreClauses.push(`CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 1 ELSE 0 END`);
|
||||||
|
scoreParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY
|
||||||
|
(${scoreClauses.join(' + ')}) DESC,
|
||||||
|
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 0 ELSE 1 END,
|
||||||
|
n.updated_at DESC
|
||||||
|
LIMIT ?`;
|
||||||
|
|
||||||
|
queryParams.push(...scoreParams, `%${search}%`, limit);
|
||||||
|
|
||||||
|
const result = sqlite.query<NodeSearchRow>(query, queryParams);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
private async searchNodesVector(
|
private async searchNodesVector(
|
||||||
sqlite: ReturnType<typeof getSQLiteClient>,
|
sqlite: ReturnType<typeof getSQLiteClient>,
|
||||||
search: string,
|
search: string,
|
||||||
@@ -643,7 +737,7 @@ export class NodeService {
|
|||||||
ORDER BY distance
|
ORDER BY distance
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
|
|||||||
@@ -616,13 +616,60 @@ class SQLiteClient {
|
|||||||
|
|
||||||
// 10) Final schema pass migrations (content→notes, event_date, dimensions.icon, drop dead columns)
|
// 10) Final schema pass migrations (content→notes, event_date, dimensions.icon, drop dead columns)
|
||||||
try {
|
try {
|
||||||
const nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||||
const nodeColNames = nodeCols2.map(c => c.name);
|
let nodeColNames = nodeCols2.map(c => c.name);
|
||||||
|
|
||||||
// Rename content → notes (additive first)
|
// Rename content → notes (additive first)
|
||||||
if (nodeColNames.includes('content') && !nodeColNames.includes('notes')) {
|
if (nodeColNames.includes('content') && !nodeColNames.includes('notes')) {
|
||||||
console.log('Migrating nodes.content → nodes.notes...');
|
console.log('Migrating nodes.content → nodes.notes...');
|
||||||
this.db.exec('ALTER TABLE nodes RENAME COLUMN content TO notes;');
|
this.db.exec('ALTER TABLE nodes RENAME COLUMN content TO notes;');
|
||||||
|
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||||
|
nodeColNames = nodeCols2.map(c => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeColNames.includes('source')) {
|
||||||
|
console.log('Adding nodes.source column...');
|
||||||
|
this.db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
|
||||||
|
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||||
|
nodeColNames = nodeCols2.map(c => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeColNames.includes('source')) {
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE nodes
|
||||||
|
SET source = chunk
|
||||||
|
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
|
||||||
|
AND chunk IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(chunk)) > 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE nodes
|
||||||
|
SET source = notes,
|
||||||
|
chunk_status = 'not_chunked'
|
||||||
|
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
|
||||||
|
AND notes IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(notes)) > 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE nodes
|
||||||
|
SET source = title || CASE
|
||||||
|
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
|
||||||
|
THEN char(10) || char(10) || description
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
chunk_status = 'not_chunked'
|
||||||
|
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE nodes
|
||||||
|
SET chunk_status = 'not_chunked'
|
||||||
|
WHERE source IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(source)) > 0
|
||||||
|
AND (chunk_status IS NULL OR chunk_status != 'chunked');
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event_date
|
// Add event_date
|
||||||
@@ -657,8 +704,10 @@ class SQLiteClient {
|
|||||||
if (edgeCols.some(c => c.name === 'user_feedback')) {
|
if (edgeCols.some(c => c.name === 'user_feedback')) {
|
||||||
try { this.db.exec('ALTER TABLE edges DROP COLUMN user_feedback;'); } catch {}
|
try { this.db.exec('ALTER TABLE edges DROP COLUMN user_feedback;'); } catch {}
|
||||||
}
|
}
|
||||||
|
// edges.explanation (top-level column added alongside context JSON)
|
||||||
if (!edgeCols.some(c => c.name === 'explanation')) {
|
if (!edgeCols.some(c => c.name === 'explanation')) {
|
||||||
this.db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
this.db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
||||||
|
// Backfill from context JSON where available
|
||||||
try {
|
try {
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
UPDATE edges SET explanation = json_extract(context, '$.explanation')
|
UPDATE edges SET explanation = json_extract(context, '$.explanation')
|
||||||
@@ -667,13 +716,13 @@ class SQLiteClient {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate nodes_fts to index title + description + notes
|
// Recreate nodes_fts to index title + source + description
|
||||||
try {
|
try {
|
||||||
const ftsCheck = this.db.prepare("SELECT sql FROM sqlite_master WHERE name='nodes_fts'").get() as { sql?: string } | undefined;
|
const ftsCheck = this.db.prepare("SELECT sql FROM sqlite_master WHERE name='nodes_fts'").get() as { sql?: string } | undefined;
|
||||||
if (ftsCheck?.sql && (!ftsCheck.sql.includes('description') || ftsCheck.sql.includes('content'))) {
|
const needsRebuild = !ftsCheck?.sql || !ftsCheck.sql.includes('source') || ftsCheck.sql.includes('notes') || ftsCheck.sql.includes('content');
|
||||||
|
if (needsRebuild) {
|
||||||
this.db.exec('DROP TABLE IF EXISTS nodes_fts;');
|
this.db.exec('DROP TABLE IF EXISTS nodes_fts;');
|
||||||
this.db.exec("CREATE VIRTUAL TABLE nodes_fts USING fts5(title, description, notes, content='nodes', content_rowid='id');");
|
this.db.exec("CREATE VIRTUAL TABLE nodes_fts USING fts5(title, source, description, content='nodes', content_rowid='id');");
|
||||||
// Rebuild FTS index
|
|
||||||
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
|
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
|
||||||
}
|
}
|
||||||
} catch (ftsErr) {
|
} catch (ftsErr) {
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ export class AutoEmbedQueue {
|
|||||||
private readonly lastRunAt = new Map<number, number>();
|
private readonly lastRunAt = new Map<number, number>();
|
||||||
private readonly maxConcurrent = 1;
|
private readonly maxConcurrent = 1;
|
||||||
private readonly cooldownMs = DEFAULT_COOLDOWN_MS;
|
private readonly cooldownMs = DEFAULT_COOLDOWN_MS;
|
||||||
private readonly embeddingsDisabled = process.env.DISABLE_EMBEDDINGS === 'true';
|
|
||||||
|
async recoverStuckNodes(): Promise<void> {
|
||||||
|
const stuckNodes = await nodeService.getNodes({ chunkStatus: 'not_chunked', limit: 1000 });
|
||||||
|
for (const node of stuckNodes) {
|
||||||
|
this.enqueue(node.id, { reason: 'startup_recovery' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean {
|
enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean {
|
||||||
if (this.embeddingsDisabled && !task.force) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const existing = this.pendingTasks.get(nodeId);
|
const existing = this.pendingTasks.get(nodeId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
this.pendingTasks.set(nodeId, { nodeId, ...task });
|
this.pendingTasks.set(nodeId, { nodeId, ...task });
|
||||||
@@ -77,21 +80,12 @@ export class AutoEmbedQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async executeTask(task: AutoEmbedTask) {
|
private async executeTask(task: AutoEmbedTask) {
|
||||||
if (this.embeddingsDisabled && !task.force) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const node = await nodeService.getNodeById(task.nodeId);
|
const node = await nodeService.getNodeById(task.nodeId);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId);
|
console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunkText = node.chunk?.trim();
|
|
||||||
if (!chunkText) {
|
|
||||||
console.warn('[AutoEmbedQueue] Node has no chunk content, skipping', task.nodeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!task.force && node.chunk_status === 'chunked') {
|
if (!task.force && node.chunk_status === 'chunked') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,4 +111,7 @@ declare global {
|
|||||||
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
|
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
|
||||||
if (!globalThis.autoEmbedQueue) {
|
if (!globalThis.autoEmbedQueue) {
|
||||||
globalThis.autoEmbedQueue = autoEmbedQueue;
|
globalThis.autoEmbedQueue = autoEmbedQueue;
|
||||||
|
autoEmbedQueue.recoverStuckNodes().catch(error => {
|
||||||
|
console.error('[AutoEmbedQueue] Startup recovery failed', error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,10 +103,10 @@ export async function embedNodeContent(nodeId: number): Promise<EmbeddingPipelin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.chunk || !node.chunk.trim()) {
|
if (!node.source || !node.source.trim()) {
|
||||||
results.chunk_embeddings = {
|
results.chunk_embeddings = {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
message: 'No chunk content to embed',
|
message: 'No source content to embed',
|
||||||
chunks_created: 0
|
chunks_created: 0
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
interface NodeRecord {
|
interface NodeRecord {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
source: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
dimensions_json: string;
|
dimensions_json: string;
|
||||||
@@ -58,7 +59,7 @@ export class NodeEmbedder {
|
|||||||
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
|
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
|
||||||
|
|
||||||
Title: ${node.title}
|
Title: ${node.title}
|
||||||
Content: ${node.notes || 'No content'}
|
Source: ${node.source || node.notes || 'No source'}
|
||||||
Dimensions: ${dimensionsText}
|
Dimensions: ${dimensionsText}
|
||||||
|
|
||||||
Focus on the main concepts, key relationships, and practical implications.`;
|
Focus on the main concepts, key relationships, and practical implications.`;
|
||||||
@@ -106,13 +107,14 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
// Create base embedding text
|
// Create base embedding text
|
||||||
let embeddingText = formatEmbeddingText(
|
let embeddingText = formatEmbeddingText(
|
||||||
node.title,
|
node.title,
|
||||||
node.notes || '',
|
node.source || node.notes || '',
|
||||||
dimensions,
|
dimensions,
|
||||||
node.description
|
node.description
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add AI analysis if content exists
|
// Add AI analysis if source exists
|
||||||
if (node.notes && node.notes.trim().length > 0) {
|
const sourceText = node.source || node.notes || '';
|
||||||
|
if (sourceText.trim().length > 0) {
|
||||||
const analysis = await this.analyzeNodeWithAI(node);
|
const analysis = await this.analyzeNodeWithAI(node);
|
||||||
if (analysis) {
|
if (analysis) {
|
||||||
embeddingText += `\n\nAI Analysis: ${analysis}`;
|
embeddingText += `\n\nAI Analysis: ${analysis}`;
|
||||||
@@ -176,8 +178,8 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
|
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
// Single node
|
// Single node
|
||||||
query = `
|
query = `
|
||||||
SELECT n.id, n.title, n.content, n.description,
|
SELECT n.id, n.title, n.source, n.notes, n.description,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||||
n.embedding, n.embedding_updated_at
|
n.embedding, n.embedding_updated_at
|
||||||
@@ -188,7 +190,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
} else if (forceReEmbed) {
|
} else if (forceReEmbed) {
|
||||||
// All nodes
|
// All nodes
|
||||||
query = `
|
query = `
|
||||||
SELECT n.id, n.title, n.content, n.description,
|
SELECT n.id, n.title, n.source, n.notes, n.description,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||||
n.embedding, n.embedding_updated_at
|
n.embedding, n.embedding_updated_at
|
||||||
@@ -198,7 +200,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
} else {
|
} else {
|
||||||
// Only nodes without embeddings
|
// Only nodes without embeddings
|
||||||
query = `
|
query = `
|
||||||
SELECT n.id, n.title, n.content, n.description,
|
SELECT n.id, n.title, n.source, n.notes, n.description,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||||
n.embedding, n.embedding_updated_at
|
n.embedding, n.embedding_updated_at
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Universal chunking and embedding service for RA-H knowledge management system
|
* Universal chunking and embedding service for RA-H knowledge management system
|
||||||
* Takes a node_id, reads chunk content from nodes table, chunks it, and stores in chunks table
|
* Takes a node_id, reads source content from nodes table, chunks it, and stores in chunks table
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
interface Node {
|
interface Node {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
chunk: string | null;
|
source: string | null;
|
||||||
chunk_status?: string | null;
|
chunk_status?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export class UniversalEmbedder {
|
|||||||
|
|
||||||
// Get node data
|
// Get node data
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
SELECT id, title, chunk, chunk_status
|
SELECT id, title, source, chunk_status
|
||||||
FROM nodes
|
FROM nodes
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
@@ -168,8 +168,8 @@ export class UniversalEmbedder {
|
|||||||
throw new Error(`Node ${nodeId} not found`);
|
throw new Error(`Node ${nodeId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.chunk || node.chunk.trim().length === 0) {
|
if (!node.source || node.source.trim().length === 0) {
|
||||||
console.log(`Node ${nodeId} has no chunk content to process`);
|
console.log(`Node ${nodeId} has no source content to process`);
|
||||||
return { chunks: 0 };
|
return { chunks: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export class UniversalEmbedder {
|
|||||||
this.deleteExistingChunks(nodeId);
|
this.deleteExistingChunks(nodeId);
|
||||||
|
|
||||||
// Split text into chunks
|
// Split text into chunks
|
||||||
const chunks = await this.textSplitter.splitText(node.chunk);
|
const chunks = await this.textSplitter.splitText(node.source);
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log(`Split into ${chunks.length} chunks`);
|
console.log(`Split into ${chunks.length} chunks`);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export const createNodeTool = tool({
|
|||||||
description: 'Create node. Description is REQUIRED and must be explicit about what the thing is (podcast, chat summary, idea, etc).',
|
description: 'Create node. Description is REQUIRED and must be explicit about what the thing is (podcast, chat summary, idea, etc).',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
title: z.string().describe('The title of the node'),
|
title: z.string().describe('The title of the node'),
|
||||||
notes: z.string().optional().describe('User notes, analysis, or thoughts about this node'),
|
|
||||||
description: z.string().max(280).describe('REQUIRED. Explicitly state WHAT this is (e.g. podcast episode, conversation summary, user insight) + WHY it matters for context grounding.'),
|
description: z.string().max(280).describe('REQUIRED. Explicitly state WHAT this is (e.g. podcast episode, conversation summary, user insight) + WHY it matters for context grounding.'),
|
||||||
|
source: z.string().optional().describe('Raw content for embedding: transcript, article text, book passages, or user thoughts. If omitted, falls back to title + description.'),
|
||||||
link: z.string().optional().describe('A URL link to the source'),
|
link: z.string().optional().describe('A URL link to the source'),
|
||||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||||
dimensions: z
|
dimensions: z
|
||||||
@@ -17,7 +17,6 @@ export const createNodeTool = tool({
|
|||||||
.max(5)
|
.max(5)
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Optional dimension tags to apply to this node (0-5 items).'),
|
.describe('Optional dimension tags to apply to this node (0-5 items).'),
|
||||||
chunk: z.string().optional().describe('Raw content for later processing - CRITICAL for extracted content from URLs'),
|
|
||||||
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
|
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
|
||||||
}),
|
}),
|
||||||
execute: async (params) => {
|
execute: async (params) => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export const getNodesByIdTool = tool({
|
|||||||
description: 'Load full node records by IDs',
|
description: 'Load full node records by IDs',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'),
|
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'),
|
||||||
includeNotesPreview: z.boolean().default(true).describe('Whether to return a trimmed content preview for each node'),
|
includeSourcePreview: z.boolean().default(true).describe('Whether to return a trimmed source preview for each node'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ nodeIds, includeNotesPreview }) => {
|
execute: async ({ nodeIds, includeSourcePreview }) => {
|
||||||
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
|
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
|
||||||
if (uniqueIds.length === 0) {
|
if (uniqueIds.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -23,8 +23,8 @@ export const getNodesByIdTool = tool({
|
|||||||
try {
|
try {
|
||||||
const node = await nodeService.getNodeById(id);
|
const node = await nodeService.getNodeById(id);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
const preview = includeNotesPreview
|
const preview = includeSourcePreview
|
||||||
? (node.notes || node.description || '')
|
? (node.source || node.description || '')
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.slice(0, 80)
|
.slice(0, 80)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -35,10 +35,12 @@ export const getNodesByIdTool = tool({
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
link: node.link,
|
link: node.link,
|
||||||
|
event_date: node.event_date ?? null,
|
||||||
dimensions: node.dimensions || [],
|
dimensions: node.dimensions || [],
|
||||||
chunk_status: node.chunk_status || 'unknown',
|
chunk_status: node.chunk_status || 'unknown',
|
||||||
|
created_at: node.created_at,
|
||||||
updated_at: node.updated_at,
|
updated_at: node.updated_at,
|
||||||
notes_preview: preview || null,
|
source_preview: preview || null,
|
||||||
metadata: node.metadata ?? null,
|
metadata: node.metadata ?? null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -48,11 +48,12 @@ export const queryDimensionNodesTool = tool({
|
|||||||
event_date: node.event_date ?? null,
|
event_date: node.event_date ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeContent && node.notes) {
|
if (includeContent && (node.source || node.notes)) {
|
||||||
|
const previewSource = node.source || node.notes || '';
|
||||||
// Truncate to ~100 chars
|
// Truncate to ~100 chars
|
||||||
formatted.notesPreview = node.notes.length > 100
|
formatted.sourcePreview = previewSource.length > 100
|
||||||
? node.notes.substring(0, 100) + '...'
|
? previewSource.substring(0, 100) + '...'
|
||||||
: node.notes;
|
: previewSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function scoreNodeForSearch(node: Node, searchTerm: string): number {
|
|||||||
const normalizedSearch = searchTerm.toLowerCase();
|
const normalizedSearch = searchTerm.toLowerCase();
|
||||||
const title = (node.title || '').toLowerCase();
|
const title = (node.title || '').toLowerCase();
|
||||||
const description = (node.description || '').toLowerCase();
|
const description = (node.description || '').toLowerCase();
|
||||||
const notes = (node.notes || '').toLowerCase();
|
const source = (node.source || '').toLowerCase();
|
||||||
const terms = extractSearchTerms(searchTerm);
|
const terms = extractSearchTerms(searchTerm);
|
||||||
|
|
||||||
let score = 0;
|
let score = 0;
|
||||||
@@ -52,23 +52,23 @@ function scoreNodeForSearch(node: Node, searchTerm: string): number {
|
|||||||
if (title === normalizedSearch) score += 100;
|
if (title === normalizedSearch) score += 100;
|
||||||
if (title.includes(normalizedSearch)) score += 40;
|
if (title.includes(normalizedSearch)) score += 40;
|
||||||
if (description.includes(normalizedSearch)) score += 20;
|
if (description.includes(normalizedSearch)) score += 20;
|
||||||
if (notes.includes(normalizedSearch)) score += 10;
|
if (source.includes(normalizedSearch)) score += 10;
|
||||||
|
|
||||||
for (const term of terms) {
|
for (const term of terms) {
|
||||||
if (title.includes(term)) score += 8;
|
if (title.includes(term)) score += 8;
|
||||||
if (description.includes(term)) score += 3;
|
if (description.includes(term)) score += 3;
|
||||||
if (notes.includes(term)) score += 2;
|
if (source.includes(term)) score += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const queryNodesTool = tool({
|
export const queryNodesTool = tool({
|
||||||
description: 'Search nodes across title, description, and notes. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.',
|
description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
filters: z.object({
|
filters: z.object({
|
||||||
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
|
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
|
||||||
search: z.string().describe('Search term to match against node title, description, or notes').optional(),
|
search: z.string().describe('Search term to match against node title, description, or source').optional(),
|
||||||
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
|
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
|
||||||
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||||
createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ export const updateNodeTool = tool({
|
|||||||
id: z.number().describe('The ID of the node to update'),
|
id: z.number().describe('The ID of the node to update'),
|
||||||
updates: z.object({
|
updates: z.object({
|
||||||
title: z.string().optional().describe('New title'),
|
title: z.string().optional().describe('New title'),
|
||||||
notes: z.string().optional().describe('User notes/analysis to append. USE THIS for workflow outputs, briefs, research notes, etc.'),
|
|
||||||
description: z.string().max(280).describe('REQUIRED on every update. Explicitly state WHAT this is + WHY it matters. No "discusses/explores".'),
|
description: z.string().max(280).describe('REQUIRED on every update. Explicitly state WHAT this is + WHY it matters. No "discusses/explores".'),
|
||||||
|
source: z.string().optional().describe('Canonical source content for embedding. Use this only to set or correct the raw source text.'),
|
||||||
link: z.string().optional().describe('New link'),
|
link: z.string().optional().describe('New link'),
|
||||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||||
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
|
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
|
||||||
chunk: z.string().optional().describe('DO NOT USE - raw source text that triggers re-embedding. Only for source corrections.'),
|
|
||||||
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
|
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
|
||||||
}).describe('Object containing the fields to update. For adding notes/analysis, always use "notes" field.')
|
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||||
}),
|
}),
|
||||||
execute: async ({ id, updates }) => {
|
execute: async ({ id, updates }) => {
|
||||||
try {
|
try {
|
||||||
@@ -44,56 +43,6 @@ export const updateNodeTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// FORCE APPEND for notes field - fetch existing and append new notes
|
|
||||||
if (updates.notes) {
|
|
||||||
const fetchResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`);
|
|
||||||
if (fetchResponse.ok) {
|
|
||||||
const { node } = await fetchResponse.json();
|
|
||||||
const existingNotes = (node?.notes || '').trim();
|
|
||||||
const newNotes = updates.notes.trim();
|
|
||||||
|
|
||||||
// Skip if new notes are identical to existing (model sent duplicate)
|
|
||||||
if (existingNotes === newNotes) {
|
|
||||||
console.log(`[updateNode] ERROR - new notes identical to existing (${existingNotes.length} chars). Model should NOT call updateNode again.`);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Notes already up to date - do not call updateNode again. Move to next step.',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if adding a section that already exists (e.g., ## Integration Analysis)
|
|
||||||
const newSectionMatch = newNotes.match(/^##\s+(.+)$/m);
|
|
||||||
if (newSectionMatch && existingNotes) {
|
|
||||||
const sectionHeader = newSectionMatch[0]; // e.g., "## Integration Analysis"
|
|
||||||
if (existingNotes.includes(sectionHeader)) {
|
|
||||||
console.log(`[updateNode] ERROR - Section "${sectionHeader}" already exists in node`);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Section "${sectionHeader}" already exists in this node. Cannot append duplicate section.`,
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if model included existing notes + new notes
|
|
||||||
if (existingNotes && newNotes.startsWith(existingNotes)) {
|
|
||||||
// Extract only the new part
|
|
||||||
const actualNewNotes = newNotes.substring(existingNotes.length).trim();
|
|
||||||
console.log(`[updateNode] Model included existing notes - extracting new part only (${actualNewNotes.length} chars)`);
|
|
||||||
const separator = existingNotes.endsWith('\n\n') ? '' : '\n\n';
|
|
||||||
updates.notes = `${existingNotes}${separator}${actualNewNotes}`;
|
|
||||||
} else if (existingNotes) {
|
|
||||||
// Normal append
|
|
||||||
const separator = existingNotes.endsWith('\n\n') ? '' : '\n\n';
|
|
||||||
updates.notes = `${existingNotes}${separator}${newNotes}`;
|
|
||||||
console.log(`[updateNode] Appended notes: ${existingNotes.length} + ${newNotes.length} = ${updates.notes.length} chars`);
|
|
||||||
} else {
|
|
||||||
console.log(`[updateNode] No existing notes, using new notes as-is (${newNotes.length} chars)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(updates.dimensions)) {
|
if (Array.isArray(updates.dimensions)) {
|
||||||
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const paperExtractTool = tool({
|
export const paperExtractTool = tool({
|
||||||
description: 'Extract a PDF or research paper into a node with summary, metadata, and full-text chunk',
|
description: 'Extract a PDF or research paper into a node with summary, metadata, and full-text source',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The PDF URL to add to inbox'),
|
url: z.string().describe('The PDF URL to add to inbox'),
|
||||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||||
@@ -95,14 +95,13 @@ export const paperExtractTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { success: boolean; notes?: string; chunk?: string; metadata?: any; error?: string };
|
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extractionResult = await extractPaper(url);
|
const extractionResult = await extractPaper(url);
|
||||||
result = {
|
result = {
|
||||||
success: true,
|
success: true,
|
||||||
notes: extractionResult.content,
|
source: extractionResult.chunk || extractionResult.content,
|
||||||
chunk: extractionResult.chunk,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
title: extractionResult.metadata.title,
|
title: extractionResult.metadata.title,
|
||||||
pages: extractionResult.metadata.pages,
|
pages: extractionResult.metadata.pages,
|
||||||
@@ -119,7 +118,7 @@ export const paperExtractTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || (!result.notes && !result.chunk)) {
|
if (!result.success || !result.source) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to extract PDF content',
|
error: result.error || 'Failed to extract PDF content',
|
||||||
@@ -132,7 +131,7 @@ export const paperExtractTool = tool({
|
|||||||
// Step 2: AI Analysis for enhanced metadata
|
// Step 2: AI Analysis for enhanced metadata
|
||||||
const aiAnalysis = await analyzeContentWithAI(
|
const aiAnalysis = await analyzeContentWithAI(
|
||||||
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
||||||
result.notes?.substring(0, 2000) || 'PDF document content',
|
result.source.substring(0, 2000) || 'PDF document content',
|
||||||
'pdf'
|
'pdf'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,17 +152,16 @@ export const paperExtractTool = tool({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
description: aiAnalysis?.nodeDescription,
|
description: aiAnalysis?.nodeDescription,
|
||||||
notes: enhancedDescription,
|
source: result.source,
|
||||||
link: url,
|
link: url,
|
||||||
dimensions: trimmedDimensions,
|
dimensions: trimmedDimensions,
|
||||||
chunk: result.chunk || result.notes,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'pdf',
|
source: 'pdf',
|
||||||
hostname: new URL(url).hostname,
|
hostname: new URL(url).hostname,
|
||||||
author: result.metadata?.author || result.metadata?.info?.Author,
|
author: result.metadata?.author || result.metadata?.info?.Author,
|
||||||
pages: result.metadata?.pages,
|
pages: result.metadata?.pages,
|
||||||
file_size: result.metadata?.file_size,
|
file_size: result.metadata?.file_size,
|
||||||
content_length: (result.chunk || result.notes)?.length,
|
content_length: result.source.length,
|
||||||
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
|
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
|
||||||
ai_analysis: aiAnalysis?.reasoning,
|
ai_analysis: aiAnalysis?.reasoning,
|
||||||
enhanced_description: enhancedDescription,
|
enhanced_description: enhancedDescription,
|
||||||
@@ -197,7 +195,7 @@ export const paperExtractTool = tool({
|
|||||||
data: {
|
data: {
|
||||||
nodeId: createResult.data?.id,
|
nodeId: createResult.data?.id,
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: (result.chunk || result.notes || '').length,
|
contentLength: result.source.length,
|
||||||
url: url,
|
url: url,
|
||||||
dimensions: actualDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const websiteExtractTool = tool({
|
export const websiteExtractTool = tool({
|
||||||
description: 'Extract website content and metadata into a node with summary, tags, and raw chunk',
|
description: 'Extract website content and metadata into a node with summary, tags, and raw source text',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The website URL to add to knowledge base'),
|
url: z.string().describe('The website URL to add to knowledge base'),
|
||||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||||
@@ -166,14 +166,13 @@ export const websiteExtractTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { success: boolean; notes?: string; chunk?: string; metadata?: any; error?: string };
|
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extractionResult = await extractWebsite(url);
|
const extractionResult = await extractWebsite(url);
|
||||||
result = {
|
result = {
|
||||||
success: true,
|
success: true,
|
||||||
notes: extractionResult.content,
|
source: extractionResult.chunk || extractionResult.content,
|
||||||
chunk: extractionResult.chunk,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
title: extractionResult.metadata.title,
|
title: extractionResult.metadata.title,
|
||||||
author: extractionResult.metadata.author,
|
author: extractionResult.metadata.author,
|
||||||
@@ -191,7 +190,7 @@ export const websiteExtractTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || (!result.notes && !result.chunk)) {
|
if (!result.success || !result.source) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to extract website content',
|
error: result.error || 'Failed to extract website content',
|
||||||
@@ -206,7 +205,7 @@ export const websiteExtractTool = tool({
|
|||||||
const contentType = inferWebsiteContentType(url);
|
const contentType = inferWebsiteContentType(url);
|
||||||
const aiAnalysis = await analyzeContentWithAI(
|
const aiAnalysis = await analyzeContentWithAI(
|
||||||
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
||||||
result.notes?.substring(0, 2000) || 'Website content',
|
result.source.substring(0, 2000) || 'Website content',
|
||||||
contentType,
|
contentType,
|
||||||
existingDimensions
|
existingDimensions
|
||||||
);
|
);
|
||||||
@@ -231,17 +230,16 @@ export const websiteExtractTool = tool({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
description: aiAnalysis?.nodeDescription,
|
description: aiAnalysis?.nodeDescription,
|
||||||
notes: enhancedDescription,
|
source: result.source,
|
||||||
link: url,
|
link: url,
|
||||||
event_date: result.metadata?.published_date || result.metadata?.date || null,
|
event_date: result.metadata?.published_date || result.metadata?.date || null,
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
chunk: result.chunk || result.notes,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
source: contentType,
|
source: contentType,
|
||||||
hostname: new URL(url).hostname,
|
hostname: new URL(url).hostname,
|
||||||
author: result.metadata?.author,
|
author: result.metadata?.author,
|
||||||
published_date: result.metadata?.published_date || result.metadata?.date,
|
published_date: result.metadata?.published_date || result.metadata?.date,
|
||||||
content_length: (result.chunk || result.notes)?.length,
|
content_length: result.source.length,
|
||||||
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
|
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
|
||||||
ai_analysis: aiAnalysis?.reasoning,
|
ai_analysis: aiAnalysis?.reasoning,
|
||||||
enhanced_description: enhancedDescription,
|
enhanced_description: enhancedDescription,
|
||||||
@@ -275,7 +273,7 @@ export const websiteExtractTool = tool({
|
|||||||
data: {
|
data: {
|
||||||
nodeId: createResult.data?.id,
|
nodeId: createResult.data?.id,
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: (result.chunk || result.notes || '').length,
|
contentLength: result.source.length,
|
||||||
url: url,
|
url: url,
|
||||||
dimensions: actualDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,15 +193,14 @@ export const youtubeExtractTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { success: boolean; notes?: string; chunk?: string; metadata?: any; error?: string };
|
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||||
|
|
||||||
console.log('📝 Using TypeScript yt-dlp extractor');
|
console.log('📝 Using TypeScript yt-dlp extractor');
|
||||||
try {
|
try {
|
||||||
const extractionResult = await extractYouTube(url);
|
const extractionResult = await extractYouTube(url);
|
||||||
result = {
|
result = {
|
||||||
success: extractionResult.success,
|
success: extractionResult.success,
|
||||||
notes: extractionResult.content,
|
source: extractionResult.chunk || extractionResult.content,
|
||||||
chunk: extractionResult.chunk,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
video_title: extractionResult.metadata.video_title,
|
video_title: extractionResult.metadata.video_title,
|
||||||
channel_name: extractionResult.metadata.channel_name,
|
channel_name: extractionResult.metadata.channel_name,
|
||||||
@@ -222,7 +221,7 @@ export const youtubeExtractTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || (!result.notes && !result.chunk)) {
|
if (!result.success || !result.source) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to extract YouTube content',
|
error: result.error || 'Failed to extract YouTube content',
|
||||||
@@ -243,8 +242,7 @@ export const youtubeExtractTool = tool({
|
|||||||
|
|
||||||
// Step 3: Create node with extracted content and AI analysis
|
// Step 3: Create node with extracted content and AI analysis
|
||||||
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
|
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
|
||||||
const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.notes || '');
|
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
|
||||||
const nodeNotes = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`;
|
|
||||||
|
|
||||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
||||||
let trimmedDimensions = suppliedDimensions
|
let trimmedDimensions = suppliedDimensions
|
||||||
@@ -262,10 +260,9 @@ export const youtubeExtractTool = tool({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
description: aiAnalysis?.nodeDescription,
|
description: aiAnalysis?.nodeDescription,
|
||||||
notes: nodeNotes,
|
source: result.source,
|
||||||
link: url,
|
link: url,
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
chunk: result.chunk || result.notes,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'youtube',
|
source: 'youtube',
|
||||||
video_id: result.metadata?.video_id,
|
video_id: result.metadata?.video_id,
|
||||||
@@ -308,7 +305,7 @@ export const youtubeExtractTool = tool({
|
|||||||
data: {
|
data: {
|
||||||
nodeId: createResult.data?.id,
|
nodeId: createResult.data?.id,
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: (result.chunk || result.notes || '').length,
|
contentLength: result.source.length,
|
||||||
url: url,
|
url: url,
|
||||||
dimensions: actualDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ export interface Node {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
notes?: string; // User's notes/thoughts about this node
|
source?: string; // Canonical embeddable content
|
||||||
|
notes?: string; // Deprecated legacy field - do not write
|
||||||
link?: string;
|
link?: string;
|
||||||
event_date?: string | null; // When the thing actually happened (ISO 8601)
|
event_date?: string | null; // When the thing actually happened (ISO 8601)
|
||||||
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
|
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
|
||||||
embedding?: Buffer; // Node-level embedding (BLOB data)
|
embedding?: Buffer; // Node-level embedding (BLOB data)
|
||||||
chunk?: string;
|
chunk?: string; // Deprecated legacy field - do not write
|
||||||
metadata?: any; // Flexible metadata storage
|
metadata?: any; // Flexible metadata storage
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -68,6 +69,7 @@ export interface NodeFilters {
|
|||||||
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
|
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
|
||||||
search?: string; // Text search in title/content
|
search?: string; // Text search in title/content
|
||||||
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
|
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
|
||||||
|
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sortBy?: 'updated' | 'edges' | 'created' | 'event_date'; // Sort by updated_at, edge count, created_at, or event_date
|
sortBy?: 'updated' | 'edges' | 'created' | 'event_date'; // Sort by updated_at, edge count, created_at, or event_date
|
||||||
|
|||||||
Reference in New Issue
Block a user