sync: ingestion improvements from private repo
- Fixed dimension auto-assignment (locked dimensions now properly assigned) - Added description field as grounding context for AI - Updated embedding format: Title → Description → Content → Dimensions - Description-weighted search (5x boost) - Bug fixes: extraction tool dimension display, DimensionSearchModal closing - Removed quickLink tool (use Quick Link workflow instead) - Added regenerate-description API endpoint
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { nodeService } from '@/services/database';
|
||||||
|
import { generateDescription } from '@/services/database/descriptionService';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const nodeId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (isNaN(nodeId)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid node ID'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await nodeService.getNodeById(nodeId);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Node not found'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new description using the description service
|
||||||
|
const newDescription = await generateDescription({
|
||||||
|
title: node.title,
|
||||||
|
content: node.content || undefined,
|
||||||
|
metadata: node.metadata as { source?: string; channel_name?: string; author?: string; site_name?: string } | undefined,
|
||||||
|
type: (node.metadata as { type?: string } | null)?.type
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the node with the new description
|
||||||
|
const updatedNode = await nodeService.updateNode(nodeId, {
|
||||||
|
description: newDescription
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
node: updatedNode,
|
||||||
|
description: newDescription,
|
||||||
|
message: 'Description regenerated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating description:', error);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to regenerate description'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-3
@@ -4,6 +4,7 @@ import { Node, NodeFilters } from '@/types/database';
|
|||||||
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
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';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -60,17 +61,32 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const rawContent = typeof body.content === 'string' ? body.content : null;
|
const rawContent = typeof body.content === 'string' ? body.content : null;
|
||||||
|
|
||||||
|
// Generate description BEFORE dimension assignment (used as primary context for matching)
|
||||||
|
let nodeDescription: string | undefined;
|
||||||
|
try {
|
||||||
|
nodeDescription = await generateDescription({
|
||||||
|
title: body.title,
|
||||||
|
content: rawContent || undefined,
|
||||||
|
metadata: body.metadata,
|
||||||
|
type: body.type
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating description:', error);
|
||||||
|
// Continue without description - dimension assignment will use content as fallback
|
||||||
|
}
|
||||||
|
|
||||||
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
|
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
|
||||||
const trimmedProvidedDimensions = providedDimensions
|
const trimmedProvidedDimensions = providedDimensions
|
||||||
.map((dim: unknown) => typeof dim === 'string' ? dim.trim() : '')
|
.map((dim: unknown) => typeof dim === 'string' ? dim.trim() : '')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 5);
|
.slice(0, 8);
|
||||||
|
|
||||||
// Auto-assign locked dimensions + keyword dimensions for all new nodes
|
// Auto-assign locked dimensions + keyword dimensions for all new nodes
|
||||||
const { locked, keywords } = await DimensionService.assignDimensions({
|
const { locked, keywords } = await DimensionService.assignDimensions({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
content: rawContent || undefined,
|
content: rawContent || undefined,
|
||||||
link: body.link
|
link: body.link,
|
||||||
|
description: nodeDescription
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure keyword dimensions exist in the database (create if new)
|
// Ensure keyword dimensions exist in the database (create if new)
|
||||||
@@ -80,7 +96,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Combine provided, locked, and keyword dimensions, remove duplicates
|
// Combine provided, locked, and keyword dimensions, remove duplicates
|
||||||
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...locked, ...keywords])]
|
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...locked, ...keywords])]
|
||||||
.slice(0, 5); // Ensure we don't exceed 5 total dimensions
|
.slice(0, 8); // max 8 total
|
||||||
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
|
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
|
||||||
let chunkToStore = rawChunk;
|
let chunkToStore = rawChunk;
|
||||||
let chunkStatus: Node['chunk_status'];
|
let chunkStatus: Node['chunk_status'];
|
||||||
@@ -94,6 +110,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const node = await nodeService.createNode({
|
const node = await nodeService.createNode({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
|
description: nodeDescription,
|
||||||
content: rawContent ?? undefined,
|
content: rawContent ?? undefined,
|
||||||
link: body.link,
|
link: body.link,
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { enqueueQuickAdd, QuickAddMode } from '@/services/agents/quickAdd';
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { input, mode } = body as { input?: unknown; mode?: unknown };
|
const { input, mode, description } = body as { input?: unknown; mode?: unknown; description?: unknown };
|
||||||
|
|
||||||
if (typeof input !== 'string' || input.trim().length === 0) {
|
if (typeof input !== 'string' || input.trim().length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -16,9 +16,13 @@ export async function POST(request: NextRequest) {
|
|||||||
const normalizedMode: QuickAddMode | undefined =
|
const normalizedMode: QuickAddMode | undefined =
|
||||||
mode === 'link' || mode === 'note' || mode === 'chat' ? mode : undefined;
|
mode === 'link' || mode === 'note' || mode === 'chat' ? mode : undefined;
|
||||||
|
|
||||||
|
const normalizedDescription: string | undefined =
|
||||||
|
typeof description === 'string' && description.trim() ? description.trim() : undefined;
|
||||||
|
|
||||||
const delegation = await enqueueQuickAdd({
|
const delegation = await enqueueQuickAdd({
|
||||||
rawInput: input.trim(),
|
rawInput: input.trim(),
|
||||||
mode: normalizedMode,
|
mode: normalizedMode,
|
||||||
|
description: normalizedDescription,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, delegation });
|
return NextResponse.json({ success: true, delegation });
|
||||||
|
|||||||
@@ -168,12 +168,12 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
|||||||
|
|
||||||
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
|
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
|
||||||
|
|
||||||
const handleQuickAddSubmit = async ({ input, mode }: { input: string; mode: 'link' | 'note' | 'chat' }) => {
|
const handleQuickAddSubmit = async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/quick-add', {
|
const response = await fetch('/api/quick-add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ input, mode })
|
body: JSON.stringify({ input, mode, description })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type QuickAddIntent = 'link' | 'note' | 'chat';
|
|||||||
interface QuickAddSubmitPayload {
|
interface QuickAddSubmitPayload {
|
||||||
input: string;
|
input: string;
|
||||||
mode: QuickAddIntent;
|
mode: QuickAddIntent;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuickAddInputProps {
|
interface QuickAddInputProps {
|
||||||
@@ -60,6 +61,7 @@ const MODE_CONFIG: Array<{
|
|||||||
|
|
||||||
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 [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
|
||||||
@@ -108,8 +110,13 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
|
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
try {
|
try {
|
||||||
await onSubmit({ input: input.trim(), mode: effectiveMode });
|
await onSubmit({
|
||||||
|
input: input.trim(),
|
||||||
|
mode: effectiveMode,
|
||||||
|
description: description.trim() || undefined
|
||||||
|
});
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setDescription('');
|
||||||
setManualMode(null);
|
setManualMode(null);
|
||||||
setAutoMode('link');
|
setAutoMode('link');
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -165,6 +172,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
CAPTURE
|
||||||
<span style={{
|
<span style={{
|
||||||
width: '22px',
|
width: '22px',
|
||||||
height: '22px',
|
height: '22px',
|
||||||
@@ -177,7 +185,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: 700
|
fontWeight: 700
|
||||||
}}>+</span>
|
}}>+</span>
|
||||||
CAPTURE
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,6 +250,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setDescription('');
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
@@ -299,6 +307,51 @@ 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' }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, type DragEvent } from 'react';
|
import { useState, useEffect, useRef, type DragEvent } from 'react';
|
||||||
import { Eye, Trash2, Link, Loader, Database, Check } from 'lucide-react';
|
import { Eye, Trash2, Link, Loader, Database, Check, RefreshCw } from 'lucide-react';
|
||||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||||
import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
|
import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
|
||||||
import { Node, NodeConnection } from '@/types/database';
|
import { Node, NodeConnection } from '@/types/database';
|
||||||
@@ -87,6 +87,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
// Title expanded state for click-to-expand full title
|
// Title expanded state for click-to-expand full title
|
||||||
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
|
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
|
||||||
|
|
||||||
|
// Description regeneration state
|
||||||
|
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
|
||||||
|
|
||||||
// Fetch priority dimensions on mount
|
// Fetch priority dimensions on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPriorityDimensions();
|
fetchPriorityDimensions();
|
||||||
@@ -387,6 +390,31 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
setTimeout(attemptSave, 150);
|
setTimeout(attemptSave, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Regenerate description for a node
|
||||||
|
const regenerateDescription = async (nodeId: number) => {
|
||||||
|
setRegeneratingDescription(nodeId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/nodes/${nodeId}/regenerate-description`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to regenerate description');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.node) {
|
||||||
|
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating description:', error);
|
||||||
|
alert('Failed to regenerate description. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setRegeneratingDescription(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- @mention state ---
|
// --- @mention state ---
|
||||||
const [mentionActive, setMentionActive] = useState(false);
|
const [mentionActive, setMentionActive] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState('');
|
const [mentionQuery, setMentionQuery] = useState('');
|
||||||
@@ -814,7 +842,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||||
}}>
|
}}>
|
||||||
#{suggestion.id}
|
{suggestion.id}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
@@ -1484,7 +1512,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
}}
|
}}
|
||||||
title="Drag to chat to reference this node"
|
title="Drag to chat to reference this node"
|
||||||
>
|
>
|
||||||
#{activeTab}
|
{activeTab}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{editingField === 'title' ? (
|
{editingField === 'title' ? (
|
||||||
@@ -1559,7 +1587,116 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description Section */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '9px',
|
||||||
|
color: '#555',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => activeTab && regenerateDescription(activeTab)}
|
||||||
|
disabled={regeneratingDescription === activeTab}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '9px',
|
||||||
|
color: '#888',
|
||||||
|
cursor: regeneratingDescription === activeTab ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
opacity: regeneratingDescription === activeTab ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
title="Regenerate description using AI"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
size={10}
|
||||||
|
style={{
|
||||||
|
animation: regeneratingDescription === activeTab ? 'spin 1s linear infinite' : 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{regeneratingDescription === activeTab ? 'Regenerating...' : 'Regenerate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingField === 'description' ? (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={editingValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 280) {
|
||||||
|
setEditingValue(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
saveField();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={savingField === 'description'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '60px',
|
||||||
|
color: '#a5a5a5',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #1a1a1a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '8px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
resize: 'vertical',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="This is a..."
|
||||||
|
/>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
right: '8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: editingValue.length >= 260 ? '#f59e0b' : '#555'
|
||||||
|
}}>
|
||||||
|
{editingValue.length}/280
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => startEdit('description', nodesData[activeTab]?.description || '')}
|
||||||
|
style={{
|
||||||
|
color: nodesData[activeTab]?.description ? '#a5a5a5' : '#555',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontStyle: nodesData[activeTab]?.description ? 'normal' : 'italic',
|
||||||
|
transition: 'border-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#1a1a1a'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{nodesData[activeTab]?.description || 'Click to add description...'}
|
||||||
|
{savingField === 'description' && <span style={{ color: '#555', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -1687,7 +1824,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
onMouseDown={(e) => { e.preventDefault(); replaceMentionWithToken(n.id, n.title); }}
|
onMouseDown={(e) => { e.preventDefault(); replaceMentionWithToken(n.id, n.title); }}
|
||||||
onMouseEnter={() => setMentionIndex(idx)}
|
onMouseEnter={() => setMentionIndex(idx)}
|
||||||
style={{ padding: '6px 8px', fontSize: 12, color: '#ddd', cursor: 'pointer', background: idx === mentionIndex ? '#252525' : 'transparent', borderBottom: '1px solid #2a2a2a' }}>
|
style={{ padding: '6px 8px', fontSize: 12, color: '#ddd', cursor: 'pointer', background: idx === mentionIndex ? '#252525' : 'transparent', borderBottom: '1px solid #2a2a2a' }}>
|
||||||
<span style={{ color: '#666', marginRight: 6 }}>#{n.id}</span>
|
<span style={{ color: '#666', marginRight: 6 }}>{n.id}</span>
|
||||||
<span>{n.title.length > 60 ? n.title.slice(0,60) + '…' : n.title}</span>
|
<span>{n.title.length > 60 ? n.title.slice(0,60) + '…' : n.title}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -2023,7 +2160,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||||
}}>
|
}}>
|
||||||
#{suggestion.id}
|
{suggestion.id}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
@@ -2123,7 +2260,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||||
}}>
|
}}>
|
||||||
#{connection.connected_node.id}
|
{connection.connected_node.id}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createPortal } from 'react-dom';
|
|||||||
interface DimensionSearchModalProps {
|
interface DimensionSearchModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDimensionSelect: (dimension: string) => void;
|
onDimensionSelect: (dimension: string, description?: string) => void;
|
||||||
existingDimensions: string[];
|
existingDimensions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,9 @@ export default function DimensionSearchModal({
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [newDimensionDescription, setNewDimensionDescription] = useState('');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -95,6 +97,10 @@ export default function DimensionSearchModal({
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Track if we're in "create new" mode to avoid re-fetching
|
||||||
|
const isCreatingNew = searchQuery.trim() &&
|
||||||
|
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
|
||||||
|
|
||||||
// Fetch dimension suggestions
|
// Fetch dimension suggestions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSuggestions = async () => {
|
const fetchSuggestions = async () => {
|
||||||
@@ -129,11 +135,13 @@ export default function DimensionSearchModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen) {
|
// Only fetch when modal is open and we're not actively creating a new dimension
|
||||||
|
// (user has typed description means they're committing to create)
|
||||||
|
if (isOpen && !newDimensionDescription.trim()) {
|
||||||
const timeoutId = setTimeout(fetchSuggestions, 100);
|
const timeoutId = setTimeout(fetchSuggestions, 100);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}, [searchQuery, existingDimensions, isOpen]);
|
}, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -150,15 +158,16 @@ export default function DimensionSearchModal({
|
|||||||
// Select existing dimension
|
// Select existing dimension
|
||||||
handleSelectDimension(suggestions[selectedIndex].dimension);
|
handleSelectDimension(suggestions[selectedIndex].dimension);
|
||||||
} else if (searchQuery.trim()) {
|
} else if (searchQuery.trim()) {
|
||||||
// Create new dimension
|
// Create new dimension with description
|
||||||
handleSelectDimension(searchQuery.trim());
|
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectDimension = (dimension: string) => {
|
const handleSelectDimension = (dimension: string, description?: string) => {
|
||||||
onDimensionSelect(dimension);
|
onDimensionSelect(dimension, description);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
setNewDimensionDescription('');
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -182,7 +191,7 @@ export default function DimensionSearchModal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Search dimensions"
|
aria-label="Search dimensions"
|
||||||
>
|
>
|
||||||
<div ref={modalRef} className="search-container">
|
<div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
@@ -228,14 +237,41 @@ export default function DimensionSearchModal({
|
|||||||
|
|
||||||
{/* Create New Option */}
|
{/* Create New Option */}
|
||||||
{canCreateNew && (
|
{canCreateNew && (
|
||||||
<div className="search-create">
|
<div
|
||||||
|
className="search-create"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="create-header">
|
||||||
|
<span className="create-icon">+</span>
|
||||||
|
<span className="create-title">Create "{searchQuery.trim()}"</span>
|
||||||
|
</div>
|
||||||
|
<div className="description-input-wrapper">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={newDimensionDescription}
|
||||||
|
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
|
||||||
|
onFocus={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
placeholder="Describe what belongs in this dimension..."
|
||||||
|
className="description-input"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<span className="description-counter">{newDimensionDescription.length}/500</span>
|
||||||
|
</div>
|
||||||
|
{!newDimensionDescription.trim() && (
|
||||||
|
<div className="description-warning">
|
||||||
|
Dimensions without descriptions may not auto-assign correctly
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelectDimension(searchQuery.trim())}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||||
|
}}
|
||||||
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
||||||
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="create-icon">+</span>
|
Create Dimension
|
||||||
Create "{searchQuery.trim()}"
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -385,35 +421,98 @@ export default function DimensionSearchModal({
|
|||||||
border: 1px solid #262626;
|
border: 1px solid #262626;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 16px 20px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-title {
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-input:focus {
|
||||||
|
border-color: #525252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-input::placeholder {
|
||||||
|
color: #525252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-counter {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #525252;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-warning {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #eab308;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.create-button {
|
.create-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
padding: 16px 20px;
|
gap: 8px;
|
||||||
background: transparent;
|
padding: 12px 16px;
|
||||||
border: none;
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 100ms ease;
|
transition: background 100ms ease;
|
||||||
text-align: left;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-button:hover,
|
.create-button:hover,
|
||||||
.create-button.selected {
|
.create-button.selected {
|
||||||
background: #1a1a1a;
|
background: #262626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-icon {
|
.create-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-empty {
|
.search-empty {
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Preview */}
|
{/* Description or Content Preview */}
|
||||||
{node.content && (
|
{(node.description || node.content) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
@@ -110,11 +110,11 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
WebkitLineClamp: 3,
|
WebkitLineClamp: 2,
|
||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
marginBottom: '10px'
|
marginBottom: '10px'
|
||||||
}}>
|
}}>
|
||||||
{truncateContent(node.content)}
|
{node.description || truncateContent(node.content)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
{node.title || 'Untitled'}
|
{node.title || 'Untitled'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Preview */}
|
{/* Description or Content Preview */}
|
||||||
{node.content && (
|
{(node.description || node.content) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
@@ -114,7 +114,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
{truncateContent(node.content)}
|
{node.description || truncateContent(node.content)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ Mission:
|
|||||||
|
|
||||||
Operating principles:
|
Operating principles:
|
||||||
- Handle analysis, planning, and writes yourself; do not delegate.
|
- Handle analysis, planning, and writes yourself; do not delegate.
|
||||||
- Use quickLink to instantly find and link related nodes (fast, no reasoning needed).
|
|
||||||
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
|
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
|
||||||
|
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
|
||||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
|
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
|
||||||
- When referencing stored content, quote verbatim text in quotes and include the node citation.
|
- When referencing stored content, quote verbatim text in quotes and include the node citation.
|
||||||
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
|
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ When to ask the user:
|
|||||||
|
|
||||||
Execution approach:
|
Execution approach:
|
||||||
- Handle planning, analysis, and writes directly—do not delegate to mini ra-h during normal conversations.
|
- Handle planning, analysis, and writes directly—do not delegate to mini ra-h during normal conversations.
|
||||||
- Use quickLink to instantly find and link related nodes (fast, no AI reasoning needed—just database search).
|
|
||||||
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
|
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
|
||||||
|
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
|
||||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
|
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
|
||||||
- Treat "this conversation/paper/video" as the active focused node.
|
- Treat "this conversation/paper/video" as the active focused node.
|
||||||
- When creating synthesis nodes, createEdge to all source nodes.
|
- When creating synthesis nodes, createEdge to all source nodes.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
|
|||||||
export interface QuickAddInput {
|
export interface QuickAddInput {
|
||||||
rawInput: string;
|
rawInput: string;
|
||||||
mode?: QuickAddMode;
|
mode?: QuickAddMode;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyChatTranscript(raw: string): boolean {
|
function isLikelyChatTranscript(raw: string): boolean {
|
||||||
@@ -197,24 +198,31 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNoteQuickAdd(rawInput: string, task: string): Promise<string> {
|
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
|
||||||
const content = rawInput.trim();
|
const content = rawInput.trim();
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new Error('Input is required to create a note');
|
throw new Error('Input is required to create a note');
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = deriveNoteTitle(content);
|
const title = deriveNoteTitle(content);
|
||||||
|
const nodePayload: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
metadata: {
|
||||||
|
source: 'quick-add-note',
|
||||||
|
refined_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// If user provided a description, use it instead of auto-generating
|
||||||
|
if (userDescription && userDescription.trim()) {
|
||||||
|
nodePayload.description = userDescription.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(nodePayload),
|
||||||
title,
|
|
||||||
content,
|
|
||||||
metadata: {
|
|
||||||
source: 'quick-add-note',
|
|
||||||
refined_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawResult = await response.json();
|
const rawResult = await response.json();
|
||||||
@@ -332,7 +340,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
|
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput) {
|
||||||
const inputType = detectInputType(rawInput, mode);
|
const inputType = detectInputType(rawInput, mode);
|
||||||
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
|
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
|
||||||
const task = buildTaskPrompt(inputType, rawInput);
|
const task = buildTaskPrompt(inputType, rawInput);
|
||||||
@@ -350,7 +358,7 @@ export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
|
|||||||
|
|
||||||
let summary: string;
|
let summary: string;
|
||||||
if (inputType === 'note') {
|
if (inputType === 'note') {
|
||||||
summary = await handleNoteQuickAdd(rawInput, task);
|
summary = await handleNoteQuickAdd(rawInput, task, description);
|
||||||
} else if (inputType === 'chat') {
|
} else if (inputType === 'chat') {
|
||||||
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
|
||||||
|
export interface DescriptionInput {
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
metadata?: {
|
||||||
|
source?: string;
|
||||||
|
channel_name?: string;
|
||||||
|
author?: string;
|
||||||
|
site_name?: string;
|
||||||
|
};
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a 280-character description for a knowledge node.
|
||||||
|
* The description starts with "This is a..." and identifies the content type.
|
||||||
|
*/
|
||||||
|
export async function generateDescription(input: DescriptionInput): Promise<string> {
|
||||||
|
try {
|
||||||
|
const prompt = buildDescriptionPrompt(input);
|
||||||
|
|
||||||
|
console.log(`[DescriptionService] Generating description for: "${input.title}"`);
|
||||||
|
|
||||||
|
const response = await generateText({
|
||||||
|
model: openaiProvider('gpt-4o-mini'),
|
||||||
|
prompt,
|
||||||
|
maxOutputTokens: 100,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = response.text.trim();
|
||||||
|
|
||||||
|
// Ensure it starts with "This is a" and is within limit
|
||||||
|
const finalDescription = description.slice(0, 280);
|
||||||
|
|
||||||
|
console.log(`[DescriptionService] Generated: "${finalDescription}"`);
|
||||||
|
|
||||||
|
return finalDescription;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DescriptionService] Error generating description:', error);
|
||||||
|
// Return a fallback description
|
||||||
|
return `This is a ${input.type || 'knowledge item'} titled "${input.title.slice(0, 200)}".`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||||
|
const metadataLines: string[] = [];
|
||||||
|
|
||||||
|
if (input.metadata?.source) {
|
||||||
|
metadataLines.push(`Source: ${input.metadata.source}`);
|
||||||
|
}
|
||||||
|
if (input.metadata?.channel_name) {
|
||||||
|
metadataLines.push(`Channel: ${input.metadata.channel_name}`);
|
||||||
|
}
|
||||||
|
if (input.metadata?.author) {
|
||||||
|
metadataLines.push(`Author: ${input.metadata.author}`);
|
||||||
|
}
|
||||||
|
if (input.metadata?.site_name) {
|
||||||
|
metadataLines.push(`Site: ${input.metadata.site_name}`);
|
||||||
|
}
|
||||||
|
if (input.type) {
|
||||||
|
metadataLines.push(`Type: ${input.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentPreview = input.content?.slice(0, 500) || '';
|
||||||
|
|
||||||
|
return `Generate a concise description (max 280 characters) for this knowledge item.
|
||||||
|
|
||||||
|
CRITICAL REQUIREMENTS:
|
||||||
|
- Start with "This is a..."
|
||||||
|
- Identify the content type (article, video, paper, podcast episode, tweet, book, tutorial, etc.)
|
||||||
|
- Be specific about what the content covers
|
||||||
|
- Maximum 280 characters total
|
||||||
|
|
||||||
|
=== KNOWLEDGE ITEM ===
|
||||||
|
Title: ${input.title}
|
||||||
|
${metadataLines.length > 0 ? metadataLines.join('\n') : ''}
|
||||||
|
${contentPreview ? `\nContent preview: ${contentPreview}${input.content && input.content.length > 500 ? '...' : ''}` : ''}
|
||||||
|
|
||||||
|
=== YOUR RESPONSE ===
|
||||||
|
Write ONLY the description, nothing else. Start with "This is a..."`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const descriptionService = {
|
||||||
|
generateDescription
|
||||||
|
};
|
||||||
@@ -53,6 +53,7 @@ export class DimensionService {
|
|||||||
title: string;
|
title: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
|
description?: string;
|
||||||
}): Promise<{ locked: string[]; keywords: string[] }> {
|
}): Promise<{ locked: string[]; keywords: string[] }> {
|
||||||
try {
|
try {
|
||||||
const lockedDimensions = await this.getLockedDimensions();
|
const lockedDimensions = await this.getLockedDimensions();
|
||||||
@@ -69,7 +70,7 @@ export class DimensionService {
|
|||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openaiProvider('gpt-4o-mini'),
|
model: openaiProvider('gpt-4o-mini'),
|
||||||
prompt,
|
prompt,
|
||||||
maxOutputTokens: 150, // Increased for two-part response
|
maxOutputTokens: 300, // Increased to accommodate more dimensions
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,16 +146,29 @@ export class DimensionService {
|
|||||||
* Build AI prompt for dimension assignment (locked + keyword dimensions)
|
* Build AI prompt for dimension assignment (locked + keyword dimensions)
|
||||||
*/
|
*/
|
||||||
private static buildAssignmentPrompt(
|
private static buildAssignmentPrompt(
|
||||||
nodeData: { title: string; content?: string; link?: string },
|
nodeData: { title: string; content?: string; link?: string; description?: string },
|
||||||
lockedDimensions: LockedDimension[]
|
lockedDimensions: LockedDimension[]
|
||||||
): string {
|
): string {
|
||||||
const contentPreview = nodeData.content?.slice(0, 1000) || '';
|
// Use description as primary context, content as fallback
|
||||||
|
let nodeContextSection: string;
|
||||||
|
if (nodeData.description) {
|
||||||
|
const contentPreview = nodeData.content?.slice(0, 500) || '';
|
||||||
|
nodeContextSection = `DESCRIPTION: ${nodeData.description}
|
||||||
|
|
||||||
// Only include dimensions that have descriptions
|
CONTENT PREVIEW: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}`;
|
||||||
const dimensionsWithDescriptions = lockedDimensions.filter(d => d.description && d.description.trim().length > 0);
|
} else {
|
||||||
|
const contentPreview = nodeData.content?.slice(0, 2000) || '';
|
||||||
|
nodeContextSection = `CONTENT: ${contentPreview}${nodeData.content && nodeData.content.length > 2000 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
const dimensionsList = dimensionsWithDescriptions
|
// Include ALL locked dimensions, using fallback text for those without descriptions
|
||||||
.map(d => `DIMENSION: "${d.name}"\nDESCRIPTION: ${d.description}`)
|
const dimensionsList = lockedDimensions
|
||||||
|
.map(d => {
|
||||||
|
const description = d.description && d.description.trim().length > 0
|
||||||
|
? d.description
|
||||||
|
: '(none - infer from name)';
|
||||||
|
return `DIMENSION: "${d.name}"\nDESCRIPTION: ${description}`;
|
||||||
|
})
|
||||||
.join('\n---\n');
|
.join('\n---\n');
|
||||||
|
|
||||||
return `You are categorizing a knowledge node. You will:
|
return `You are categorizing a knowledge node. You will:
|
||||||
@@ -163,7 +177,7 @@ export class DimensionService {
|
|||||||
|
|
||||||
=== NODE TO CATEGORIZE ===
|
=== NODE TO CATEGORIZE ===
|
||||||
Title: ${nodeData.title}
|
Title: ${nodeData.title}
|
||||||
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 1000 ? '...' : ''}
|
${nodeContextSection}
|
||||||
URL: ${nodeData.link || 'none'}
|
URL: ${nodeData.link || 'none'}
|
||||||
|
|
||||||
=== PART 1: LOCKED DIMENSIONS ===
|
=== PART 1: LOCKED DIMENSIONS ===
|
||||||
@@ -171,7 +185,6 @@ CRITICAL: Read each dimension's DESCRIPTION carefully.
|
|||||||
The description defines what belongs in that dimension.
|
The description defines what belongs in that dimension.
|
||||||
Only assign if the content CLEARLY matches the description.
|
Only assign if the content CLEARLY matches the description.
|
||||||
If unsure, skip it — better to miss than assign incorrectly.
|
If unsure, skip it — better to miss than assign incorrectly.
|
||||||
Maximum 3 locked dimensions.
|
|
||||||
|
|
||||||
AVAILABLE LOCKED DIMENSIONS:
|
AVAILABLE LOCKED DIMENSIONS:
|
||||||
${dimensionsList}
|
${dimensionsList}
|
||||||
@@ -224,11 +237,6 @@ KEYWORDS:
|
|||||||
|
|
||||||
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
|
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
|
||||||
lockedDimensions.push(matchedDimension.name);
|
lockedDimensions.push(matchedDimension.name);
|
||||||
|
|
||||||
// Limit to 3 locked dimensions
|
|
||||||
if (lockedDimensions.length >= 3) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,25 +36,27 @@ export class NodeService {
|
|||||||
params.push(...dimensions);
|
params.push(...dimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text search in title and content (SQLite LIKE with COLLATE NOCASE)
|
// Text search in title, description, and content (SQLite LIKE with COLLATE NOCASE)
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
|
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
|
||||||
params.push(`%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting logic
|
// Sorting logic
|
||||||
if (search) {
|
if (search) {
|
||||||
// For search queries, prioritize by relevance: exact title → starts with → contains in title → content
|
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → content
|
||||||
query += ` ORDER BY
|
query += ` ORDER BY
|
||||||
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 5 END,
|
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
|
||||||
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 5 END,
|
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
|
||||||
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 5 END,
|
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
|
||||||
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 4 ELSE 5 END,
|
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
|
||||||
|
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
|
||||||
n.updated_at DESC`;
|
n.updated_at DESC`;
|
||||||
params.push(
|
params.push(
|
||||||
search, // Exact match (case-insensitive)
|
search, // Exact match (case-insensitive)
|
||||||
`${search}%`, // Starts with search term
|
`${search}%`, // Starts with search term
|
||||||
`%${search}%`, // Contains in title
|
`%${search}%`, // Contains in title
|
||||||
|
`%${search}%`, // Contains in description
|
||||||
`%${search}%` // Contains in content
|
`%${search}%` // Contains in content
|
||||||
);
|
);
|
||||||
} else if (sortBy === 'edges') {
|
} else if (sortBy === 'edges') {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface NodeRecord {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
|
description: string | null;
|
||||||
dimensions_json: string;
|
dimensions_json: string;
|
||||||
embedding?: Buffer | null;
|
embedding?: Buffer | null;
|
||||||
embedding_updated_at?: string | null;
|
embedding_updated_at?: string | null;
|
||||||
@@ -106,7 +107,8 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
let embeddingText = formatEmbeddingText(
|
let embeddingText = formatEmbeddingText(
|
||||||
node.title,
|
node.title,
|
||||||
node.content || '',
|
node.content || '',
|
||||||
dimensions
|
dimensions,
|
||||||
|
node.description
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add AI analysis if content exists
|
// Add AI analysis if content exists
|
||||||
@@ -175,7 +177,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
// Single node
|
// Single node
|
||||||
query = `
|
query = `
|
||||||
SELECT n.id, n.title, n.content,
|
SELECT n.id, n.title, n.content, n.description,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||||
n.embedding, n.embedding_updated_at
|
n.embedding, n.embedding_updated_at
|
||||||
@@ -186,7 +188,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
} else if (forceReEmbed) {
|
} else if (forceReEmbed) {
|
||||||
// All nodes
|
// All nodes
|
||||||
query = `
|
query = `
|
||||||
SELECT n.id, n.title, n.content,
|
SELECT n.id, n.title, n.content, n.description,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||||
n.embedding, n.embedding_updated_at
|
n.embedding, n.embedding_updated_at
|
||||||
@@ -196,7 +198,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
|||||||
} else {
|
} else {
|
||||||
// Only nodes without embeddings
|
// Only nodes without embeddings
|
||||||
query = `
|
query = `
|
||||||
SELECT n.id, n.title, n.content,
|
SELECT n.id, n.title, n.content, n.description,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||||
n.embedding, n.embedding_updated_at
|
n.embedding, n.embedding_updated_at
|
||||||
|
|||||||
@@ -83,10 +83,12 @@ export function createDatabaseConnection(): Database.Database {
|
|||||||
export function formatEmbeddingText(
|
export function formatEmbeddingText(
|
||||||
title: string,
|
title: string,
|
||||||
content: string,
|
content: string,
|
||||||
dimensions: string[]
|
dimensions: string[],
|
||||||
|
description?: string | null
|
||||||
): string {
|
): string {
|
||||||
|
const descriptionText = description && description.trim() ? description.trim() : 'none';
|
||||||
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
|
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
|
||||||
return `Title: ${title}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
|
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { tool } from 'ai';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const createDimensionTool = tool({
|
export const createDimensionTool = tool({
|
||||||
description: 'Create a new dimension or update existing dimension',
|
description: 'Create a new dimension or update existing dimension. IMPORTANT: Always ask the user for a description explaining what belongs in this dimension before creating it. Dimensions without descriptions cannot be auto-assigned.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
name: z.string().describe('Dimension name'),
|
name: z.string().describe('Dimension name'),
|
||||||
description: z.string().max(500).optional().describe('Dimension description (max 500 characters)'),
|
description: z.string().min(1).max(500).describe('Dimension description explaining what content belongs in this dimension (required, max 500 characters)'),
|
||||||
isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)')
|
isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)')
|
||||||
}),
|
}),
|
||||||
execute: async (params) => {
|
execute: async (params) => {
|
||||||
@@ -26,7 +26,7 @@ export const createDimensionTool = tool({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
description: params.description?.trim() || null,
|
description: params.description.trim(),
|
||||||
isPriority: params.isPriority || false
|
isPriority: params.isPriority || false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { tool } from 'ai';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { nodeService } from '@/services/database/nodes';
|
|
||||||
import { edgeService } from '@/services/database/edges';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick Link Tool - Creates edges to related nodes in ONE operation
|
|
||||||
* No agent loop, no LLM reasoning - just fast database operations
|
|
||||||
*/
|
|
||||||
export const quickLinkTool = tool({
|
|
||||||
description: 'Instantly find and link related nodes based on title/content matches. Fast - no AI reasoning, just database search and edge creation.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
node_id: z.number().describe('The node ID to find connections for'),
|
|
||||||
max_edges: z.number().optional().default(3).describe('Maximum edges to create (default 3)'),
|
|
||||||
}),
|
|
||||||
execute: async (params) => {
|
|
||||||
const { node_id, max_edges = 3 } = params;
|
|
||||||
try {
|
|
||||||
// 1. Get the source node
|
|
||||||
const sourceNode = await nodeService.getNodeById(node_id);
|
|
||||||
if (!sourceNode) {
|
|
||||||
return { success: false, error: `Node ${node_id} not found`, edgesCreated: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Extract search terms from title (split on common separators)
|
|
||||||
const title = sourceNode.title || '';
|
|
||||||
const searchTerms = extractSearchTerms(title);
|
|
||||||
|
|
||||||
if (searchTerms.length === 0) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'No searchable terms found in title',
|
|
||||||
edgesCreated: 0,
|
|
||||||
searchTerms: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Search for related nodes using each term
|
|
||||||
const foundNodeIds = new Set<number>();
|
|
||||||
const matchDetails: Array<{ nodeId: number; title: string; matchedTerm: string }> = [];
|
|
||||||
|
|
||||||
for (const term of searchTerms.slice(0, 3)) { // Max 3 search terms
|
|
||||||
const results = await nodeService.getNodes({
|
|
||||||
search: term,
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const node of results) {
|
|
||||||
// Skip self and already found
|
|
||||||
if (node.id === node_id || foundNodeIds.has(node.id)) continue;
|
|
||||||
|
|
||||||
foundNodeIds.add(node.id);
|
|
||||||
matchDetails.push({
|
|
||||||
nodeId: node.id,
|
|
||||||
title: node.title || `Node ${node.id}`,
|
|
||||||
matchedTerm: term
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get existing edges to avoid duplicates
|
|
||||||
const existingConnections = await edgeService.getNodeConnections(node_id);
|
|
||||||
const existingTargets = new Set(existingConnections.map(c => c.connected_node.id));
|
|
||||||
|
|
||||||
// 5. Create edges for top matches (excluding existing)
|
|
||||||
const edgesToCreate = matchDetails
|
|
||||||
.filter(m => !existingTargets.has(m.nodeId))
|
|
||||||
.slice(0, max_edges);
|
|
||||||
|
|
||||||
const createdEdges: Array<{ toNodeId: number; toTitle: string }> = [];
|
|
||||||
|
|
||||||
for (const match of edgesToCreate) {
|
|
||||||
try {
|
|
||||||
await edgeService.createEdge({
|
|
||||||
from_node_id: node_id,
|
|
||||||
to_node_id: match.nodeId,
|
|
||||||
source: 'helper_name',
|
|
||||||
context: { quickLink: true, matchedTerm: match.matchedTerm }
|
|
||||||
});
|
|
||||||
createdEdges.push({ toNodeId: match.nodeId, toTitle: match.title });
|
|
||||||
} catch (err) {
|
|
||||||
// Edge might already exist, continue
|
|
||||||
console.warn(`[quickLink] Failed to create edge to ${match.nodeId}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Return summary
|
|
||||||
const skippedCount = matchDetails.filter(m => existingTargets.has(m.nodeId)).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
sourceNode: { id: node_id, title: sourceNode.title },
|
|
||||||
searchTerms,
|
|
||||||
edgesCreated: createdEdges.length,
|
|
||||||
edges: createdEdges,
|
|
||||||
skippedExisting: skippedCount,
|
|
||||||
totalMatches: matchDetails.length,
|
|
||||||
message: createdEdges.length > 0
|
|
||||||
? `Linked to ${createdEdges.length} nodes: ${createdEdges.map(e => e.toTitle).join(', ')}`
|
|
||||||
: skippedCount > 0
|
|
||||||
? `Found ${skippedCount} matches but edges already exist`
|
|
||||||
: `No related nodes found for: ${searchTerms.join(', ')}`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Quick link failed',
|
|
||||||
edgesCreated: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract meaningful search terms from a title
|
|
||||||
* Looks for: names, quoted phrases, capitalized words, etc.
|
|
||||||
*/
|
|
||||||
function extractSearchTerms(title: string): string[] {
|
|
||||||
const terms: string[] = [];
|
|
||||||
|
|
||||||
// 1. Extract quoted phrases
|
|
||||||
const quotedMatches = title.match(/["']([^"']+)["']/g);
|
|
||||||
if (quotedMatches) {
|
|
||||||
terms.push(...quotedMatches.map(m => m.replace(/["']/g, '').trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Extract potential names (2-3 capitalized words in sequence)
|
|
||||||
const namePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g;
|
|
||||||
let match;
|
|
||||||
while ((match = namePattern.exec(title)) !== null) {
|
|
||||||
const name = match[1];
|
|
||||||
// Skip common non-name phrases
|
|
||||||
if (!isCommonPhrase(name)) {
|
|
||||||
terms.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Extract standalone capitalized words (potential company/project names)
|
|
||||||
const words = title.split(/\s+/);
|
|
||||||
for (const word of words) {
|
|
||||||
const cleaned = word.replace(/[^a-zA-Z0-9]/g, '');
|
|
||||||
if (cleaned.length > 2 && /^[A-Z]/.test(cleaned) && !terms.includes(cleaned)) {
|
|
||||||
terms.push(cleaned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedupe and limit
|
|
||||||
const unique = [...new Set(terms)].filter(t => t.length > 2);
|
|
||||||
return unique.slice(0, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCommonPhrase(phrase: string): boolean {
|
|
||||||
const common = [
|
|
||||||
'The', 'How', 'What', 'Why', 'When', 'Where', 'Which',
|
|
||||||
'New', 'First', 'Last', 'Next', 'This', 'That',
|
|
||||||
'Part', 'Chapter', 'Section', 'Episode'
|
|
||||||
];
|
|
||||||
return common.some(c => phrase.startsWith(c + ' ') || phrase === c);
|
|
||||||
}
|
|
||||||
@@ -56,7 +56,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
|||||||
updateNode: 'execution',
|
updateNode: 'execution',
|
||||||
createEdge: 'execution',
|
createEdge: 'execution',
|
||||||
updateEdge: 'execution',
|
updateEdge: 'execution',
|
||||||
quickLink: 'execution',
|
|
||||||
embedContent: 'execution',
|
embedContent: 'execution',
|
||||||
youtubeExtract: 'execution',
|
youtubeExtract: 'execution',
|
||||||
websiteExtract: 'execution',
|
websiteExtract: 'execution',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { updateNodeTool } from '../database/updateNode';
|
|||||||
import { createEdgeTool } from '../database/createEdge';
|
import { createEdgeTool } from '../database/createEdge';
|
||||||
import { queryEdgeTool } from '../database/queryEdge';
|
import { queryEdgeTool } from '../database/queryEdge';
|
||||||
import { updateEdgeTool } from '../database/updateEdge';
|
import { updateEdgeTool } from '../database/updateEdge';
|
||||||
import { quickLinkTool } from '../database/quickLink';
|
|
||||||
import { createDimensionTool } from '../database/createDimension';
|
import { createDimensionTool } from '../database/createDimension';
|
||||||
import { updateDimensionTool } from '../database/updateDimension';
|
import { updateDimensionTool } from '../database/updateDimension';
|
||||||
// lockDimension and unlockDimension consolidated into updateDimension (use isPriority param)
|
// lockDimension and unlockDimension consolidated into updateDimension (use isPriority param)
|
||||||
@@ -56,7 +55,6 @@ const EXECUTION_TOOLS: Record<string, any> = {
|
|||||||
updateNode: updateNodeTool,
|
updateNode: updateNodeTool,
|
||||||
createEdge: createEdgeTool,
|
createEdge: createEdgeTool,
|
||||||
updateEdge: updateEdgeTool,
|
updateEdge: updateEdgeTool,
|
||||||
quickLink: quickLinkTool,
|
|
||||||
createDimension: createDimensionTool,
|
createDimension: createDimensionTool,
|
||||||
updateDimension: updateDimensionTool,
|
updateDimension: updateDimensionTool,
|
||||||
deleteDimension: deleteDimensionTool,
|
deleteDimension: deleteDimensionTool,
|
||||||
@@ -89,7 +87,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
|||||||
'updateNode',
|
'updateNode',
|
||||||
'createEdge',
|
'createEdge',
|
||||||
'updateEdge',
|
'updateEdge',
|
||||||
'quickLink',
|
|
||||||
'createDimension',
|
'createDimension',
|
||||||
'updateDimension',
|
'updateDimension',
|
||||||
'deleteDimension',
|
'deleteDimension',
|
||||||
@@ -114,7 +111,6 @@ const PLANNER_TOOL_NAMES = [
|
|||||||
'think',
|
'think',
|
||||||
'updateNode',
|
'updateNode',
|
||||||
'createEdge',
|
'createEdge',
|
||||||
'quickLink',
|
|
||||||
'updateDimension',
|
'updateDimension',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -173,10 +173,12 @@ export const paperExtractTool = tool({
|
|||||||
|
|
||||||
console.log('🎯 PaperExtract completed successfully');
|
console.log('🎯 PaperExtract completed successfully');
|
||||||
|
|
||||||
|
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
||||||
|
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
|
||||||
const formattedNode = createResult.data?.id
|
const formattedNode = createResult.data?.id
|
||||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] })
|
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||||
: nodeTitle;
|
: nodeTitle;
|
||||||
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none';
|
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -186,7 +188,7 @@ export const paperExtractTool = tool({
|
|||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: (result.chunk || result.content || '').length,
|
contentLength: (result.chunk || result.content || '').length,
|
||||||
url: url,
|
url: url,
|
||||||
dimensions: trimmedDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -163,10 +163,12 @@ export const websiteExtractTool = tool({
|
|||||||
|
|
||||||
console.log('🎯 WebsiteExtract completed successfully');
|
console.log('🎯 WebsiteExtract completed successfully');
|
||||||
|
|
||||||
|
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
||||||
|
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
|
||||||
const formattedNode = createResult.data?.id
|
const formattedNode = createResult.data?.id
|
||||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] })
|
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||||
: nodeTitle;
|
: nodeTitle;
|
||||||
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none';
|
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -176,7 +178,7 @@ export const websiteExtractTool = tool({
|
|||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: (result.chunk || result.content || '').length,
|
contentLength: (result.chunk || result.content || '').length,
|
||||||
url: url,
|
url: url,
|
||||||
dimensions: trimmedDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -225,10 +225,12 @@ export const youtubeExtractTool = tool({
|
|||||||
|
|
||||||
console.log('🎯 YouTubeExtract completed successfully');
|
console.log('🎯 YouTubeExtract completed successfully');
|
||||||
|
|
||||||
|
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
||||||
|
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
|
||||||
const formattedNode = createResult.data?.id
|
const formattedNode = createResult.data?.id
|
||||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] })
|
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||||
: nodeTitle;
|
: nodeTitle;
|
||||||
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none';
|
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -238,7 +240,7 @@ export const youtubeExtractTool = tool({
|
|||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: (result.chunk || result.content || '').length,
|
contentLength: (result.chunk || result.content || '').length,
|
||||||
url: url,
|
url: url,
|
||||||
dimensions: trimmedDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user