sync: Idea Genealogy + Quick Capture Overhaul
From private repo: Idea Genealogy: - Required edge explanations across UI/tools/API/MCP - Inferred edge schema (category/type/confidence) - Added part_of relationship type - Connection UI chips (Made by / Part of / Came from / Related) Quick Capture Overhaul: - Jina.ai fallback for JS-rendered sites (Twitter, SPAs) - Simplified UI: removed mode buttons, auto-detect input type - Renamed to "Add Stuff", moved to top-right - Auto-edge creation from description entities - Increased content summary length (500→1500 chars) Description Generation Fix: - Simplified prompt to "what is this" - Added link/dimensions context - Better creator attribution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
f7b8b2058c
commit
2cbc950fd4
+21
-4
@@ -27,10 +27,10 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!body.from_node_id || !body.to_node_id) {
|
if (!body.from_node_id || !body.to_node_id || typeof body.explanation !== 'string') {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Missing required fields: from_node_id and to_node_id are required'
|
error: 'Missing required fields: from_node_id, to_node_id, and explanation are required'
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +57,21 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const fromId = parseInt(body.from_node_id);
|
const fromId = parseInt(body.from_node_id);
|
||||||
const toId = parseInt(body.to_node_id);
|
const toId = parseInt(body.to_node_id);
|
||||||
|
const explanation = String(body.explanation || '').trim();
|
||||||
|
|
||||||
|
if (!explanation) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'explanation is required and cannot be empty'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipInference = Boolean(body.skip_inference);
|
||||||
|
const createdVia = (() => {
|
||||||
|
const raw = typeof body.created_via === 'string' ? body.created_via : '';
|
||||||
|
if (['ui', 'agent', 'mcp', 'workflow', 'quicklink'].includes(raw)) return raw as any;
|
||||||
|
return 'ui' as const;
|
||||||
|
})();
|
||||||
|
|
||||||
// Idempotency: prevent duplicate edges between same pair
|
// Idempotency: prevent duplicate edges between same pair
|
||||||
try {
|
try {
|
||||||
@@ -76,8 +91,10 @@ export async function POST(request: NextRequest) {
|
|||||||
const edge = await edgeService.createEdge({
|
const edge = await edgeService.createEdge({
|
||||||
from_node_id: fromId,
|
from_node_id: fromId,
|
||||||
to_node_id: toId,
|
to_node_id: toId,
|
||||||
context: body.context || {},
|
explanation,
|
||||||
source: body.source
|
created_via: createdVia,
|
||||||
|
source: body.source,
|
||||||
|
skip_inference: skipInference
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -4,6 +4,55 @@ import { generateDescription } from '@/services/database/descriptionService';
|
|||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
type NodeMetadata = { source?: string; channel_name?: string; author?: string; site_name?: string; type?: string } & Record<string, unknown>;
|
||||||
|
|
||||||
|
function parseMetadata(raw: unknown): NodeMetadata | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as NodeMetadata;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
return raw as NodeMetadata;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichYoutubeMetadataIfMissing(link: string, metadata: NodeMetadata | undefined): Promise<NodeMetadata | undefined> {
|
||||||
|
const url = link.trim();
|
||||||
|
if (!url) return metadata;
|
||||||
|
if (!url.includes('youtube.com') && !url.includes('youtu.be')) return metadata;
|
||||||
|
|
||||||
|
const existing = metadata || {};
|
||||||
|
const hasCreatorHint = Boolean(
|
||||||
|
(typeof existing.author === 'string' && existing.author.trim()) ||
|
||||||
|
(typeof existing.channel_name === 'string' && existing.channel_name.trim())
|
||||||
|
);
|
||||||
|
if (hasCreatorHint) return existing;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||||
|
const response = await fetch(oembedUrl, { signal: AbortSignal.timeout(6000) });
|
||||||
|
if (!response.ok) return existing;
|
||||||
|
const data = await response.json();
|
||||||
|
const authorName = typeof data.author_name === 'string' ? data.author_name.trim() : '';
|
||||||
|
const providerName = typeof data.provider_name === 'string' ? data.provider_name.trim() : '';
|
||||||
|
if (!authorName) return existing;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
source: typeof existing.source === 'string' && existing.source.trim().length > 0 ? existing.source : 'youtube',
|
||||||
|
channel_name: typeof existing.channel_name === 'string' && existing.channel_name.trim().length > 0 ? existing.channel_name : authorName,
|
||||||
|
site_name: typeof existing.site_name === 'string' && existing.site_name.trim().length > 0 ? existing.site_name : (providerName || 'YouTube'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -28,19 +77,25 @@ export async function POST(
|
|||||||
}, { status: 404 });
|
}, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedMetadata = parseMetadata(node.metadata);
|
||||||
|
const enrichedMetadata = node.link
|
||||||
|
? await enrichYoutubeMetadataIfMissing(node.link, parsedMetadata)
|
||||||
|
: parsedMetadata;
|
||||||
|
|
||||||
// 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,
|
||||||
content: node.content || undefined,
|
content: node.content || undefined,
|
||||||
link: node.link || undefined,
|
link: node.link || undefined,
|
||||||
metadata: node.metadata as { source?: string; channel_name?: string; author?: string; site_name?: string } | undefined,
|
metadata: enrichedMetadata,
|
||||||
type: (node.metadata as { type?: string } | null)?.type,
|
type: enrichedMetadata?.type,
|
||||||
dimensions: node.dimensions || []
|
dimensions: node.dimensions || []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the node with the new description
|
// Update the node with the new description
|
||||||
const updatedNode = await nodeService.updateNode(nodeId, {
|
const updatedNode = await nodeService.updateNode(nodeId, {
|
||||||
description: newDescription
|
description: newDescription,
|
||||||
|
metadata: enrichedMetadata ?? parsedMetadata ?? node.metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
|||||||
import { hasSufficientContent } from '@/services/embedding/constants';
|
import { hasSufficientContent } from '@/services/embedding/constants';
|
||||||
import { DimensionService } from '@/services/database/dimensionService';
|
import { DimensionService } from '@/services/database/dimensionService';
|
||||||
import { generateDescription } from '@/services/database/descriptionService';
|
import { generateDescription } from '@/services/database/descriptionService';
|
||||||
|
import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -126,6 +127,11 @@ export async function POST(request: NextRequest) {
|
|||||||
autoEmbedQueue.enqueue(node.id, { reason: 'node_created' });
|
autoEmbedQueue.enqueue(node.id, { reason: 'node_created' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule auto-edge creation (fire-and-forget, non-blocking)
|
||||||
|
if (node.id) {
|
||||||
|
scheduleAutoEdgeCreation(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: node,
|
data: node,
|
||||||
|
|||||||
+18
-21
@@ -159,8 +159,7 @@ const getNodesOutputSchema = {
|
|||||||
const createEdgeInputSchema = {
|
const createEdgeInputSchema = {
|
||||||
sourceId: z.number().int().positive().describe('Source node ID'),
|
sourceId: z.number().int().positive().describe('Source node ID'),
|
||||||
targetId: z.number().int().positive().describe('Target node ID'),
|
targetId: z.number().int().positive().describe('Target node ID'),
|
||||||
type: z.string().optional().describe('Edge type/label'),
|
explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.')
|
||||||
weight: z.number().min(0).max(1).optional().describe('Edge weight 0-1')
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEdgeOutputSchema = {
|
const createEdgeOutputSchema = {
|
||||||
@@ -191,8 +190,7 @@ const queryEdgesOutputSchema = {
|
|||||||
// rah_update_edge schemas
|
// rah_update_edge schemas
|
||||||
const updateEdgeInputSchema = {
|
const updateEdgeInputSchema = {
|
||||||
id: z.number().int().positive().describe('Edge ID to update'),
|
id: z.number().int().positive().describe('Edge ID to update'),
|
||||||
type: z.string().optional().describe('New edge type/label'),
|
explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)')
|
||||||
weight: z.number().min(0).max(1).optional().describe('New edge weight 0-1')
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEdgeOutputSchema = {
|
const updateEdgeOutputSchema = {
|
||||||
@@ -553,12 +551,13 @@ mcpServer.registerTool(
|
|||||||
inputSchema: createEdgeInputSchema,
|
inputSchema: createEdgeInputSchema,
|
||||||
outputSchema: createEdgeOutputSchema
|
outputSchema: createEdgeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ sourceId, targetId, type, weight }) => {
|
async ({ sourceId, targetId, explanation }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
source_id: sourceId,
|
from_node_id: sourceId,
|
||||||
target_id: targetId,
|
to_node_id: targetId,
|
||||||
type: type || 'related',
|
explanation: explanation.trim(),
|
||||||
weight: weight ?? 0.5
|
source: 'helper_name',
|
||||||
|
created_via: 'mcp'
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await callRaHApi('/api/edges', {
|
const result = await callRaHApi('/api/edges', {
|
||||||
@@ -602,10 +601,10 @@ mcpServer.registerTool(
|
|||||||
count: edges.length,
|
count: edges.length,
|
||||||
edges: edges.map(e => ({
|
edges: edges.map(e => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
source_id: e.source_id,
|
source_id: e.from_node_id,
|
||||||
target_id: e.target_id,
|
target_id: e.to_node_id,
|
||||||
type: e.type ?? null,
|
type: e.context?.type ?? null,
|
||||||
weight: e.weight ?? null
|
weight: typeof e.context?.confidence === 'number' ? e.context.confidence : null
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -620,18 +619,16 @@ mcpServer.registerTool(
|
|||||||
inputSchema: updateEdgeInputSchema,
|
inputSchema: updateEdgeInputSchema,
|
||||||
outputSchema: updateEdgeOutputSchema
|
outputSchema: updateEdgeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ id, type, weight }) => {
|
async ({ id, explanation }) => {
|
||||||
const payload = {};
|
if (typeof explanation !== 'string' || explanation.trim().length === 0) {
|
||||||
if (type !== undefined) payload.type = type;
|
throw new McpError(ErrorCode.InvalidParams, 'explanation is required.');
|
||||||
if (weight !== undefined) payload.weight = weight;
|
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
|
||||||
throw new McpError(ErrorCode.InvalidParams, 'At least one field (type or weight) must be provided.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/edges/${id}`, {
|
const result = await callRaHApi(`/api/edges/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify({
|
||||||
|
context: { explanation: explanation.trim(), created_via: 'mcp' }
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -108,8 +108,7 @@ const getNodesOutputSchema = {
|
|||||||
const createEdgeInputSchema = {
|
const createEdgeInputSchema = {
|
||||||
sourceId: z.number().int().positive().describe('Source node ID'),
|
sourceId: z.number().int().positive().describe('Source node ID'),
|
||||||
targetId: z.number().int().positive().describe('Target node ID'),
|
targetId: z.number().int().positive().describe('Target node ID'),
|
||||||
type: z.string().optional().describe('Edge type/label'),
|
explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.')
|
||||||
weight: z.number().min(0).max(1).optional().describe('Edge weight 0-1')
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEdgeOutputSchema = {
|
const createEdgeOutputSchema = {
|
||||||
@@ -140,8 +139,7 @@ const queryEdgesOutputSchema = {
|
|||||||
// rah_update_edge schemas
|
// rah_update_edge schemas
|
||||||
const updateEdgeInputSchema = {
|
const updateEdgeInputSchema = {
|
||||||
id: z.number().int().positive().describe('Edge ID to update'),
|
id: z.number().int().positive().describe('Edge ID to update'),
|
||||||
type: z.string().optional().describe('New edge type/label'),
|
explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)')
|
||||||
weight: z.number().min(0).max(1).optional().describe('New edge weight 0-1')
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEdgeOutputSchema = {
|
const updateEdgeOutputSchema = {
|
||||||
@@ -525,12 +523,13 @@ server.registerTool(
|
|||||||
inputSchema: createEdgeInputSchema,
|
inputSchema: createEdgeInputSchema,
|
||||||
outputSchema: createEdgeOutputSchema
|
outputSchema: createEdgeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ sourceId, targetId, type, weight }) => {
|
async ({ sourceId, targetId, explanation }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
from_node_id: sourceId,
|
from_node_id: sourceId,
|
||||||
to_node_id: targetId,
|
to_node_id: targetId,
|
||||||
type: type || 'related',
|
explanation: explanation.trim(),
|
||||||
weight: weight ?? 0.5
|
source: 'helper_name',
|
||||||
|
created_via: 'mcp'
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await callRaHApi('/api/edges', {
|
const result = await callRaHApi('/api/edges', {
|
||||||
@@ -592,18 +591,16 @@ server.registerTool(
|
|||||||
inputSchema: updateEdgeInputSchema,
|
inputSchema: updateEdgeInputSchema,
|
||||||
outputSchema: updateEdgeOutputSchema
|
outputSchema: updateEdgeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ id, type, weight }) => {
|
async ({ id, explanation }) => {
|
||||||
const payload = {};
|
if (typeof explanation !== 'string' || explanation.trim().length === 0) {
|
||||||
if (type !== undefined) payload.type = type;
|
throw new Error('explanation is required');
|
||||||
if (weight !== undefined) payload.weight = weight;
|
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
|
||||||
throw new Error('At least one field (type or weight) must be provided.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/edges/${id}`, {
|
const result = await callRaHApi(`/api/edges/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify({
|
||||||
|
context: { explanation: explanation.trim(), created_via: 'mcp' }
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+68
-8
@@ -58,18 +58,78 @@ Primary knowledge storage. Each row is a discrete knowledge item.
|
|||||||
### edges
|
### edges
|
||||||
Directed relationships between nodes (knowledge graph).
|
Directed relationships between nodes (knowledge graph).
|
||||||
|
|
||||||
**Columns:**
|
**Important behavior:**
|
||||||
|
- **Storage is directed** (`from_node_id → to_node_id`)
|
||||||
|
- **UI treats connections as bidirectional** (a node shows edges where it is either `from` or `to`)
|
||||||
|
- **Every new edge requires an explanation** (enforced in service layer)
|
||||||
|
- **Every new edge is classified** into a structured `EdgeContext` (stored as JSON)
|
||||||
|
|
||||||
|
**Columns (SQLite):**
|
||||||
- `id` (INTEGER PK)
|
- `id` (INTEGER PK)
|
||||||
- `from_node_id` (INTEGER FK → nodes.id)
|
- `from_node_id` (INTEGER FK → nodes.id) — directed “from”
|
||||||
- `to_node_id` (INTEGER FK → nodes.id)
|
- `to_node_id` (INTEGER FK → nodes.id) — directed “to”
|
||||||
- `source` (TEXT) - How edge was created (e.g., "user", "agent")
|
- `source` (TEXT) — creation source (`user`, `helper_name`, `ai_similarity`)
|
||||||
- `context` (TEXT) - Relationship context/description
|
|
||||||
- `user_feedback` (INTEGER) - User rating
|
|
||||||
- `created_at` (TEXT)
|
- `created_at` (TEXT)
|
||||||
|
- `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below)
|
||||||
|
- `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
|
||||||
- `idx_edges_to` - Fast "incoming edges" queries
|
- `idx_edges_to` — fast “incoming edges” queries
|
||||||
|
|
||||||
|
#### EdgeContext (canonical relationship metadata)
|
||||||
|
Stored as JSON in `edges.context`. This is the “Idea Genealogy” layer.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EdgeContext {
|
||||||
|
// SYSTEM-INFERRED (AI + heuristics classify from explanation + node context)
|
||||||
|
category: 'attribution' | 'intellectual';
|
||||||
|
type:
|
||||||
|
| 'created_by' // attribution: authorship/creation/founding
|
||||||
|
| 'features' // attribution: appears in / host / guest / explicitly mentioned
|
||||||
|
| 'part_of' // attribution: membership/container (episode→podcast, chapter→book, video→channel)
|
||||||
|
| 'source_of' // intellectual: idea/insight came from source
|
||||||
|
| 'extends' // intellectual: builds on
|
||||||
|
| 'supports' // intellectual: evidence for
|
||||||
|
| 'contradicts' // intellectual: in tension
|
||||||
|
| 'related_to'; // intellectual: fallback
|
||||||
|
confidence: number; // 0–1
|
||||||
|
inferred_at: string; // ISO timestamp
|
||||||
|
|
||||||
|
// PROVIDED BY USER/AGENT
|
||||||
|
explanation: string; // required; free-form text (user can edit)
|
||||||
|
|
||||||
|
// SYSTEM-MANAGED
|
||||||
|
created_via: 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Direction rule (how to write explanations)
|
||||||
|
Explanations must read correctly **FROM → TO**:
|
||||||
|
- `created_by`: **FROM** was created/authored/founded by **TO**
|
||||||
|
- `features`: **FROM** features/mentions **TO**
|
||||||
|
- `part_of`: **FROM** is part of **TO**
|
||||||
|
- `source_of`: **FROM** came from / was inspired by **TO**
|
||||||
|
|
||||||
|
#### Inference + guardrails
|
||||||
|
On edge create and on explanation edits, RA-H:
|
||||||
|
- runs lightweight **heuristics** for common phrases (e.g., “Created by …”, “Part of …”, “Came from …”, “Features …”)
|
||||||
|
- otherwise runs an AI classification step to populate `category/type/confidence`
|
||||||
|
|
||||||
|
The UI also provides 4 quick chips to reduce user cognitive load:
|
||||||
|
- **Made by** → “Created by …”
|
||||||
|
- **Part of** → “Part of …”
|
||||||
|
- **Came from** → “Came from …”
|
||||||
|
- **Related** → “Related to …”
|
||||||
|
|
||||||
|
#### Where edges get created/updated
|
||||||
|
All edge creation funnels through the service layer enforcement:
|
||||||
|
- UI (`FocusPanel`) — requires explanation; allows editing explanation (re-infers)
|
||||||
|
- REST API `POST /api/edges` — requires `explanation`
|
||||||
|
- Tooling (`createEdge` tool) — requires `explanation`
|
||||||
|
- MCP (`rah_create_edge`) — requires `explanation`
|
||||||
|
- Workflows (e.g. `connect`, `integrate`) — call `createEdge` with `explanation`
|
||||||
|
|
||||||
### chunks
|
### chunks
|
||||||
Long-form content split into searchable pieces.
|
Long-form content split into searchable pieces.
|
||||||
|
|||||||
@@ -202,11 +202,11 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
}}>
|
}}>
|
||||||
{/* Top Bar - just collapse button */}
|
{/* Top Bar - collapse button + Add Stuff */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'space-between',
|
||||||
marginBottom: '24px'
|
marginBottom: '24px'
|
||||||
}}>
|
}}>
|
||||||
{onCollapse && (
|
{onCollapse && (
|
||||||
@@ -238,6 +238,13 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
|||||||
<Minimize2 size={14} />
|
<Minimize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Add Stuff - top right */}
|
||||||
|
<div style={{ maxWidth: '200px' }}>
|
||||||
|
<QuickAddInput
|
||||||
|
activeDelegations={orderedDelegations}
|
||||||
|
onSubmit={handleQuickAddSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center Section - Start button centered */}
|
{/* Center Section - Start button centered */}
|
||||||
@@ -314,14 +321,6 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Add - at bottom, full width with padding */}
|
|
||||||
<div style={{ width: '100%', padding: '0 16px 16px 16px' }}>
|
|
||||||
<QuickAddInput
|
|
||||||
activeDelegations={orderedDelegations}
|
|
||||||
onSubmit={handleQuickAddSubmit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -413,7 +412,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
flexShrink: 0
|
flexShrink: 0
|
||||||
}}>+</span>
|
}}>+</span>
|
||||||
Capture
|
Add Stuff
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||||
|
|
||||||
type QuickAddIntent = 'link' | 'note' | 'chat';
|
|
||||||
|
|
||||||
interface QuickAddSubmitPayload {
|
interface QuickAddSubmitPayload {
|
||||||
input: string;
|
input: string;
|
||||||
mode: QuickAddIntent;
|
mode: 'link' | 'note' | 'chat';
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,63 +14,10 @@ interface QuickAddInputProps {
|
|||||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_CONFIG: Array<{
|
|
||||||
key: QuickAddIntent;
|
|
||||||
label: string;
|
|
||||||
hint: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
key: 'link',
|
|
||||||
label: 'Link',
|
|
||||||
hint: 'Drop URLs for auto extraction',
|
|
||||||
icon: (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'note',
|
|
||||||
label: 'Note',
|
|
||||||
hint: 'Quick note, no processing',
|
|
||||||
icon: (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M14 2v6h6" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M16 13H8" strokeLinecap="round" />
|
|
||||||
<path d="M16 17H8" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'chat',
|
|
||||||
label: 'Chat',
|
|
||||||
hint: 'Paste conversations',
|
|
||||||
icon: (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
|
|
||||||
const [autoMode, setAutoMode] = useState<QuickAddIntent>('link');
|
|
||||||
|
|
||||||
const effectiveMode: QuickAddIntent = manualMode ?? autoMode;
|
|
||||||
|
|
||||||
const currentPlaceholder = useMemo(() => {
|
|
||||||
if (effectiveMode === 'note') return 'Write a quick note...';
|
|
||||||
if (effectiveMode === 'chat') return 'Paste conversation...';
|
|
||||||
return 'Paste a link or write something...';
|
|
||||||
}, [effectiveMode]);
|
|
||||||
|
|
||||||
const maxConcurrent = 5;
|
const maxConcurrent = 5;
|
||||||
const activeCount = activeDelegations.filter(
|
const activeCount = activeDelegations.filter(
|
||||||
@@ -81,44 +25,17 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
).length;
|
).length;
|
||||||
const isSoftLimited = activeCount >= maxConcurrent;
|
const isSoftLimited = activeCount >= maxConcurrent;
|
||||||
|
|
||||||
const inferChatIntent = (value: string) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed || manualMode) return;
|
|
||||||
|
|
||||||
const newlineCount = (trimmed.match(/\n/g)?.length ?? 0);
|
|
||||||
const looksLikeTranscript =
|
|
||||||
newlineCount >= 2 ||
|
|
||||||
/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed);
|
|
||||||
|
|
||||||
if (looksLikeTranscript && trimmed.length > 280) {
|
|
||||||
setAutoMode('chat');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (value: string) => {
|
|
||||||
setInput(value);
|
|
||||||
inferChatIntent(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModeClick = (mode: QuickAddIntent) => {
|
|
||||||
setManualMode(mode);
|
|
||||||
setAutoMode(mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!input.trim() || isPosting || isSoftLimited) return;
|
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||||
|
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
try {
|
try {
|
||||||
|
// Mode is auto-detected server-side via quickAdd.ts detectInputType()
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
input: input.trim(),
|
input: input.trim(),
|
||||||
mode: effectiveMode,
|
mode: 'link', // Default; actual type is inferred server-side
|
||||||
description: description.trim() || undefined
|
|
||||||
});
|
});
|
||||||
setInput('');
|
setInput('');
|
||||||
setDescription('');
|
|
||||||
setManualMode(null);
|
|
||||||
setAutoMode('link');
|
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[QuickAddInput] Submit error:', error);
|
console.error('[QuickAddInput] Submit error:', error);
|
||||||
@@ -138,7 +55,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collapsed state - visible trigger
|
// Collapsed state - prominent "ADD STUFF" button
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -147,114 +64,105 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '12px',
|
gap: '8px',
|
||||||
width: '100%',
|
padding: '10px 16px',
|
||||||
padding: '16px 24px',
|
background: 'rgba(34, 197, 94, 0.1)',
|
||||||
background: 'transparent',
|
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||||
border: '1px solid #333',
|
borderRadius: '8px',
|
||||||
borderRadius: '12px',
|
color: '#22c55e',
|
||||||
color: '#888',
|
fontSize: '11px',
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.08em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s ease'
|
transition: 'all 0.2s ease',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: '0 0 12px rgba(34, 197, 94, 0.15)'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = '#444';
|
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||||
e.currentTarget.style.color = '#aaa';
|
e.currentTarget.style.borderColor = '#22c55e';
|
||||||
e.currentTarget.style.background = '#1a1a1a';
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.25)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = '#333';
|
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
|
||||||
e.currentTarget.style.color = '#888';
|
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.15)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
CAPTURE
|
|
||||||
<span style={{
|
<span style={{
|
||||||
width: '22px',
|
width: '18px',
|
||||||
height: '22px',
|
height: '18px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: '#22c55e',
|
background: '#22c55e',
|
||||||
color: '#0a0a0a',
|
color: '#0a0a0a',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '14px',
|
fontSize: '12px',
|
||||||
fontWeight: 700
|
fontWeight: 700
|
||||||
}}>+</span>
|
}}>+</span>
|
||||||
|
Add Stuff
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expanded state
|
// Expanded state - full width overlay
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '60px',
|
||||||
|
left: '20px',
|
||||||
|
right: '20px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
width: '100%',
|
background: '#0a0a0a',
|
||||||
background: 'transparent',
|
padding: '16px',
|
||||||
padding: '0',
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #2a2a2a',
|
||||||
|
zIndex: 100,
|
||||||
animation: 'fadeIn 150ms ease-out'
|
animation: 'fadeIn 150ms ease-out'
|
||||||
}}>
|
}}>
|
||||||
{/* Mode tabs */}
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
<div style={{
|
||||||
{MODE_CONFIG.map((mode) => {
|
display: 'flex',
|
||||||
const isActive = effectiveMode === mode.key;
|
alignItems: 'center',
|
||||||
return (
|
justifyContent: 'space-between'
|
||||||
<button
|
}}>
|
||||||
key={mode.key}
|
<span style={{
|
||||||
type="button"
|
color: '#22c55e',
|
||||||
onClick={() => handleModeClick(mode.key)}
|
fontSize: '11px',
|
||||||
title={mode.hint}
|
fontWeight: 600,
|
||||||
style={{
|
letterSpacing: '0.1em',
|
||||||
display: 'flex',
|
textTransform: 'uppercase',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
gap: '6px',
|
alignItems: 'center',
|
||||||
padding: '8px 14px',
|
gap: '8px'
|
||||||
borderRadius: '8px',
|
}}>
|
||||||
border: isActive ? '1px solid #333' : '1px solid transparent',
|
<span style={{
|
||||||
background: isActive ? '#1a1a1a' : 'transparent',
|
width: '18px',
|
||||||
color: isActive ? '#fff' : '#666',
|
height: '18px',
|
||||||
fontSize: '12px',
|
borderRadius: '50%',
|
||||||
fontWeight: 600,
|
background: '#22c55e',
|
||||||
cursor: 'pointer',
|
color: '#0a0a0a',
|
||||||
transition: 'all 0.15s ease',
|
display: 'flex',
|
||||||
textTransform: 'uppercase',
|
alignItems: 'center',
|
||||||
letterSpacing: '0.05em'
|
justifyContent: 'center',
|
||||||
}}
|
fontSize: '12px',
|
||||||
onMouseEnter={(e) => {
|
fontWeight: 700
|
||||||
if (!isActive) {
|
}}>+</span>
|
||||||
e.currentTarget.style.color = '#999';
|
Add Stuff
|
||||||
e.currentTarget.style.background = '#1a1a1a';
|
</span>
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.color = '#666';
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ display: 'flex', opacity: isActive ? 1 : 0.7 }}>{mode.icon}</span>
|
|
||||||
{mode.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
setInput('');
|
setInput('');
|
||||||
setDescription('');
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 'auto',
|
padding: '6px',
|
||||||
padding: '8px',
|
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
@@ -274,28 +182,29 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area - consistent height */}
|
{/* Input area - expanded */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<textarea
|
<textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => handleInputChange(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={currentPlaceholder}
|
placeholder="Paste a URL, note, or transcript — RA-H will figure out what it is."
|
||||||
disabled={isPosting || isSoftLimited}
|
disabled={isPosting || isSoftLimited}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '80px',
|
minHeight: '140px',
|
||||||
maxHeight: '200px',
|
maxHeight: '300px',
|
||||||
padding: '12px 14px',
|
padding: '14px 16px',
|
||||||
background: '#0a0a0a',
|
background: '#0a0a0a',
|
||||||
border: '1px solid #1f1f1f',
|
border: '1px solid #1f1f1f',
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
color: '#e5e5e5',
|
color: '#e5e5e5',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
resize: 'none',
|
resize: 'none',
|
||||||
|
lineHeight: '1.6',
|
||||||
transition: 'border-color 0.15s ease'
|
transition: 'border-color 0.15s ease'
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
@@ -307,51 +216,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description field - optional */}
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.value.length <= 280) {
|
|
||||||
setDescription(e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="This is a... (optional)"
|
|
||||||
disabled={isPosting || isSoftLimited}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '50px',
|
|
||||||
maxHeight: '100px',
|
|
||||||
padding: '10px 14px',
|
|
||||||
background: '#0a0a0a',
|
|
||||||
border: '1px solid #1f1f1f',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: '#a5a5a5',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
outline: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
transition: 'border-color 0.15s ease'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333';
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '8px',
|
|
||||||
right: '10px',
|
|
||||||
fontSize: '10px',
|
|
||||||
color: description.length >= 260 ? '#f59e0b' : '#525252'
|
|
||||||
}}>
|
|
||||||
{description.length}/280
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span style={{ fontSize: '11px', color: '#525252' }}>
|
<span style={{ fontSize: '11px', color: '#525252' }}>
|
||||||
@@ -361,8 +225,8 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!input.trim() || isPosting || isSoftLimited}
|
disabled={!input.trim() || isPosting || isSoftLimited}
|
||||||
style={{
|
style={{
|
||||||
width: '36px',
|
width: '38px',
|
||||||
height: '36px',
|
height: '38px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState<NodeSearchResult[]>([]);
|
const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState<NodeSearchResult[]>([]);
|
||||||
const [selectedSearchIndex, setSelectedSearchIndex] = useState(0);
|
const [selectedSearchIndex, setSelectedSearchIndex] = useState(0);
|
||||||
const [edgeExplanation, setEdgeExplanation] = useState('');
|
const [edgeExplanation, setEdgeExplanation] = useState('');
|
||||||
|
const [pendingEdgeTarget, setPendingEdgeTarget] = useState<{ id: number; title: string } | null>(null);
|
||||||
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
|
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
|
||||||
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
|
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
|
||||||
const [edgeEditingValue, setEdgeEditingValue] = useState<string>('');
|
const [edgeEditingValue, setEdgeEditingValue] = useState<string>('');
|
||||||
@@ -296,7 +297,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
from_node_id: nodeId,
|
from_node_id: nodeId,
|
||||||
to_node_id: toId,
|
to_node_id: toId,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
explanation: 'Referenced via @ mention'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@@ -345,7 +346,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
from_node_id: nodeId,
|
from_node_id: nodeId,
|
||||||
to_node_id: toId,
|
to_node_id: toId,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
explanation: 'Referenced via @ mention'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@@ -540,7 +541,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
from_node_id: sourceNodeId,
|
from_node_id: sourceNodeId,
|
||||||
to_node_id: nodeId,
|
to_node_id: nodeId,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
explanation: 'Referenced via @ mention'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -630,7 +631,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
// Edge management functions (following same patterns as node functions)
|
// Edge management functions (following same patterns as node functions)
|
||||||
|
|
||||||
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
|
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
|
||||||
createEdgeWithExplanation(targetNodeId, '');
|
setPendingEdgeTarget({ id: targetNodeId, title: _targetNodeTitle || `Node ${targetNodeId}` });
|
||||||
|
setEdgeExplanation('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle node search keyboard navigation
|
// Handle node search keyboard navigation
|
||||||
@@ -648,13 +650,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
|
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
|
||||||
createEdgeWithExplanation(suggestion.id, '');
|
setPendingEdgeTarget({ id: suggestion.id, title: suggestion.title });
|
||||||
setNodeSearchQuery('');
|
setEdgeExplanation('');
|
||||||
setNodeSearchSuggestions([]);
|
setNodeSearchSuggestions([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
||||||
if (activeNodeId === null) return;
|
if (activeNodeId === null) return;
|
||||||
|
const trimmed = (explanation || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert('Please add a short explanation for why this connection exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/edges', {
|
const response = await fetch('/api/edges', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -665,10 +672,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
from_node_id: activeNodeId,
|
from_node_id: activeNodeId,
|
||||||
to_node_id: targetNodeId,
|
to_node_id: targetNodeId,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
context: {
|
explanation: trimmed
|
||||||
explanation: explanation,
|
|
||||||
created_via: 'focus_panel'
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -682,6 +686,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
// Reset state
|
// Reset state
|
||||||
setAddingEdge(null);
|
setAddingEdge(null);
|
||||||
setEdgeExplanation('');
|
setEdgeExplanation('');
|
||||||
|
setPendingEdgeTarget(null);
|
||||||
|
setNodeSearchQuery('');
|
||||||
|
setNodeSearchSuggestions([]);
|
||||||
|
setShowConnectionsModal(false);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating edge:', error);
|
console.error('Error creating edge:', error);
|
||||||
@@ -807,14 +815,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
key={suggestion.id}
|
key={suggestion.id}
|
||||||
onClick={() => handleSelectNodeSuggestion(suggestion)}
|
onClick={() => handleSelectNodeSuggestion(suggestion)}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 16px',
|
padding: '10px 14px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||||
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
||||||
transition: 'background 100ms ease',
|
transition: 'background 100ms ease',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px'
|
gap: '10px'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (index !== selectedSearchIndex) {
|
if (index !== selectedSearchIndex) {
|
||||||
@@ -831,13 +839,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '10px',
|
fontSize: '9px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#0a0a0a',
|
color: '#0a0a0a',
|
||||||
background: '#22c55e',
|
background: '#22c55e',
|
||||||
padding: '4px 8px',
|
padding: '3px 6px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
minWidth: '28px',
|
minWidth: '24px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||||
@@ -845,7 +853,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
{suggestion.id}
|
{suggestion.id}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '15px',
|
fontSize: '13px',
|
||||||
color: '#e5e5e5',
|
color: '#e5e5e5',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@@ -861,6 +869,123 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Explanation (required) */}
|
||||||
|
{pendingEdgeTarget && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
background: '#0f0f0f',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '10px'
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 500 }}>
|
||||||
|
Connecting to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Made by', value: 'Created by ' },
|
||||||
|
{ label: 'Part of', value: 'Part of ' },
|
||||||
|
{ label: 'Came from', value: 'Came from ' },
|
||||||
|
{ label: 'Related', value: 'Related to ' },
|
||||||
|
].map((chip) => (
|
||||||
|
<button
|
||||||
|
key={chip.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEdgeExplanation((prev) => {
|
||||||
|
const trimmed = (prev || '').trim();
|
||||||
|
return trimmed.length > 0 ? prev : chip.value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
background: '#141414',
|
||||||
|
color: '#e5e5e5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#141414'; }}
|
||||||
|
title={`Prefill: ${chip.value.trim()}`}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={edgeExplanation}
|
||||||
|
onChange={(e) => setEdgeExplanation(e.target.value)}
|
||||||
|
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
resize: 'vertical',
|
||||||
|
background: '#141414',
|
||||||
|
border: '1px solid #1f1f1f',
|
||||||
|
color: '#fafafa',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '10px',
|
||||||
|
fontSize: '13px',
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setPendingEdgeTarget(null);
|
||||||
|
setEdgeExplanation('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPendingEdgeTarget(null);
|
||||||
|
setEdgeExplanation('');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: '#22c55e',
|
||||||
|
border: '1px solid #16a34a',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#737373', fontSize: '11px' }}>
|
||||||
|
Tip: press <span style={{ fontFamily: 'monospace' }}>⌘</span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing Connections */}
|
{/* Existing Connections */}
|
||||||
@@ -877,11 +1002,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}
|
}
|
||||||
const visible = edgesExpanded[activeTab] ? list : list.slice(0, 5);
|
const visible = edgesExpanded[activeTab] ? list : list.slice(0, 5);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
{visible.map((connection) => (
|
{visible.map((connection) => (
|
||||||
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
|
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
@@ -896,7 +1021,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}}>
|
}}>
|
||||||
{connection.connected_node.id}
|
{connection.connected_node.id}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#f8fafc', fontSize: '14px', fontWeight: 500 }}>{connection.connected_node.title}</span>
|
<span style={{ color: '#f8fafc', fontSize: '13px', fontWeight: 500 }}>{connection.connected_node.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{edgeEditingId === connection.edge.id ? (
|
{edgeEditingId === connection.edge.id ? (
|
||||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||||
@@ -958,8 +1083,33 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{typeof connection.edge.context?.type === 'string' && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
background: '#1f1f1f',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
textTransform: 'none'
|
||||||
|
}}>
|
||||||
|
{String(connection.edge.context.type).replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{connection.edge.context?.explanation ? (
|
{connection.edge.context?.explanation ? (
|
||||||
<span style={{ color: '#94a3b8', fontSize: '12px' }}>— {connection.edge.context.explanation}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
title={String(connection.edge.context.explanation)}
|
||||||
|
>
|
||||||
|
— {connection.edge.context.explanation}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#64748b', fontSize: '12px', fontStyle: 'italic' }}>No explanation</span>
|
<span style={{ color: '#64748b', fontSize: '12px', fontStyle: 'italic' }}>No explanation</span>
|
||||||
)}
|
)}
|
||||||
@@ -986,7 +1136,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
disabled={deletingEdge === connection.edge.id}
|
disabled={deletingEdge === connection.edge.id}
|
||||||
style={{
|
style={{
|
||||||
color: deletingEdge === connection.edge.id ? '#64748b' : '#94a3b8',
|
color: deletingEdge === connection.edge.id ? '#64748b' : '#94a3b8',
|
||||||
fontSize: '14px',
|
fontSize: '12px',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
|
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
|
||||||
@@ -2127,14 +2277,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setNodeSearchSuggestions([]);
|
setNodeSearchSuggestions([]);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 20px',
|
padding: '10px 16px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||||
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
||||||
transition: 'background 100ms ease',
|
transition: 'background 100ms ease',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '14px'
|
gap: '10px'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = '#1a1a1a';
|
e.currentTarget.style.background = '#1a1a1a';
|
||||||
@@ -2149,13 +2299,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '10px',
|
fontSize: '9px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#0a0a0a',
|
color: '#0a0a0a',
|
||||||
background: '#22c55e',
|
background: '#22c55e',
|
||||||
padding: '4px 8px',
|
padding: '3px 6px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
minWidth: '28px',
|
minWidth: '24px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||||
@@ -2163,7 +2313,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
{suggestion.id}
|
{suggestion.id}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
color: '#e5e5e5',
|
color: '#e5e5e5',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@@ -2196,6 +2346,126 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Explanation prompt (required) */}
|
||||||
|
{pendingEdgeTarget && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
background: '#141414',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '16px 18px',
|
||||||
|
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 600 }}>
|
||||||
|
Create connection to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Made by', value: 'Created by ' },
|
||||||
|
{ label: 'Part of', value: 'Part of ' },
|
||||||
|
{ label: 'Came from', value: 'Came from ' },
|
||||||
|
{ label: 'Related', value: 'Related to ' },
|
||||||
|
].map((chip) => (
|
||||||
|
<button
|
||||||
|
key={chip.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEdgeExplanation((prev) => {
|
||||||
|
const trimmed = (prev || '').trim();
|
||||||
|
return trimmed.length > 0 ? prev : chip.value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
background: '#0f0f0f',
|
||||||
|
color: '#e5e5e5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#0f0f0f'; }}
|
||||||
|
title={`Prefill: ${chip.value.trim()}`}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={edgeExplanation}
|
||||||
|
onChange={(e) => setEdgeExplanation(e.target.value)}
|
||||||
|
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
resize: 'vertical',
|
||||||
|
background: '#0f0f0f',
|
||||||
|
border: '1px solid #333',
|
||||||
|
color: '#fafafa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setPendingEdgeTarget(null);
|
||||||
|
setEdgeExplanation('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPendingEdgeTarget(null);
|
||||||
|
setEdgeExplanation('');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#262626',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#22c55e',
|
||||||
|
border: '1px solid #16a34a',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#737373', fontSize: '11px' }}>
|
||||||
|
Tip: press <span style={{ fontFamily: 'monospace' }}>⌘</span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Existing Connections */}
|
{/* Existing Connections */}
|
||||||
{!nodeSearchQuery && (
|
{!nodeSearchQuery && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -2211,7 +2481,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
minHeight: 0
|
minHeight: 0
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px 20px',
|
padding: '12px 16px',
|
||||||
borderBottom: '1px solid #1f1f1f',
|
borderBottom: '1px solid #1f1f1f',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#737373',
|
color: '#737373',
|
||||||
@@ -2236,15 +2506,15 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<div
|
<div
|
||||||
key={connection.id}
|
key={connection.id}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 20px',
|
padding: '10px 16px',
|
||||||
borderBottom: '1px solid #1f1f1f',
|
borderBottom: '1px solid #1f1f1f',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '8px'
|
gap: '6px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Connection header row */}
|
{/* Connection header row */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -2253,9 +2523,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#0a0a0a',
|
color: '#0a0a0a',
|
||||||
background: '#22c55e',
|
background: '#22c55e',
|
||||||
padding: '4px 8px',
|
padding: '3px 6px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
minWidth: '28px',
|
minWidth: '24px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||||
@@ -2266,7 +2536,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
color: '#e5e5e5',
|
color: '#e5e5e5',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -2302,7 +2572,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
</div>
|
</div>
|
||||||
{/* Description row */}
|
{/* Description row */}
|
||||||
{edgeEditingId === connection.edge.id ? (
|
{edgeEditingId === connection.edge.id ? (
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
value={edgeEditingValue}
|
value={edgeEditingValue}
|
||||||
onChange={(e) => setEdgeEditingValue(e.target.value)}
|
onChange={(e) => setEdgeEditingValue(e.target.value)}
|
||||||
@@ -2319,12 +2589,12 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
placeholder="Add explanation..."
|
placeholder="Add explanation..."
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
color: '#e5e5e5',
|
color: '#e5e5e5',
|
||||||
background: '#0a0a0a',
|
background: '#0a0a0a',
|
||||||
border: '1px solid #333',
|
border: '1px solid #333',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
padding: '8px 10px',
|
padding: '6px 8px',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
fontFamily: 'inherit'
|
fontFamily: 'inherit'
|
||||||
}}
|
}}
|
||||||
@@ -2332,7 +2602,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<button
|
<button
|
||||||
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
|
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '5px 10px',
|
||||||
background: '#22c55e',
|
background: '#22c55e',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
@@ -2347,7 +2617,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
<button
|
<button
|
||||||
onClick={cancelEditEdgeExplanation}
|
onClick={cancelEditEdgeExplanation}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '5px 10px',
|
||||||
background: '#262626',
|
background: '#262626',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
@@ -2368,12 +2638,39 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
color: connection.edge.context?.explanation ? '#888' : '#525252',
|
color: connection.edge.context?.explanation ? '#888' : '#525252',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '4px 0',
|
padding: '4px 0',
|
||||||
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic'
|
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#aaa'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#aaa'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = connection.edge.context?.explanation ? '#888' : '#525252'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.color = connection.edge.context?.explanation ? '#888' : '#525252'; }}
|
||||||
>
|
>
|
||||||
{(connection.edge.context?.explanation as string) || 'Click to add explanation...'}
|
{typeof connection.edge.context?.type === 'string' && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
background: '#0f0f0f',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{String(connection.edge.context.type).replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={String(connection.edge.context?.explanation || '')}
|
||||||
|
>
|
||||||
|
{(connection.edge.context?.explanation as string) || 'Click to add explanation...'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ WORKFLOW STEPS
|
|||||||
Call createEdge for each:
|
Call createEdge for each:
|
||||||
- from_node_id: focused node ID
|
- from_node_id: focused node ID
|
||||||
- to_node_id: related node ID
|
- to_node_id: related node ID
|
||||||
- context: { explanation: "brief reason" }
|
- explanation: "brief reason for this connection"
|
||||||
|
Direction rule: write the explanation so it reads FROM → TO.
|
||||||
|
Examples:
|
||||||
|
- Episode → Podcast: "Episode of this podcast"
|
||||||
|
- Book → Author: "Written by"
|
||||||
|
|
||||||
4. DONE
|
4. DONE
|
||||||
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
|
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ WORKFLOW STEPS
|
|||||||
For each connection found, call createEdge:
|
For each connection found, call createEdge:
|
||||||
- from_node_id: the focused node ID
|
- from_node_id: the focused node ID
|
||||||
- to_node_id: the connected node ID
|
- to_node_id: the connected node ID
|
||||||
- context: { explanation: "why this connection matters" }
|
- explanation: "why this connection matters"
|
||||||
|
Direction rule: write the explanation so it reads FROM → TO.
|
||||||
|
Examples:
|
||||||
|
- Insight → Source: "Came from / inspired by"
|
||||||
|
- Episode → Podcast: "Episode of this podcast"
|
||||||
|
|
||||||
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
|
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Auto-Edge Creation Service
|
||||||
|
*
|
||||||
|
* After Quick Capture creates a node, this service:
|
||||||
|
* 1. Extracts candidate entity strings from the node's description
|
||||||
|
* 2. Looks up existing entity nodes by exact title match
|
||||||
|
* 3. Creates edges with explanations for matches
|
||||||
|
*
|
||||||
|
* This is a "fast path" for obvious connections only - conservative by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nodeService, edgeService } from '@/services/database';
|
||||||
|
import { Node } from '@/types/database';
|
||||||
|
|
||||||
|
// Entity-like dimensions where we expect to find referenceable entities
|
||||||
|
const ENTITY_DIMENSIONS = ['people', 'companies', 'organizations', 'books', 'papers', 'articles', 'podcasts', 'creators'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a candidate entity string by removing common prefixes/suffixes.
|
||||||
|
*/
|
||||||
|
function cleanEntityCandidate(candidate: string): string {
|
||||||
|
let cleaned = candidate.trim();
|
||||||
|
|
||||||
|
// Remove common author/attribution prefixes
|
||||||
|
const prefixPatterns = [
|
||||||
|
/^by\s+/i,
|
||||||
|
/^author:\s*/i,
|
||||||
|
/^written by\s+/i,
|
||||||
|
/^from\s+/i,
|
||||||
|
/^via\s+/i,
|
||||||
|
/^featuring\s+/i,
|
||||||
|
/^with\s+/i,
|
||||||
|
/^hosted by\s+/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of prefixPatterns) {
|
||||||
|
cleaned = cleaned.replace(pattern, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract candidate entity strings from text using conservative heuristics.
|
||||||
|
* Returns proper names, quoted titles, and recognized patterns.
|
||||||
|
*/
|
||||||
|
function extractCandidateEntities(text: string): string[] {
|
||||||
|
if (!text || typeof text !== 'string') return [];
|
||||||
|
|
||||||
|
const candidates: Set<string> = new Set();
|
||||||
|
|
||||||
|
// Pattern 1: "By [Name]" pattern - common in article descriptions
|
||||||
|
// Matches: "By Simon Willison", "by Sam Altman"
|
||||||
|
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
|
||||||
|
let match;
|
||||||
|
while ((match = byPattern.exec(text)) !== null) {
|
||||||
|
const name = match[1].trim();
|
||||||
|
if (name.length >= 4 && !isGenericPhrase(name)) {
|
||||||
|
candidates.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: Proper name sequences (2-4 capitalized words)
|
||||||
|
// Matches: "Sam Altman", "Dario Amodei", "Peter Thiel"
|
||||||
|
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
|
||||||
|
while ((match = properNamePattern.exec(text)) !== null) {
|
||||||
|
const name = match[1].trim();
|
||||||
|
// Clean the candidate (remove "By ", etc.)
|
||||||
|
const cleaned = cleanEntityCandidate(name);
|
||||||
|
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
|
||||||
|
candidates.add(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: Quoted titles (single or double quotes)
|
||||||
|
// Matches: "Zero to One", 'The Lean Startup'
|
||||||
|
const quotedPattern = /["']([^"']{3,60})["']/g;
|
||||||
|
while ((match = quotedPattern.exec(text)) !== null) {
|
||||||
|
const title = match[1].trim();
|
||||||
|
if (title.length >= 3 && !isGenericPhrase(title)) {
|
||||||
|
candidates.add(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 4: Known organization patterns
|
||||||
|
// Matches: OpenAI, DeepMind, Y Combinator, Fly.io
|
||||||
|
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
|
||||||
|
while ((match = orgPattern.exec(text)) !== null) {
|
||||||
|
candidates.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a phrase is too generic to be a useful entity reference.
|
||||||
|
*/
|
||||||
|
function isGenericPhrase(phrase: string): boolean {
|
||||||
|
const normalized = phrase.toLowerCase();
|
||||||
|
|
||||||
|
// Common stopwords and generic terms
|
||||||
|
const genericTerms = [
|
||||||
|
'the author', 'the article', 'the book', 'the podcast',
|
||||||
|
'this article', 'this book', 'this podcast', 'this paper',
|
||||||
|
'new research', 'recent study', 'key points', 'main ideas',
|
||||||
|
'artificial intelligence', 'machine learning', 'deep learning',
|
||||||
|
'first section', 'last section', 'next chapter',
|
||||||
|
'united states', 'new york', 'san francisco', 'silicon valley'
|
||||||
|
];
|
||||||
|
|
||||||
|
return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up existing nodes that match candidate entity strings.
|
||||||
|
* Uses exact title matching (case-insensitive) with optional dimension filtering.
|
||||||
|
*/
|
||||||
|
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
|
||||||
|
const matches = new Map<string, Node>();
|
||||||
|
|
||||||
|
if (candidates.length === 0) return matches;
|
||||||
|
|
||||||
|
// Get all nodes (we'll filter in memory for exact title matches)
|
||||||
|
// In a larger system, we'd use a more efficient query
|
||||||
|
const allNodes = await nodeService.getNodes({ limit: 10000 });
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalizedCandidate = candidate.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Find exact title match (case-insensitive)
|
||||||
|
const matchingNode = allNodes.find(node => {
|
||||||
|
const normalizedTitle = (node.title || '').toLowerCase().trim();
|
||||||
|
if (normalizedTitle !== normalizedCandidate) return false;
|
||||||
|
|
||||||
|
// Optionally filter to entity-like dimensions for higher confidence
|
||||||
|
// But don't require it - some entities might not have dimensions yet
|
||||||
|
const nodeDimensions = node.dimensions || [];
|
||||||
|
const hasEntityDimension = nodeDimensions.some(dim =>
|
||||||
|
ENTITY_DIMENSIONS.includes(dim.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Accept if it has an entity dimension OR if it's a short title (likely a named entity)
|
||||||
|
return hasEntityDimension || node.title.length < 50;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingNode) {
|
||||||
|
matches.set(candidate, matchingNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create edges from a new node to matched entity nodes.
|
||||||
|
* Each edge includes an explanation for auditability.
|
||||||
|
*/
|
||||||
|
async function createAutoEdges(
|
||||||
|
newNodeId: number,
|
||||||
|
matches: Map<string, Node>
|
||||||
|
): Promise<number> {
|
||||||
|
let edgesCreated = 0;
|
||||||
|
|
||||||
|
for (const [candidateText, entityNode] of matches) {
|
||||||
|
// Skip self-references
|
||||||
|
if (entityNode.id === newNodeId) continue;
|
||||||
|
|
||||||
|
// Check if edge already exists
|
||||||
|
const exists = await edgeService.edgeExists(newNodeId, entityNode.id);
|
||||||
|
if (exists) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await edgeService.createEdge({
|
||||||
|
from_node_id: newNodeId,
|
||||||
|
to_node_id: entityNode.id,
|
||||||
|
explanation: `Explicitly mentioned in description: "${candidateText}"`,
|
||||||
|
created_via: 'quick_capture_auto',
|
||||||
|
source: 'ai_similarity',
|
||||||
|
skip_inference: false, // Let Idea Genealogy classify the relationship
|
||||||
|
});
|
||||||
|
edgesCreated++;
|
||||||
|
console.log(`[autoEdge] Created edge: ${newNodeId} → ${entityNode.id} (${entityNode.title})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgesCreated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point: Run auto-edge creation for a newly created node.
|
||||||
|
* This is designed to be called fire-and-forget (non-blocking).
|
||||||
|
*/
|
||||||
|
export async function runAutoEdgeCreation(nodeId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Fetch the newly created node
|
||||||
|
const node = await nodeService.getNodeById(nodeId);
|
||||||
|
if (!node) {
|
||||||
|
console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use description as the source of truth for entity extraction
|
||||||
|
const description = node.description || '';
|
||||||
|
if (!description || description.length < 10) {
|
||||||
|
console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract candidate entities from description
|
||||||
|
const candidates = extractCandidateEntities(description);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates);
|
||||||
|
|
||||||
|
// Find matching existing nodes
|
||||||
|
const matches = await findMatchingEntityNodes(candidates);
|
||||||
|
if (matches.size === 0) {
|
||||||
|
console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create edges
|
||||||
|
const edgesCreated = await createAutoEdges(nodeId, matches);
|
||||||
|
console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule auto-edge creation to run asynchronously (fire-and-forget).
|
||||||
|
* Use this from the nodes API to avoid blocking the response.
|
||||||
|
*/
|
||||||
|
export function scheduleAutoEdgeCreation(nodeId: number): void {
|
||||||
|
setImmediate(() => {
|
||||||
|
runAutoEdgeCreation(nodeId).catch(error => {
|
||||||
|
console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -48,6 +48,34 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDescriptionPrompt(input: DescriptionInput): string {
|
function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||||
|
const normalizedSource = (input.metadata?.source || '').toLowerCase();
|
||||||
|
const url = typeof input.link === 'string' ? input.link.trim() : '';
|
||||||
|
|
||||||
|
// Best-effort creator hint from structured metadata (when available),
|
||||||
|
// but never assume a particular extraction source (YouTube vs paper vs website vs note).
|
||||||
|
const creatorHint =
|
||||||
|
input.metadata?.author?.trim() ||
|
||||||
|
input.metadata?.channel_name?.trim() ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Best-effort publisher / container hint (less ideal than a true author, but better than nothing).
|
||||||
|
const publisherHint = input.metadata?.site_name?.trim() || '';
|
||||||
|
|
||||||
|
const likelyExternal =
|
||||||
|
Boolean(url) ||
|
||||||
|
normalizedSource.includes('youtube') ||
|
||||||
|
normalizedSource.includes('extract') ||
|
||||||
|
normalizedSource.includes('paper') ||
|
||||||
|
normalizedSource.includes('pdf') ||
|
||||||
|
normalizedSource.includes('website');
|
||||||
|
|
||||||
|
const likelyUserAuthored =
|
||||||
|
!likelyExternal &&
|
||||||
|
(normalizedSource.includes('quick-add-note') ||
|
||||||
|
normalizedSource.includes('quick-add-chat') ||
|
||||||
|
normalizedSource.includes('note') ||
|
||||||
|
normalizedSource.length === 0);
|
||||||
|
|
||||||
const lines: string[] = [`Title: ${input.title}`];
|
const lines: string[] = [`Title: ${input.title}`];
|
||||||
|
|
||||||
if (input.link) lines.push(`URL: ${input.link}`);
|
if (input.link) lines.push(`URL: ${input.link}`);
|
||||||
@@ -55,11 +83,27 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
|||||||
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
|
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
|
||||||
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
|
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
|
||||||
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
|
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
|
||||||
|
if (creatorHint) lines.push(`Creator hint: ${creatorHint}`);
|
||||||
|
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
|
||||||
|
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
|
||||||
|
|
||||||
const contentPreview = input.content?.slice(0, 800) || '';
|
const contentPreview = input.content?.slice(0, 800) || '';
|
||||||
if (contentPreview) lines.push(`Content: ${contentPreview}${input.content && input.content.length > 800 ? '...' : ''}`);
|
if (contentPreview) lines.push(`Content: ${contentPreview}${input.content && input.content.length > 800 ? '...' : ''}`);
|
||||||
|
|
||||||
return `Your job is to do your best to answer 'what is this' - the most simple, high level contextual information of what this thing is. Users will be adding a variety of different nodes (ideas, books, podcasts, people, papers etc). Do your best to take the available information and infer what it is - high level. If unsure, that's fine just give your best guess and say you're unsure. Max 280 characters.
|
return `Your job is to answer: "what is this?" in one short line. Max 280 characters.
|
||||||
|
|
||||||
|
GOAL: Include "who created it" when possible.
|
||||||
|
|
||||||
|
RULES (in priority order):
|
||||||
|
1) ONLY use a creator if you have a creator hint (Author/Channel) or the content explicitly says "by <X>" / "hosted by <X>".
|
||||||
|
- Do NOT treat prominent people in the title/transcript (e.g. a guest) as the creator.
|
||||||
|
- If a creator hint is provided, prefer it.
|
||||||
|
2) If you can identify a creator/author/channel/person/org from a creator hint, start with: "By <creator> — ..."
|
||||||
|
3) If it's likely user-authored, start with: "Your <thing> — ..." (don't invent a creator name).
|
||||||
|
4) If creator is unknown, do NOT guess; omit the byline.
|
||||||
|
|
||||||
|
Then, in the remainder, state what it is (video/paper/article/note/idea/etc) + what it's about (high-signal).
|
||||||
|
If unsure, say so briefly.
|
||||||
|
|
||||||
${lines.join('\n')}`;
|
${lines.join('\n')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,120 @@
|
|||||||
import { getSQLiteClient } from './sqlite-client';
|
import { getSQLiteClient } from './sqlite-client';
|
||||||
import { Edge, EdgeData, NodeConnection, Node } from '@/types/database';
|
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
|
||||||
import { eventBroadcaster } from '../events';
|
import { eventBroadcaster } from '../events';
|
||||||
|
import { nodeService } from './nodes';
|
||||||
|
import { apiKeyService } from '../storage/apiKeys';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
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'
|
||||||
|
]),
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function inferEdgeContext(params: {
|
||||||
|
explanation: string;
|
||||||
|
fromNode: Node;
|
||||||
|
toNode: Node;
|
||||||
|
}): Promise<Pick<EdgeContext, 'category' | 'type' | 'confidence'>> {
|
||||||
|
const { explanation, fromNode, toNode } = params;
|
||||||
|
|
||||||
|
// Heuristic fast-paths for the 4 core UI chips.
|
||||||
|
// 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));
|
||||||
|
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
|
||||||
|
return { category: 'attribution', type: 'created_by', confidence: 1.0 };
|
||||||
|
}
|
||||||
|
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
|
||||||
|
return { category: 'attribution', type: 'part_of', confidence: 1.0 };
|
||||||
|
}
|
||||||
|
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
|
||||||
|
return { category: 'attribution', type: 'features', confidence: 0.95 };
|
||||||
|
}
|
||||||
|
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
|
||||||
|
return { category: 'intellectual', type: 'source_of', confidence: 0.9 };
|
||||||
|
}
|
||||||
|
if (startsWithAny(['related to', 'related'])) {
|
||||||
|
return { category: 'intellectual', type: 'related_to', confidence: 0.8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = createOpenAI({ apiKey });
|
||||||
|
const prompt = [
|
||||||
|
`Given this edge explanation: "${explanation}"`,
|
||||||
|
``,
|
||||||
|
`From node:`,
|
||||||
|
`- Title: "${fromNode.title}"`,
|
||||||
|
`- Description: ${fromNode.description || 'No description available'}`,
|
||||||
|
`- Dimensions: ${fromNode.dimensions?.join(', ') || 'none'}`,
|
||||||
|
``,
|
||||||
|
`To node:`,
|
||||||
|
`- Title: "${toNode.title}"`,
|
||||||
|
`- Description: ${toNode.description || 'No description available'}`,
|
||||||
|
`- Dimensions: ${toNode.dimensions?.join(', ') || 'none'}`,
|
||||||
|
``,
|
||||||
|
`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: 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}`
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: provider('gpt-4o-mini'),
|
||||||
|
prompt,
|
||||||
|
temperature: 0.0,
|
||||||
|
maxOutputTokens: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedJson = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// Sometimes models wrap JSON in prose; try to recover.
|
||||||
|
const match = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (match) return JSON.parse(match[0]);
|
||||||
|
throw new Error('AI did not return valid JSON');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const parsed = inferredEdgeContextSchema.safeParse(parsedJson);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class EdgeService {
|
export class EdgeService {
|
||||||
async getEdges(): Promise<Edge[]> {
|
async getEdges(): Promise<Edge[]> {
|
||||||
@@ -25,13 +139,40 @@ export class EdgeService {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const sqlite = getSQLiteClient();
|
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
|
||||||
|
const [fromNode, toNode] = await Promise.all([
|
||||||
|
nodeService.getNodeById(edgeData.from_node_id),
|
||||||
|
nodeService.getNodeById(edgeData.to_node_id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
const context: EdgeContext = {
|
||||||
|
...inferred,
|
||||||
|
inferred_at: now,
|
||||||
|
explanation,
|
||||||
|
created_via: createdVia,
|
||||||
|
};
|
||||||
|
|
||||||
const result = sqlite.prepare(`
|
const result = sqlite.prepare(`
|
||||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
edgeData.from_node_id,
|
edgeData.from_node_id,
|
||||||
edgeData.to_node_id,
|
edgeData.to_node_id,
|
||||||
JSON.stringify(edgeData.context || {}),
|
JSON.stringify(context),
|
||||||
edgeData.source,
|
edgeData.source,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -67,6 +208,51 @@ export class EdgeService {
|
|||||||
const updateFields: string[] = [];
|
const updateFields: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
||||||
|
// If explanation changes, re-infer classification and write full EdgeContext
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'context') && updates.context && typeof updates.context === 'object') {
|
||||||
|
const incomingContext = updates.context as Partial<EdgeContext> & { explanation?: unknown };
|
||||||
|
if (typeof incomingContext.explanation === 'string') {
|
||||||
|
const explanation = incomingContext.explanation.trim();
|
||||||
|
if (!explanation) {
|
||||||
|
throw new Error('Edge explanation is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEdge = await this.getEdgeById(id);
|
||||||
|
if (!existingEdge) {
|
||||||
|
throw new Error(`Edge with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fromNode, toNode] = await Promise.all([
|
||||||
|
nodeService.getNodeById(existingEdge.from_node_id),
|
||||||
|
nodeService.getNodeById(existingEdge.to_node_id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!fromNode) throw new Error(`Source node ${existingEdge.from_node_id} not found`);
|
||||||
|
if (!toNode) throw new Error(`Target node ${existingEdge.to_node_id} not found`);
|
||||||
|
|
||||||
|
const inferred = await inferEdgeContext({ explanation, fromNode, toNode });
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const existingContext = (existingEdge.context && typeof existingEdge.context === 'object')
|
||||||
|
? (existingEdge.context as Partial<EdgeContext>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const created_via: EdgeCreatedVia =
|
||||||
|
(incomingContext.created_via as EdgeCreatedVia) ||
|
||||||
|
(existingContext?.created_via as EdgeCreatedVia) ||
|
||||||
|
'ui';
|
||||||
|
|
||||||
|
updates.context = {
|
||||||
|
...existingContext,
|
||||||
|
...incomingContext,
|
||||||
|
...inferred,
|
||||||
|
inferred_at: now,
|
||||||
|
explanation,
|
||||||
|
created_via,
|
||||||
|
} satisfies EdgeContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build dynamic update query
|
// Build dynamic update query
|
||||||
Object.entries(updates).forEach(([key, value]) => {
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
|
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
|
||||||
@@ -309,17 +495,23 @@ export class EdgeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createBidirectionalEdge(fromId: number, toId: number, options?: {
|
async createBidirectionalEdge(fromId: number, toId: number, options?: {
|
||||||
context?: any;
|
explanation?: string;
|
||||||
|
created_via?: EdgeCreatedVia;
|
||||||
source?: 'user' | 'ai_similarity' | 'helper_name';
|
source?: 'user' | 'ai_similarity' | 'helper_name';
|
||||||
|
skip_inference?: boolean;
|
||||||
}): Promise<Edge[]> {
|
}): Promise<Edge[]> {
|
||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
|
const explanation = (options?.explanation || 'Similarity-based connection').trim();
|
||||||
|
const created_via: EdgeCreatedVia = options?.created_via || 'workflow';
|
||||||
|
|
||||||
// Create edge from A to B
|
// Create edge from A to B
|
||||||
const forwardEdge = await this.createEdge({
|
const forwardEdge = await this.createEdge({
|
||||||
from_node_id: fromId,
|
from_node_id: fromId,
|
||||||
to_node_id: toId,
|
to_node_id: toId,
|
||||||
context: options?.context,
|
explanation,
|
||||||
source: options?.source || 'ai_similarity'
|
created_via,
|
||||||
|
source: options?.source || 'ai_similarity',
|
||||||
|
skip_inference: options?.skip_inference,
|
||||||
});
|
});
|
||||||
edges.push(forwardEdge);
|
edges.push(forwardEdge);
|
||||||
|
|
||||||
@@ -327,8 +519,10 @@ export class EdgeService {
|
|||||||
const backwardEdge = await this.createEdge({
|
const backwardEdge = await this.createEdge({
|
||||||
from_node_id: toId,
|
from_node_id: toId,
|
||||||
to_node_id: fromId,
|
to_node_id: fromId,
|
||||||
context: options?.context,
|
explanation,
|
||||||
source: options?.source || 'ai_similarity'
|
created_via,
|
||||||
|
source: options?.source || 'ai_similarity',
|
||||||
|
skip_inference: options?.skip_inference,
|
||||||
});
|
});
|
||||||
edges.push(backwardEdge);
|
edges.push(backwardEdge);
|
||||||
|
|
||||||
|
|||||||
@@ -111,13 +111,18 @@ function parseMetadata(metadata: unknown): Record<string, any> {
|
|||||||
if (!metadata) return {};
|
if (!metadata) return {};
|
||||||
if (typeof metadata === 'string') {
|
if (typeof metadata === 'string') {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(metadata);
|
const parsed = JSON.parse(metadata);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return parsed as Record<string, any>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse node metadata JSON:', error);
|
console.warn('Failed to parse node metadata JSON:', error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof metadata === 'object') {
|
if (typeof metadata === 'object') {
|
||||||
|
if (metadata === null) return {};
|
||||||
return metadata as Record<string, any>;
|
return metadata as Record<string, any>;
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -27,6 +27,46 @@ export class WebsiteExtractor {
|
|||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content using Jina.ai Reader API
|
||||||
|
* Used as fallback when cheerio fails (JS-rendered sites, Twitter, SPAs)
|
||||||
|
* Free tier: 20 requests/minute per IP (no API key needed)
|
||||||
|
*/
|
||||||
|
private async extractWithJina(url: string): Promise<ExtractionResult> {
|
||||||
|
const jinaUrl = `https://r.jina.ai/${url}`;
|
||||||
|
|
||||||
|
const response = await fetch(jinaUrl, {
|
||||||
|
headers: { 'User-Agent': 'RA-H/1.0' },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Jina extraction failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await response.text();
|
||||||
|
|
||||||
|
// Parse title from first markdown heading or first line
|
||||||
|
const titleMatch = markdown.match(/^#\s+(.+)$/m) || markdown.match(/^(.+)$/m);
|
||||||
|
const title = titleMatch?.[1]?.trim() || 'Extracted Content';
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
|
const metadata: WebsiteMetadata = {
|
||||||
|
title,
|
||||||
|
extraction_method: 'jina',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format content consistently with cheerio output
|
||||||
|
const content = this.formatContent(metadata, markdown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
chunk: markdown,
|
||||||
|
metadata,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean extracted content for better readability
|
* Clean extracted content for better readability
|
||||||
*/
|
*/
|
||||||
@@ -189,8 +229,40 @@ export class WebsiteExtractor {
|
|||||||
return sections.join('\n');
|
return sections.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content using cheerio (static HTML parser)
|
||||||
|
* Fast, free, no external calls - but fails on JS-rendered sites
|
||||||
|
*/
|
||||||
|
private async extractWithCheerio(url: string): Promise<ExtractionResult> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: this.headers,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const metadata = this.extractMetadata($);
|
||||||
|
metadata.extraction_method = 'cheerio';
|
||||||
|
const mainContent = this.extractMainContent($);
|
||||||
|
|
||||||
|
const content = this.formatContent(metadata, mainContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
chunk: mainContent,
|
||||||
|
metadata,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main extraction method
|
* Main extraction method
|
||||||
|
* Strategy: Try cheerio first (fast, free), fall back to Jina for JS-rendered sites
|
||||||
*/
|
*/
|
||||||
async extract(url: string): Promise<ExtractionResult> {
|
async extract(url: string): Promise<ExtractionResult> {
|
||||||
// Validate URL
|
// Validate URL
|
||||||
@@ -198,46 +270,28 @@ export class WebsiteExtractor {
|
|||||||
throw new Error('Invalid URL format - must start with http:// or https://');
|
throw new Error('Invalid URL format - must start with http:// or https://');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try cheerio first (fast, no external call)
|
||||||
try {
|
try {
|
||||||
// Fetch the webpage
|
const result = await this.extractWithCheerio(url);
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: this.headers,
|
|
||||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// If content is substantial, cheerio worked
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
if (result.chunk.length > 200) {
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
console.log('[WebsiteExtractor] Cheerio extraction too short, trying Jina...');
|
||||||
|
|
||||||
// Parse HTML with cheerio
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
// Extract metadata and content
|
|
||||||
const metadata = this.extractMetadata($);
|
|
||||||
// Mark extraction method for downstream metadata
|
|
||||||
metadata.extraction_method = 'typescript_cheerio';
|
|
||||||
const mainContent = this.extractMainContent($);
|
|
||||||
|
|
||||||
// Format content for display
|
|
||||||
const content = this.formatContent(metadata, mainContent);
|
|
||||||
|
|
||||||
// Chunk is the main content text
|
|
||||||
const chunk = mainContent;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
chunk,
|
|
||||||
metadata,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
throw new Error('Request timeout - website took too long to respond');
|
throw new Error('Request timeout - website took too long to respond');
|
||||||
}
|
}
|
||||||
throw error;
|
console.log('[WebsiteExtractor] Cheerio failed, trying Jina...', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Jina for JS-rendered sites
|
||||||
|
try {
|
||||||
|
return await this.extractWithJina(url);
|
||||||
|
} catch (jinaError: any) {
|
||||||
|
throw new Error(`Extraction failed: ${jinaError.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,32 @@ import { nodeService } from '@/services/database/nodes';
|
|||||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||||
|
|
||||||
export const createEdgeTool = tool({
|
export const createEdgeTool = tool({
|
||||||
description: 'Create directed relationship between nodes',
|
description:
|
||||||
|
'Create directed relationship between nodes.\n\n' +
|
||||||
|
'Direction rule: FROM node → TO node should read correctly.\n' +
|
||||||
|
'Prefer the 4 core relations unless the user clearly wants an advanced intellectual relation:\n' +
|
||||||
|
'- Made by → created_by (attribution)\n' +
|
||||||
|
'- Part of → part_of (attribution)\n' +
|
||||||
|
'- Came from → source_of (intellectual)\n' +
|
||||||
|
'- Related → related_to (intellectual fallback)\n\n' +
|
||||||
|
'Examples:\n' +
|
||||||
|
'- Episode → Podcast: "Episode of this podcast"\n' +
|
||||||
|
'- Book → Author: "Written by"\n' +
|
||||||
|
'- Company → Founder: "Founded by"\n' +
|
||||||
|
'- Insight → Source: "Came from / inspired by"\n',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
|
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
|
||||||
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
|
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
|
||||||
context: z.record(z.any()).optional().describe('Additional context about this connection - can include explanation, relationship type, strength, notes, etc.'),
|
explanation: z.string().describe(
|
||||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe('Source of this edge - use "ai" for AI-created connections, "user" for manual connections, "ai_similarity" for similarity-based connections')
|
'REQUIRED: Why does this connection exist? Be specific. ' +
|
||||||
|
'Write it as a relationship that reads FROM → TO. ' +
|
||||||
|
'Examples: "Author of this book", "Guest on this podcast", ' +
|
||||||
|
'"Episode of this podcast", "This insight came from this podcast episode", "Extends the concept introduced here"'
|
||||||
|
),
|
||||||
|
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe(
|
||||||
|
'Source of this edge. Use "ai" (or "helper_name") for AI-created connections, ' +
|
||||||
|
'"user" for manual connections, "ai_similarity" for similarity-based connections.'
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
execute: async (params) => {
|
execute: async (params) => {
|
||||||
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
|
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||||
@@ -41,6 +61,15 @@ export const createEdgeTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const explanation = (params.explanation || '').trim();
|
||||||
|
if (!explanation) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'explanation is required. Provide a clear reason for why these two nodes should be connected.',
|
||||||
|
data: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [fromNode, toNode] = await Promise.all([
|
const [fromNode, toNode] = await Promise.all([
|
||||||
nodeService.getNodeById(params.from_node_id),
|
nodeService.getNodeById(params.from_node_id),
|
||||||
nodeService.getNodeById(params.to_node_id)
|
nodeService.getNodeById(params.to_node_id)
|
||||||
@@ -84,7 +113,8 @@ export const createEdgeTool = tool({
|
|||||||
const newEdge = await edgeService.createEdge({
|
const newEdge = await edgeService.createEdge({
|
||||||
from_node_id: params.from_node_id,
|
from_node_id: params.from_node_id,
|
||||||
to_node_id: params.to_node_id,
|
to_node_id: params.to_node_id,
|
||||||
context: params.context || {},
|
explanation,
|
||||||
|
created_via: 'agent',
|
||||||
source
|
source
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ Description: "${description}"
|
|||||||
|
|
||||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||||
{
|
{
|
||||||
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
|
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
|
||||||
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
||||||
"reasoning": "Brief explanation of why you chose these categories"
|
"reasoning": "Brief explanation of why you chose these categories"
|
||||||
}
|
}
|
||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
|
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
|
||||||
|
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
|
||||||
- Include 3-8 relevant semantic tags (not just generic ones)
|
- Include 3-8 relevant semantic tags (not just generic ones)
|
||||||
- Make description informative and contextual
|
|
||||||
- For academic papers, include tags like: research, academic, paper, plus domain-specific tags
|
- For academic papers, include tags like: research, academic, paper, plus domain-specific tags
|
||||||
- For AI/ML papers, include: ai, machine-learning, artificial-intelligence, deep-learning
|
- For AI/ML papers, include: ai, machine-learning, artificial-intelligence, deep-learning
|
||||||
- For economics papers, include: economics, finance, markets, policy
|
- For economics papers, include: economics, finance, markets, policy
|
||||||
@@ -32,7 +33,7 @@ Guidelines:
|
|||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openai('gpt-5-mini'),
|
model: openai('gpt-5-mini'),
|
||||||
prompt,
|
prompt,
|
||||||
maxOutputTokens: 500
|
maxOutputTokens: 800
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = response.text || '{}';
|
let content = response.text || '{}';
|
||||||
@@ -122,7 +123,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.content?.substring(0, 1000) || 'PDF document content',
|
result.content?.substring(0, 2000) || 'PDF document content',
|
||||||
'pdf'
|
'pdf'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ Description: "${description}"
|
|||||||
|
|
||||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||||
{
|
{
|
||||||
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
|
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
|
||||||
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
||||||
"reasoning": "Brief explanation of why you chose these categories"
|
"reasoning": "Brief explanation of why you chose these categories"
|
||||||
}
|
}
|
||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
|
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
|
||||||
|
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
|
||||||
- Include 3-8 relevant semantic tags (not just generic ones)
|
- Include 3-8 relevant semantic tags (not just generic ones)
|
||||||
- Make description informative and contextual
|
|
||||||
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
|
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
|
||||||
- For economics content, include: economics, finance, markets, policy
|
- For economics content, include: economics, finance, markets, policy
|
||||||
- Be specific and insightful
|
- Be specific and insightful
|
||||||
@@ -31,7 +32,7 @@ Guidelines:
|
|||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openai('gpt-5-mini'),
|
model: openai('gpt-5-mini'),
|
||||||
prompt,
|
prompt,
|
||||||
maxOutputTokens: 500
|
maxOutputTokens: 800
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = response.text || '{}';
|
let content = response.text || '{}';
|
||||||
@@ -113,7 +114,7 @@ export const websiteExtractTool = 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 || `Website: ${new URL(url).hostname}`,
|
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
||||||
result.content?.substring(0, 500) || 'Website content',
|
result.content?.substring(0, 2000) || 'Website content',
|
||||||
'website'
|
'website'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Available types: ${AVAILABLE_SEGMENTS.join(', ')}
|
|||||||
|
|
||||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||||
{
|
{
|
||||||
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
|
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
|
||||||
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
||||||
"segments": ["research"],
|
"segments": ["research"],
|
||||||
"reasoning": "Brief explanation of why you chose these categories"
|
"reasoning": "Brief explanation of why you chose these categories"
|
||||||
@@ -30,8 +30,9 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
|
|||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
- Choose 1 segment that best fits the content type
|
- Choose 1 segment that best fits the content type
|
||||||
|
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
|
||||||
|
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
|
||||||
- Include 3-8 relevant semantic tags (not just generic ones)
|
- Include 3-8 relevant semantic tags (not just generic ones)
|
||||||
- Make description informative and contextual
|
|
||||||
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
|
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
|
||||||
- For economics content, include: economics, finance, markets, policy
|
- For economics content, include: economics, finance, markets, policy
|
||||||
- Be specific and insightful
|
- Be specific and insightful
|
||||||
@@ -40,7 +41,7 @@ Guidelines:
|
|||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openai('gpt-5-mini'),
|
model: openai('gpt-5-mini'),
|
||||||
prompt,
|
prompt,
|
||||||
maxOutputTokens: 500
|
maxOutputTokens: 800
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = response.text || '{}';
|
let content = response.text || '{}';
|
||||||
|
|||||||
+35
-3
@@ -58,10 +58,40 @@ export interface Edge {
|
|||||||
from_node_id: number;
|
from_node_id: number;
|
||||||
to_node_id: number;
|
to_node_id: number;
|
||||||
context?: any;
|
context?: any;
|
||||||
source: 'user' | 'ai_similarity' | 'helper_name';
|
source: EdgeSource;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EdgeSource = 'user' | 'ai_similarity' | 'helper_name';
|
||||||
|
|
||||||
|
export type EdgeContextCategory = 'attribution' | 'intellectual';
|
||||||
|
|
||||||
|
export type EdgeContextType =
|
||||||
|
| 'created_by'
|
||||||
|
| 'features'
|
||||||
|
| 'part_of'
|
||||||
|
| 'source_of'
|
||||||
|
| 'extends'
|
||||||
|
| 'supports'
|
||||||
|
| 'contradicts'
|
||||||
|
| 'related_to';
|
||||||
|
|
||||||
|
export type EdgeCreatedVia = 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink' | 'quick_capture_auto';
|
||||||
|
|
||||||
|
export interface EdgeContext {
|
||||||
|
// SYSTEM-INFERRED (AI classifies from explanation + nodes)
|
||||||
|
category: EdgeContextCategory;
|
||||||
|
type: EdgeContextType;
|
||||||
|
confidence: number; // 0-1
|
||||||
|
inferred_at: string; // ISO timestamp
|
||||||
|
|
||||||
|
// PROVIDED AT CREATION / EDIT
|
||||||
|
explanation: string;
|
||||||
|
|
||||||
|
// SYSTEM-MANAGED
|
||||||
|
created_via: EdgeCreatedVia;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
id: number;
|
id: number;
|
||||||
user_message?: string;
|
user_message?: string;
|
||||||
@@ -120,8 +150,10 @@ export interface ChunkData {
|
|||||||
export interface EdgeData {
|
export interface EdgeData {
|
||||||
from_node_id: number;
|
from_node_id: number;
|
||||||
to_node_id: number;
|
to_node_id: number;
|
||||||
context?: any;
|
explanation: string;
|
||||||
source: 'user' | 'ai_similarity' | 'helper_name';
|
created_via: EdgeCreatedVia;
|
||||||
|
source: EdgeSource;
|
||||||
|
skip_inference?: boolean; // reserved for bulk imports / migrations
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatData {
|
export interface ChatData {
|
||||||
|
|||||||
Reference in New Issue
Block a user