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:
@@ -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()
|
||||
|
||||
@@ -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...')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -38,6 +38,9 @@ export function EditorRightPanel({
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
185
src/utils/ai-context.test.ts
Normal file
185
src/utils/ai-context.test.ts
Normal 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
90
src/utils/ai-context.ts
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user