feat: contextual AI chat on open note (Cmd+I) (#169)

- Cmd+I toggles AI panel with context from the active note
- Context bar shows note title in AI panel when a note is open
- useAiAgent accepts optional contextPrompt used as system prompt
- ai-context.ts extracts structured context from note + related entries
- Updated AiPanel, EditorRightPanel, App, useAppCommands, useCommandRegistry
- 1292 frontend tests pass, coverage 78.96%

Co-authored-by: Test <test@test.com>
This commit is contained in:
Luca Rossi
2026-03-02 05:00:29 +01:00
committed by GitHub
parent 88954151f3
commit 7d563b28fd
12 changed files with 477 additions and 25 deletions

View File

@@ -290,6 +290,7 @@ function App() {
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => { await themeManager.createTheme() },
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onToggleAIChat: dialogs.toggleAIChat,
})
const { status: updateStatus, actions: updateActions } = useUpdater()

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
// Mock the hooks and utils to isolate component tests
vi.mock('../hooks/useAiAgent', () => ({
@@ -16,10 +17,37 @@ vi.mock('../utils/ai-chat', () => ({
nextMessageId: () => `msg-${Date.now()}`,
}))
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
describe('AiPanel', () => {
it('renders panel with AI Agent header', () => {
it('renders panel with AI Chat header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('AI Chat')).toBeTruthy()
})
it('renders data-testid ai-panel', () => {
@@ -38,9 +66,44 @@ describe('AiPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('renders empty state when no messages', () => {
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Ask the AI agent to work with your vault')).toBeTruthy()
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
})
it('renders contextual empty state when active entry is provided', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
)
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
})
it('shows context bar with active entry title', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
)
expect(screen.getByTestId('context-bar')).toBeTruthy()
expect(screen.getByText('My Note')).toBeTruthy()
})
it('shows linked count in context bar when entry has outgoing links', () => {
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
render(
<AiPanel
onClose={vi.fn()} vaultPath="/tmp/vault"
activeEntry={entry} entries={[entry, linked]}
allContent={{}}
/>
)
expect(screen.getByText('+ 1 linked')).toBeTruthy()
})
it('does not show context bar when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.queryByTestId('context-bar')).toBeNull()
})
it('renders input field enabled', () => {
@@ -55,4 +118,19 @@ describe('AiPanel', () => {
const sendBtn = screen.getByTestId('agent-send')
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
})
it('shows contextual placeholder when active entry exists', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask about this note...')
})
it('shows generic placeholder when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask the AI agent...')
})
})

View File

