sync: Major UI updates from private repo (Jan 16-24)
Features synced: - UI Panels Refactor: flexible pane system with ChatPane, NodePane, DimensionsPane, WorkflowsPane, ViewsPane, MapPane - Source Content Reader: content type detection + 4 formatters (transcript, book, markdown, raw) - Source Content Search: Cmd+F search in Source Reader - Feed Layout Overhaul: LeftToolbar, SplitHandle, CollapsedRail - Map Panel: promoted to first-class pane - Edge policy simplification: auto-inference + direction correction New files: - src/components/panes/* (pane system) - src/components/layout/CollapsedRail.tsx - src/components/layout/LeftToolbar.tsx - src/components/layout/SplitHandle.tsx - src/components/focus/source/* (Source Reader + formatters) - src/components/views/ViewsOverlay.tsx Updated: - ThreePanelLayout.tsx (complete refactor) - FocusPanel.tsx (Source tab integration) - API routes (dimensions GET, edges auto-inference) - Database services (nodes, edges, dimensions) Dependencies added: - react-markdown - remark-gfm Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
cea1df7f1f
commit
05523b7cb2
@@ -143,7 +143,7 @@ export class DimensionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AI prompt for dimension assignment (locked + keyword dimensions)
|
||||
* Build AI prompt for dimension assignment (locked dimensions only)
|
||||
*/
|
||||
private static buildAssignmentPrompt(
|
||||
nodeData: { title: string; content?: string; link?: string; description?: string },
|
||||
@@ -171,56 +171,39 @@ CONTENT PREVIEW: ${contentPreview}${nodeData.content && nodeData.content.length
|
||||
})
|
||||
.join('\n---\n');
|
||||
|
||||
return `You are categorizing a knowledge node. You will:
|
||||
1. Assign LOCKED dimensions (from a provided list)
|
||||
2. Suggest KEYWORD dimensions (to aid searchability)
|
||||
return `You are categorizing a knowledge node into locked dimensions.
|
||||
|
||||
=== NODE TO CATEGORIZE ===
|
||||
Title: ${nodeData.title}
|
||||
${nodeContextSection}
|
||||
URL: ${nodeData.link || 'none'}
|
||||
|
||||
=== PART 1: LOCKED DIMENSIONS ===
|
||||
=== LOCKED DIMENSIONS ===
|
||||
CRITICAL: Read each dimension's DESCRIPTION carefully.
|
||||
The description defines what belongs in that dimension.
|
||||
Only assign if the content CLEARLY matches the description.
|
||||
If unsure, skip it — better to miss than assign incorrectly.
|
||||
|
||||
AVAILABLE LOCKED DIMENSIONS:
|
||||
AVAILABLE DIMENSIONS:
|
||||
${dimensionsList}
|
||||
|
||||
=== PART 2: KEYWORD DIMENSIONS ===
|
||||
Suggest 1-3 simple, lowercase keywords that:
|
||||
- Capture the ESSENCE of this content
|
||||
- Make it easily SEARCHABLE
|
||||
- Are thoughtful and aid organization
|
||||
|
||||
Good keywords: ai, podcast, paper, productivity, machine-learning, book, video, interview, tutorial, framework
|
||||
If unsure what keywords fit, respond with "none" — no noise is better than bad tags.
|
||||
|
||||
=== RESPONSE FORMAT ===
|
||||
LOCKED:
|
||||
[dimension names from the list above, one per line, or "none"]
|
||||
|
||||
KEYWORDS:
|
||||
[lowercase keyword suggestions, one per line, or "none"]`;
|
||||
[dimension names from the list above, one per line, or "none"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response and extract locked + keyword dimensions
|
||||
* Parse AI response and extract locked dimensions
|
||||
*/
|
||||
private static parseAssignmentResponse(
|
||||
response: string,
|
||||
availableDimensions: LockedDimension[]
|
||||
): { locked: string[]; keywords: string[] } {
|
||||
const lockedDimensions: string[] = [];
|
||||
const keywordDimensions: string[] = [];
|
||||
|
||||
// Split response into LOCKED and KEYWORDS sections
|
||||
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)(?=KEYWORDS:|$)/i);
|
||||
const keywordsMatch = response.match(/KEYWORDS:\s*([\s\S]*?)$/i);
|
||||
// Extract LOCKED section
|
||||
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)$/i);
|
||||
|
||||
// Parse LOCKED section
|
||||
if (lockedMatch) {
|
||||
const lockedLines = lockedMatch[1].trim().split('\n');
|
||||
for (const line of lockedLines) {
|
||||
@@ -241,31 +224,7 @@ KEYWORDS:
|
||||
}
|
||||
}
|
||||
|
||||
// Parse KEYWORDS section
|
||||
if (keywordsMatch) {
|
||||
const keywordLines = keywordsMatch[1].trim().split('\n');
|
||||
for (const line of keywordLines) {
|
||||
// Clean and normalize keyword
|
||||
const keyword = line.trim().toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '') // Only allow lowercase letters, numbers, hyphens
|
||||
.slice(0, 30); // Max 30 chars per keyword
|
||||
|
||||
if (keyword === 'none' || keyword === '' || keyword.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!keywordDimensions.includes(keyword)) {
|
||||
keywordDimensions.push(keyword);
|
||||
|
||||
// Limit to 3 keywords
|
||||
if (keywordDimensions.length >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { locked: lockedDimensions, keywords: keywordDimensions };
|
||||
return { locked: lockedDimensions, keywords: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+165
-61
@@ -8,81 +8,77 @@ import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const inferredEdgeContextSchema = z.object({
|
||||
category: z.enum(['attribution', 'intellectual']),
|
||||
type: z.enum([
|
||||
'created_by',
|
||||
'features',
|
||||
'part_of',
|
||||
'source_of',
|
||||
'extends',
|
||||
'supports',
|
||||
'contradicts',
|
||||
'related_to'
|
||||
]),
|
||||
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
|
||||
confidence: z.number().min(0).max(1),
|
||||
swap_direction: z.boolean(),
|
||||
});
|
||||
|
||||
async function inferEdgeContext(params: {
|
||||
explanation: string;
|
||||
fromNode: Node;
|
||||
toNode: Node;
|
||||
}): Promise<Pick<EdgeContext, 'category' | 'type' | 'confidence'>> {
|
||||
}): Promise<{ type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
|
||||
const { explanation, fromNode, toNode } = params;
|
||||
|
||||
// Heuristic fast-paths for the 4 core UI chips.
|
||||
// Heuristic fast-paths for common patterns.
|
||||
// This makes classification robust and reduces reliance on the model.
|
||||
const norm = explanation.trim().toLowerCase();
|
||||
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
|
||||
|
||||
// "Created by X" → FROM was created by TO (no swap needed)
|
||||
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
|
||||
return { category: 'attribution', type: 'created_by', confidence: 1.0 };
|
||||
return { type: 'created_by', confidence: 1.0, swap_direction: false };
|
||||
}
|
||||
// "Author of X" → FROM is the author, so we need TO→FROM for created_by (swap needed)
|
||||
if (startsWithAny(['author of', 'creator of', 'wrote', 'made', 'founded', 'created'])) {
|
||||
return { type: 'created_by', confidence: 1.0, swap_direction: true };
|
||||
}
|
||||
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
|
||||
return { category: 'attribution', type: 'part_of', confidence: 1.0 };
|
||||
return { type: 'part_of', confidence: 1.0, swap_direction: false };
|
||||
}
|
||||
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
|
||||
return { category: 'attribution', type: 'features', confidence: 0.95 };
|
||||
if (startsWithAny(['contains', 'includes', 'features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
|
||||
// FROM contains/features TO → TO is part of FROM (swap needed)
|
||||
return { type: 'part_of', confidence: 0.95, swap_direction: true };
|
||||
}
|
||||
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
|
||||
return { category: 'intellectual', type: 'source_of', confidence: 0.9 };
|
||||
if (startsWithAny(['came from', 'inspired by', 'derived from', 'based on', 'from', 'ideas from', 'insights from', 'ideas or insights from'])) {
|
||||
// "FROM came from TO" / "FROM has ideas from TO" → no swap needed
|
||||
return { type: 'source_of', confidence: 0.9, swap_direction: false };
|
||||
}
|
||||
if (startsWithAny(['inspired', 'source for', 'source of', 'led to'])) {
|
||||
// "FROM inspired TO" / "FROM is source of TO" → swap needed (TO came from FROM)
|
||||
return { type: 'source_of', confidence: 0.9, swap_direction: true };
|
||||
}
|
||||
if (startsWithAny(['related to', 'related'])) {
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.8 };
|
||||
return { type: 'related_to', confidence: 0.8, swap_direction: false };
|
||||
}
|
||||
|
||||
// If no API key is configured, degrade gracefully.
|
||||
// We still enforce explanation, but fall back to "related_to" classification.
|
||||
const apiKey = apiKeyService.getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.0 };
|
||||
return { type: 'related_to', confidence: 0.0, swap_direction: false };
|
||||
}
|
||||
|
||||
const provider = createOpenAI({ apiKey });
|
||||
const prompt = [
|
||||
`Given this edge explanation: "${explanation}"`,
|
||||
`Given two nodes and an explanation, determine the relationship type and direction.`,
|
||||
``,
|
||||
`From node:`,
|
||||
`- Title: "${fromNode.title}"`,
|
||||
`- Description: ${fromNode.description || 'No description available'}`,
|
||||
`- Dimensions: ${fromNode.dimensions?.join(', ') || 'none'}`,
|
||||
`FROM: "${fromNode.title}" — ${fromNode.description || 'No description'}`,
|
||||
`TO: "${toNode.title}" — ${toNode.description || 'No description'}`,
|
||||
`Explanation: "${explanation}"`,
|
||||
``,
|
||||
`To node:`,
|
||||
`- Title: "${toNode.title}"`,
|
||||
`- Description: ${toNode.description || 'No description available'}`,
|
||||
`- Dimensions: ${toNode.dimensions?.join(', ') || 'none'}`,
|
||||
`Edge types (the arrow shows required direction):`,
|
||||
`- created_by: Content → Creator (e.g., "Book" → "Author", "Article" → "Writer")`,
|
||||
`- part_of: Part → Whole (e.g., "Episode" → "Podcast", "Chapter" → "Book")`,
|
||||
`- source_of: Derivative → Source (e.g., "Insight" → "Article it came from")`,
|
||||
`- related_to: General relationship (bidirectional, no swap needed)`,
|
||||
``,
|
||||
`Classify the relationship:`,
|
||||
`- category: "attribution" (factual: author, creator, host, guest) or "intellectual" (idea relationship)`,
|
||||
`- type: one of [created_by, features, part_of, source_of, extends, supports, contradicts, related_to]`,
|
||||
`- confidence: 0-1`,
|
||||
`IMPORTANT: Check if FROM and TO match the required direction for the type.`,
|
||||
`- If FROM is a Person/Creator and TO is Content, and type is created_by → swap_direction: true`,
|
||||
`- If FROM is a Whole and TO is a Part, and type is part_of → swap_direction: true`,
|
||||
`- If FROM is a Source and TO is Derivative, and type is source_of → swap_direction: true`,
|
||||
``,
|
||||
`IMPORTANT: Interpret the direction as "FROM node → TO node". Pick a type that reads correctly in that direction:`,
|
||||
`- created_by: FROM was created/founded/authored by TO`,
|
||||
`- features: FROM features TO (host/guest/subject appearing in FROM)`,
|
||||
`- part_of: FROM is part of TO (episode→podcast, chapter→book, note→project)`,
|
||||
`- source_of: FROM came from TO / was inspired by TO`,
|
||||
`- extends/supports/contradicts: FROM extends/supports/contradicts TO`,
|
||||
``,
|
||||
`Return JSON only: {"category": "...", "type": "...", "confidence": 0.X}`
|
||||
`Return JSON: {"type": "...", "swap_direction": bool, "confidence": 0.X}`
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
@@ -106,13 +102,103 @@ async function inferEdgeContext(params: {
|
||||
|
||||
const parsed = inferredEdgeContextSchema.safeParse(parsedJson);
|
||||
if (!parsed.success) {
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
|
||||
return { type: 'related_to', confidence: 0.2, swap_direction: false };
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
console.warn('[edges] inferEdgeContext failed; falling back to related_to', error);
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
|
||||
return { type: 'related_to', confidence: 0.2, swap_direction: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-generate explanation and infer type when user doesn't provide an explanation
|
||||
async function autoInferEdge(params: {
|
||||
fromNode: Node;
|
||||
toNode: Node;
|
||||
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
|
||||
const { fromNode, toNode } = params;
|
||||
|
||||
const apiKey = apiKeyService.getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
// Fallback without AI
|
||||
return {
|
||||
explanation: `Related to ${toNode.title}`,
|
||||
type: 'related_to',
|
||||
confidence: 0.0,
|
||||
swap_direction: false,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = createOpenAI({ apiKey });
|
||||
const prompt = [
|
||||
`Given two knowledge base nodes, determine how they are related.`,
|
||||
``,
|
||||
`FROM: "${fromNode.title}"`,
|
||||
`Description: ${fromNode.description || 'No description'}`,
|
||||
``,
|
||||
`TO: "${toNode.title}"`,
|
||||
`Description: ${toNode.description || 'No description'}`,
|
||||
``,
|
||||
`Edge types (Content→Creator means the arrow goes FROM content TO creator):`,
|
||||
`- created_by: Content → Person/Creator. The content node points to its creator.`,
|
||||
`- part_of: Part → Whole (episode→podcast, chapter→book)`,
|
||||
`- source_of: Derivative → Source (summary→original, insight→article)`,
|
||||
`- related_to: DEFAULT. Similar topics, related concepts, or when unsure.`,
|
||||
``,
|
||||
`CRITICAL RULES:`,
|
||||
`1. If BOTH are documents/articles/content → use "related_to" or "source_of", NEVER "created_by"`,
|
||||
`2. If FROM is a Person and TO is Content they created → use "created_by" with swap_direction: TRUE`,
|
||||
`3. If FROM is Content and TO is the Person who created it → use "created_by" with swap_direction: FALSE`,
|
||||
`4. When unsure → use "related_to"`,
|
||||
``,
|
||||
`Return JSON: {"explanation": "...", "type": "...", "swap_direction": bool, "confidence": 0.X}`,
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: provider('gpt-4o-mini'),
|
||||
prompt,
|
||||
temperature: 0.0,
|
||||
maxOutputTokens: 150,
|
||||
});
|
||||
|
||||
const parsedJson = (() => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) return JSON.parse(match[0]);
|
||||
throw new Error('AI did not return valid JSON');
|
||||
}
|
||||
})();
|
||||
|
||||
const schema = z.object({
|
||||
explanation: z.string(),
|
||||
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
|
||||
confidence: z.number().min(0).max(1),
|
||||
swap_direction: z.boolean(),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(parsedJson);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
explanation: `Related to ${toNode.title}`,
|
||||
type: 'related_to',
|
||||
confidence: 0.2,
|
||||
swap_direction: false,
|
||||
};
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
console.warn('[edges] autoInferEdge failed; falling back', error);
|
||||
return {
|
||||
explanation: `Related to ${toNode.title}`,
|
||||
type: 'related_to',
|
||||
confidence: 0.2,
|
||||
swap_direction: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +225,6 @@ export class EdgeService {
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const explanation = (edgeData.explanation || '').trim();
|
||||
if (!explanation) {
|
||||
throw new Error('Edge explanation is required');
|
||||
}
|
||||
|
||||
const createdVia: EdgeCreatedVia = edgeData.created_via;
|
||||
|
||||
// Fetch nodes for inference context
|
||||
@@ -155,12 +236,32 @@ export class EdgeService {
|
||||
if (!fromNode) throw new Error(`Source node ${edgeData.from_node_id} not found`);
|
||||
if (!toNode) throw new Error(`Target node ${edgeData.to_node_id} not found`);
|
||||
|
||||
const inferred = edgeData.skip_inference
|
||||
? { category: 'intellectual' as const, type: 'related_to' as const, confidence: 0.0 }
|
||||
: await inferEdgeContext({ explanation, fromNode, toNode });
|
||||
let explanation = (edgeData.explanation || '').trim();
|
||||
let inferred: { type: EdgeContext['type']; confidence: number; swap_direction: boolean };
|
||||
|
||||
if (!explanation && !edgeData.skip_inference) {
|
||||
// Auto-generate explanation and infer type
|
||||
const autoResult = await autoInferEdge({ fromNode, toNode });
|
||||
explanation = autoResult.explanation;
|
||||
inferred = {
|
||||
type: autoResult.type,
|
||||
confidence: autoResult.confidence,
|
||||
swap_direction: autoResult.swap_direction,
|
||||
};
|
||||
} else if (edgeData.skip_inference) {
|
||||
inferred = { type: 'related_to' as const, confidence: 0.0, swap_direction: false };
|
||||
if (!explanation) explanation = `Related to ${toNode.title}`;
|
||||
} else {
|
||||
inferred = await inferEdgeContext({ explanation, fromNode, toNode });
|
||||
}
|
||||
|
||||
// Apply swap_direction: flip from/to if inference determined direction should be reversed
|
||||
const finalFromId = inferred.swap_direction ? edgeData.to_node_id : edgeData.from_node_id;
|
||||
const finalToId = inferred.swap_direction ? edgeData.from_node_id : edgeData.to_node_id;
|
||||
|
||||
const context: EdgeContext = {
|
||||
...inferred,
|
||||
type: inferred.type,
|
||||
confidence: inferred.confidence,
|
||||
inferred_at: now,
|
||||
explanation,
|
||||
created_via: createdVia,
|
||||
@@ -170,8 +271,8 @@ export class EdgeService {
|
||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
edgeData.from_node_id,
|
||||
edgeData.to_node_id,
|
||||
finalFromId,
|
||||
finalToId,
|
||||
JSON.stringify(context),
|
||||
edgeData.source,
|
||||
now
|
||||
@@ -184,13 +285,13 @@ export class EdgeService {
|
||||
throw new Error('Failed to create edge');
|
||||
}
|
||||
|
||||
// Broadcast edge creation event
|
||||
// Broadcast edge creation event (use final IDs from the saved edge)
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'EDGE_CREATED',
|
||||
data: {
|
||||
fromNodeId: newEdge.from_node_id,
|
||||
toNodeId: newEdge.to_node_id,
|
||||
edge: newEdge
|
||||
data: {
|
||||
fromNodeId: finalFromId,
|
||||
toNodeId: finalToId,
|
||||
edge: newEdge
|
||||
}
|
||||
});
|
||||
|
||||
@@ -242,10 +343,13 @@ export class EdgeService {
|
||||
(existingContext?.created_via as EdgeCreatedVia) ||
|
||||
'ui';
|
||||
|
||||
// Note: On update, we don't swap direction - the edge already exists with its direction.
|
||||
// We only update the type and confidence based on the new explanation.
|
||||
updates.context = {
|
||||
...existingContext,
|
||||
...incomingContext,
|
||||
...inferred,
|
||||
type: inferred.type,
|
||||
confidence: inferred.confidence,
|
||||
inferred_at: now,
|
||||
explanation,
|
||||
created_via,
|
||||
|
||||
@@ -78,10 +78,11 @@ export class NodeService {
|
||||
|
||||
const result = sqlite.query<Node & { dimensions_json: string }>(query, params);
|
||||
|
||||
// Parse dimensions_json back to array for compatibility
|
||||
// Parse dimensions_json and metadata back for compatibility
|
||||
return result.rows.map(row => ({
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -109,7 +110,8 @@ export class NodeService {
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user