@@ -1,7 +1,9 @@
import { useState, useRef, useEffect } from 'react'
import { Robot, X, PaperPlaneRight, Plus } from '@phosphor-icons/react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -9,6 +11,9 @@ interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
allContent?: Record<string, string>
}
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
@@ -19,7 +24,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
AI Chat
</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
@@ -39,7 +44,23 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
)
}
function EmptyState() {
function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) {
return (
<div
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
data-testid="context-bar"
>
<Link size={12} className="shrink-0" />
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
{linkedCount > 0 && (
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
)}
</div>
)
}
function EmptyState({ hasContext }: { hasContext: boolean }) {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
@@ -47,17 +68,23 @@ function EmptyState() {
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
Ask the AI agent to work with your vault
{hasContext
? 'Ask about this note and its linked context'
: 'Open a note, then ask the AI about it'
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Creates notes, searches, edits frontmatter, and more
{hasContext
? 'Summarize, find connections, expand ideas'
: 'The AI will use the active note as context'
}
</p>
</div>
)
}
function MessageHistory({ messages, isActive, onOpenNote }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
}) {
const endRef = useRef<HTMLDivElement>(null)
@@ -67,7 +94,7 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && <EmptyState />}
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
{messages.map((msg, i) => (
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
))}
@@ -76,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
)
}
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
input: string; onInputChange: (v: string) => void
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
isActive: boolean
isActive: boolean; hasContext: boolean
}) {
const sendDisabled = isActive || !input.trim()
return (
@@ -97,7 +124,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit',
}}
placeholder="Ask the AI agent..."
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
disabled={isActive}
data-testid="agent-input"
/>
@@ -121,9 +148,21 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
const [input, setInput] = useState('')
const agent = useAiAgent(vaultPath)
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
return collectLinkedEntries(activeEntry, entries)
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent) return undefined
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
}, [activeEntry, linkedEntries, allContent])
const agent = useAiAgent(vaultPath, contextPrompt)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
@@ -146,10 +185,14 @@ export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
data-testid="ai-panel"
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
{activeEntry && (
<ContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
<MessageHistory
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
hasContext={hasContext}
/>
<InputBar
input={input}
@@ -157,6 +200,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
onSend={handleSend}
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
/>
</aside>
)

View File

@@ -38,6 +38,9 @@ export function EditorRightPanel({
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}
allContent={allContent}
/>
</div>
)

View File

@@ -4,7 +4,7 @@
*
* States: idle -> thinking -> tool-executing -> done/error
*/
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import { nextMessageId } from '../utils/ai-chat'
@@ -20,10 +20,14 @@ export interface AiAgentMessage {
id?: string
}
export function useAiAgent(vaultPath: string) {
export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const [messages, setMessages] = useState<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const contextRef = useRef(contextPrompt)
useEffect(() => {
contextRef.current = contextPrompt
}, [contextPrompt])
const sendMessage = useCallback(async (text: string) => {
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
@@ -49,7 +53,11 @@ export function useAiAgent(vaultPath: string) {
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
}
await streamClaudeAgent(text.trim(), buildAgentSystemPrompt(), vaultPath, {
// When a contextual prompt is provided (from buildContextualPrompt),
// use it directly — it already includes the system preamble.
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
onText: (text) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, response: (m.response ?? '') + text }))

View File

@@ -49,6 +49,7 @@ interface AppCommandsConfig {
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenVault?: () => void
onToggleAIChat?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -69,6 +70,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomReset: config.onZoomReset,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onToggleAIChat: config.onToggleAIChat,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
})
@@ -126,6 +128,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
onOpenVault: config.onOpenVault,
onToggleAIChat: config.onToggleAIChat,
})
useKeyboardNavigation({

View File

@@ -182,4 +182,22 @@ describe('useAppKeyboard', () => {
fireKey('0', { metaKey: true })
expect(actions.onZoomReset).toHaveBeenCalled()
})
it('Cmd+I triggers toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('i', { metaKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Cmd+I works when text input is focused', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedInput(() => {
fireKey('i', { metaKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
})

View File

@@ -17,6 +17,7 @@ interface KeyboardActions {
onZoomReset: () => void
onGoBack?: () => void
onGoForward?: () => void
onToggleAIChat?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
@@ -62,7 +63,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -87,6 +88,7 @@ export function useAppKeyboard({
'+': onZoomIn,
'-': onZoomOut,
'0': onZoomReset,
i: () => onToggleAIChat?.(),
}
const handleKeyDown = (e: KeyboardEvent) => {
@@ -102,5 +104,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat])
}

View File

@@ -52,6 +52,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
zoomLevel: 100,
onSelect: vi.fn(),
onCloseTab: vi.fn(),
onOpenDailyNote: vi.fn(),
...overrides,
}
}
@@ -176,6 +177,23 @@ describe('useCommandRegistry', () => {
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
it('has toggle-ai-chat command with shortcut', () => {
const onToggleAIChat = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
const cmd = result.current.find(c => c.id === 'toggle-ai-chat')
expect(cmd).toBeDefined()
expect(cmd!.shortcut).toBe('⌘I')
expect(cmd!.group).toBe('View')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleAIChat when toggle-ai-chat executes', () => {
const onToggleAIChat = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onToggleAIChat })))
result.current.find(c => c.id === 'toggle-ai-chat')!.execute()
expect(onToggleAIChat).toHaveBeenCalled()
})
it('has open-daily-note command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'open-daily-note')

View File

@@ -31,6 +31,7 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleAIChat?: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -128,7 +129,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
@@ -178,6 +179,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
@@ -198,7 +200,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
hasActiveNote, activeTabPath, isArchived, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,

View File

@@ -0,0 +1,185 @@
import { describe, it, expect } from 'vitest'
import { resolveTarget, collectLinkedEntries, buildContextualPrompt } from './ai-context'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
describe('resolveTarget', () => {
const entries = [
makeEntry({ path: '/vault/a.md', title: 'Alpha', filename: 'a.md' }),
makeEntry({ path: '/vault/b.md', title: 'Beta', filename: 'beta-note.md', aliases: ['B'] }),
]
it('resolves by title (case-insensitive)', () => {
expect(resolveTarget('alpha', entries)?.path).toBe('/vault/a.md')
expect(resolveTarget('Alpha', entries)?.path).toBe('/vault/a.md')
})
it('resolves by alias (case-insensitive)', () => {
expect(resolveTarget('B', entries)?.path).toBe('/vault/b.md')
expect(resolveTarget('b', entries)?.path).toBe('/vault/b.md')
})
it('resolves by filename stem', () => {
expect(resolveTarget('beta-note', entries)?.path).toBe('/vault/b.md')
})
it('returns undefined for unresolvable target', () => {
expect(resolveTarget('nonexistent', entries)).toBeUndefined()
})
})
describe('collectLinkedEntries', () => {
const entryA = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const entryB = makeEntry({ path: '/vault/b.md', title: 'Beta' })
const entryC = makeEntry({ path: '/vault/c.md', title: 'Gamma' })
const entryD = makeEntry({ path: '/vault/d.md', title: 'Delta' })
const allEntries = [entryA, entryB, entryC, entryD]
it('returns empty array when active has no links', () => {
expect(collectLinkedEntries(entryA, allEntries)).toEqual([])
})
it('collects entries from outgoingLinks', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
outgoingLinks: ['Alpha', 'Beta'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Beta'])
})
it('collects entries from relationships', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
relationships: { relatedTo: ['[[Alpha]]', '[[Gamma]]'] },
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Gamma'])
})
it('collects entries from belongsTo', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
belongsTo: ['[[Delta]]'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Delta'])
})
it('collects entries from relatedTo', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
relatedTo: ['[[Beta]]'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Beta'])
})
it('deduplicates entries across all link sources', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
outgoingLinks: ['Alpha', 'Beta'],
relationships: { people: ['[[Alpha]]'] },
belongsTo: ['[[Beta]]'],
relatedTo: ['[[Alpha]]'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha', 'Beta'])
})
it('excludes the active note itself', () => {
const active = makeEntry({
path: '/vault/a.md', title: 'Alpha',
outgoingLinks: ['Alpha'],
})
const linked = collectLinkedEntries(active, allEntries)
expect(linked).toEqual([])
})
it('ignores unresolvable links', () => {
const active = makeEntry({
path: '/vault/main.md', title: 'Main',
outgoingLinks: ['Alpha', 'Nonexistent'],
})
const linked = collectLinkedEntries(active, [...allEntries, active])
expect(linked.map(e => e.title)).toEqual(['Alpha'])
})
})
describe('buildContextualPrompt', () => {
it('includes active note title and content', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project' })
const content = { '/vault/a.md': '# Alpha\nThis is the alpha project.' }
const prompt = buildContextualPrompt(active, [], content)
expect(prompt).toContain('Alpha')
expect(prompt).toContain('Project')
expect(prompt).toContain('This is the alpha project.')
})
it('includes linked note content', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
const content = {
'/vault/a.md': '# Alpha\nMain note.',
'/vault/b.md': '# Beta\nLinked person.',
}
const prompt = buildContextualPrompt(active, [linked], content)
expect(prompt).toContain('Beta')
expect(prompt).toContain('Person')
expect(prompt).toContain('Linked person.')
expect(prompt).toContain('Linked Notes')
})
it('shows (no content) when content is missing', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [], {})
expect(prompt).toContain('(no content)')
})
it('truncates linked note content to 2000 chars', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta' })
const longContent = 'x'.repeat(3000)
const content = {
'/vault/a.md': '# Alpha',
'/vault/b.md': longContent,
}
const prompt = buildContextualPrompt(active, [linked], content)
// The linked note content should be truncated
const betaIdx = prompt.indexOf('### Beta')
const afterBeta = prompt.slice(betaIdx)
expect(afterBeta.length).toBeLessThan(2200)
})
it('includes the system preamble', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [], { '/vault/a.md': 'content' })
expect(prompt).toContain('AI assistant integrated into Laputa')
})
})

90
src/utils/ai-context.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* AI contextual chat — builds the context note list from the active note
* and its first-degree linked notes (outgoingLinks + relationships).
*/
import type { VaultEntry } from '../types'
import { wikilinkTarget } from './wikilink'
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */
export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined {
const lower = target.toLowerCase()
return entries.find(e => {
if (e.title.toLowerCase() === lower) return true
if (e.aliases.some(a => a.toLowerCase() === lower)) return true
const stem = e.filename.replace(/\.md$/, '')
if (stem.toLowerCase() === lower) return true
return false
})
}
/** Collect first-degree linked notes from the active entry. */
export function collectLinkedEntries(
active: VaultEntry,
entries: VaultEntry[],
): VaultEntry[] {
const seen = new Set<string>([active.path])
const linked: VaultEntry[] = []
const addTarget = (target: string) => {
const entry = resolveTarget(target, entries)
if (entry && !seen.has(entry.path)) {
seen.add(entry.path)
linked.push(entry)
}
}
// outgoingLinks are raw targets (no [[ ]] wrapper)
for (const target of active.outgoingLinks) {
addTarget(target)
}
// relationships values are wikilink references like [[target]]
for (const refs of Object.values(active.relationships)) {
for (const ref of refs) {
addTarget(wikilinkTarget(ref))
}
}
// belongsTo and relatedTo are also wikilink references
for (const ref of active.belongsTo) {
addTarget(wikilinkTarget(ref))
}
for (const ref of active.relatedTo) {
addTarget(wikilinkTarget(ref))
}
return linked
}
/** Build a contextual system prompt from the active note and its linked notes. */
export function buildContextualPrompt(
active: VaultEntry,
linkedEntries: VaultEntry[],
allContent: Record<string, string>,
): string {
const parts: string[] = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'The user is viewing a specific note. Use the note and its linked context to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'',
`## Active Note: ${active.title}`,
`Type: ${active.isA ?? 'Note'} | Path: ${active.path}`,
'',
allContent[active.path] ?? '(no content)',
]
if (linkedEntries.length > 0) {
parts.push('', '## Linked Notes')
for (const entry of linkedEntries) {
const content = allContent[entry.path]
parts.push(
'',
`### ${entry.title} (${entry.isA ?? 'Note'})`,
content ? content.slice(0, 2000) : '(no content loaded)',
)
}
}
return parts.join('\n')
}