Compare commits
7 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d29f919182 | ||
|
|
42e37e035c | ||
|
|
0ad0fa9b6b | ||
|
|
c37c03d6a9 | ||
|
|
fcc264d7dc | ||
|
|
382ba0a6d4 | ||
|
|
4719810b10 |
@@ -1 +1 @@
|
||||
66070
|
||||
88289
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -94,6 +96,28 @@ const TOOLS = [
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
|
||||
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Optional specific note path that changed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
@@ -122,14 +148,27 @@ async function handleGetNote(args) {
|
||||
}
|
||||
|
||||
function handleOpenNote(args) {
|
||||
// Refresh vault first so the new/modified note appears in the note list,
|
||||
// then signal the UI to open it in a tab.
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleRefreshVault(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.2.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
let tmpDir
|
||||
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNote', () => {
|
||||
it('should read a note by relative path', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('Test Project'))
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
describe('getNote', () => {
|
||||
it('should read a note with parsed frontmatter', async () => {
|
||||
const note = await getNote(tmpDir, 'project/test-project.md')
|
||||
assert.equal(note.path, 'project/test-project.md')
|
||||
assert.equal(note.frontmatter.title, 'Test Project')
|
||||
assert.equal(note.frontmatter.is_a, 'Project')
|
||||
assert.ok(note.content.includes('test project for the MCP server'))
|
||||
})
|
||||
|
||||
it('should throw for missing notes', async () => {
|
||||
await assert.rejects(
|
||||
() => readNote(tmpDir, 'nonexistent.md'),
|
||||
() => getNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note with frontmatter', async () => {
|
||||
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
|
||||
assert.ok(absPath.endsWith('new-note.md'))
|
||||
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('title: My New Note'))
|
||||
assert.ok(content.includes('is_a: Note'))
|
||||
assert.ok(content.includes('# My New Note'))
|
||||
})
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('# Deep Note'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToNote', () => {
|
||||
it('should append text to a note', async () => {
|
||||
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
|
||||
const content = await readNote(tmpDir, 'note/daily-log.md')
|
||||
assert.ok(content.includes('## Evening Update'))
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('editNoteFrontmatter', () => {
|
||||
it('should merge a patch into frontmatter', async () => {
|
||||
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
|
||||
assert.equal(updated.status, 'Completed')
|
||||
assert.equal(updated.priority, 'High')
|
||||
assert.equal(updated.title, 'Test Project')
|
||||
})
|
||||
|
||||
it('should preserve existing frontmatter fields', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
assert.ok(content.includes('status: Completed'))
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should delete an existing note', async () => {
|
||||
const delPath = 'note/to-delete.md'
|
||||
await createNote(tmpDir, delPath, 'To Delete')
|
||||
const absPath = path.join(tmpDir, delPath)
|
||||
|
||||
// Verify it exists
|
||||
await fs.access(absPath)
|
||||
|
||||
await deleteNote(tmpDir, delPath)
|
||||
|
||||
await assert.rejects(
|
||||
() => fs.access(absPath),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => deleteNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkNotes', () => {
|
||||
it('should add a target to an array property', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
|
||||
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.deepEqual(result, ['[[note/daily-log]]'])
|
||||
})
|
||||
|
||||
it('should not duplicate existing links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.equal(result.length, 1)
|
||||
})
|
||||
|
||||
it('should add multiple distinct links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
// Should have daily-log and test-project
|
||||
assert.ok(result.includes('[[note/daily-log]]'))
|
||||
assert.ok(result.includes('[[project/test-project]]'))
|
||||
assert.equal(result.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should list all notes sorted by title', async () => {
|
||||
const notes = await listNotes(tmpDir)
|
||||
assert.ok(notes.length >= 3)
|
||||
// Verify sorted by title
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const projects = await listNotes(tmpDir, 'Project')
|
||||
assert.ok(projects.length >= 1)
|
||||
for (const n of projects) {
|
||||
assert.equal(n.type, 'Project')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty for unknown type', async () => {
|
||||
const notes = await listNotes(tmpDir, 'UnknownType12345')
|
||||
assert.equal(notes.length, 0)
|
||||
})
|
||||
|
||||
it('should support mtime sorting', async () => {
|
||||
const notes = await listNotes(tmpDir, undefined, 'mtime')
|
||||
assert.ok(notes.length >= 1)
|
||||
// Just verify it returns results without crashing
|
||||
assert.ok(notes[0].path)
|
||||
assert.ok(notes[0].title)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vaultContext', () => {
|
||||
it('should return types, recent notes, and vault path', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
|
||||
assert.ok(note.title)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include folders', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.folders.includes('project/'))
|
||||
assert.ok(ctx.folders.includes('note/'))
|
||||
})
|
||||
|
||||
it('should report correct note count', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.equal(ctx.noteCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,10 +46,12 @@ const TOOL_HANDLERS = {
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -86,6 +87,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -168,6 +168,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
@@ -210,6 +213,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
|
||||
mcp-server:
|
||||
dependencies:
|
||||
@@ -2043,6 +2049,9 @@ packages:
|
||||
'@types/use-sync-external-store@1.5.0':
|
||||
resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0':
|
||||
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -6011,6 +6020,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@1.5.0': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
|
||||
@@ -18,6 +18,7 @@ interface AIChatPanelProps {
|
||||
allContent: Record<string, string>
|
||||
entries?: VaultEntry[]
|
||||
onClose: () => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function TypingIndicator() {
|
||||
@@ -99,10 +100,10 @@ function ContextSearchDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<MarkdownContent content={msg.content} />
|
||||
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
|
||||
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
|
||||
@@ -121,10 +122,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingContent({ content }: { content: string }) {
|
||||
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<MarkdownContent content={content} />
|
||||
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -176,7 +177,7 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -213,7 +214,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
<MessageList
|
||||
messages={chat.messages} isStreaming={chat.isStreaming}
|
||||
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
|
||||
/>
|
||||
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
@@ -284,10 +285,11 @@ function ContextBar({
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
|
||||
}: {
|
||||
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
|
||||
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
@@ -302,10 +304,10 @@ function MessageList({
|
||||
<div key={msg.id} style={{ marginBottom: 12 }}>
|
||||
{msg.role === 'user'
|
||||
? <UserBubble content={msg.content} />
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AiMessageProps {
|
||||
response?: string
|
||||
isStreaming?: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function ReferencePill({ reference, onClick }: {
|
||||
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ResponseBlock({ text }: { text: string }) {
|
||||
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<MarkdownContent content={text} />
|
||||
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
style={{ fontSize: 11, marginTop: 4 }}
|
||||
@@ -175,7 +176,7 @@ function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
// Manual override: null = follow auto behavior, true/false = user forced
|
||||
const [userOverride, setUserOverride] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
|
||||
onToggleExpand={toggleAction}
|
||||
/>
|
||||
)}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { findEntryByTarget } from '../utils/wikilinkColors'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
@@ -89,8 +90,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
|
||||
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -102,7 +103,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
@@ -167,6 +168,12 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
if (!entries) return
|
||||
const entry = findEntryByTarget(entries, target)
|
||||
if (entry) onOpenNote?.(entry.path)
|
||||
}, [entries, onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
@@ -198,6 +205,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { preprocessWikilinks } from '../utils/chatWikilinks'
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
it('renders bold text', () => {
|
||||
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
|
||||
expect(bq).toBeTruthy()
|
||||
expect(bq!.textContent).toContain('A quote')
|
||||
})
|
||||
|
||||
describe('wikilinks', () => {
|
||||
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
|
||||
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
|
||||
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
|
||||
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
|
||||
})
|
||||
|
||||
it('renders [[Note Title]] as a clickable wikilink chip', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('My Note')
|
||||
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
|
||||
})
|
||||
|
||||
it('fires onWikilinkClick when a wikilink is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Daily Log')
|
||||
})
|
||||
|
||||
it('renders multiple wikilinks in the same paragraph', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
expect(wikilinks[0].textContent).toBe('Note A')
|
||||
expect(wikilinks[1].textContent).toBe('Note B')
|
||||
})
|
||||
|
||||
it('handles pipe syntax [[target|display]]', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('My Display')
|
||||
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('path/to/note')
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside inline code', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside code blocks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles notes with special characters in title', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
})
|
||||
|
||||
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Some Note]]" />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
expect(container.textContent).toContain('[[Some Note]]')
|
||||
})
|
||||
|
||||
it('renders wikilinks inside list items', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has role="link" and tabIndex for accessibility', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.getAttribute('role')).toBe('link')
|
||||
expect(wikilink.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
|
||||
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
const REHYPE_PLUGINS = [rehypeHighlight]
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
|
||||
const rendered = useMemo(() => (
|
||||
<div className="ai-markdown">
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
|
||||
{content}
|
||||
function wikilinkUrlTransform(url: string): string {
|
||||
if (url.startsWith(WIKILINK_SCHEME)) return url
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
onWikilinkClick?: (target: string) => void
|
||||
}
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
|
||||
const processedContent = useMemo(
|
||||
() => onWikilinkClick ? preprocessWikilinks(content) : content,
|
||||
[content, onWikilinkClick],
|
||||
)
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
|
||||
if (el) {
|
||||
e.preventDefault()
|
||||
onWikilinkClick?.(el.dataset.wikilinkTarget!)
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
const components = useMemo(() => {
|
||||
if (!onWikilinkClick) return undefined
|
||||
return {
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
if (href?.startsWith(WIKILINK_SCHEME)) {
|
||||
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
|
||||
return (
|
||||
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href}>{children}</a>
|
||||
},
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
return (
|
||||
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
|
||||
<Markdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={components}
|
||||
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
|
||||
>
|
||||
{processedContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
), [content])
|
||||
return rendered
|
||||
)
|
||||
})
|
||||
|
||||
@@ -111,4 +111,20 @@ describe('useAIChat', () => {
|
||||
expect(thirdMessage).toContain('Q2')
|
||||
expect(thirdMessage).toContain('Q3')
|
||||
})
|
||||
|
||||
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — session_id should still be undefined (no --resume)
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
// Third argument is sessionId — must be undefined for both calls
|
||||
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
|
||||
expect(streamClaudeChatMock.mock.calls[1][2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ export function useAIChat(
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
@@ -34,9 +33,10 @@ export function useAIChat(
|
||||
const messageWithHistory = formatMessageWithHistory(history, text.trim())
|
||||
let accumulated = ''
|
||||
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
// No session_id: each call is independent. Context is provided via
|
||||
// formatted history in the prompt, avoiding --resume which causes
|
||||
// double-context confusion when combined with in-prompt history.
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, undefined, {
|
||||
onText: (chunk) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
@@ -58,8 +58,6 @@ export function useAIChat(
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
@@ -68,7 +66,6 @@ export function useAIChat(
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
sessionIdRef.current = undefined
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
|
||||
148
src/hooks/useAiAgent.test.ts
Normal file
148
src/hooks/useAiAgent.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
|
||||
import type { AgentFileCallbacks } from './useAiAgent'
|
||||
|
||||
const VAULT = '/Users/luca/Laputa'
|
||||
|
||||
function makeCallbacks() {
|
||||
return {
|
||||
onFileCreated: vi.fn(),
|
||||
onFileModified: vi.fn(),
|
||||
} satisfies AgentFileCallbacks
|
||||
}
|
||||
|
||||
describe('detectFileOperation', () => {
|
||||
it('calls onFileCreated for Write tool with .md in vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
|
||||
expect(cb.onFileModified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onFileModified for Edit tool with .md in vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Edit', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-md files', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/image.png` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores files outside vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: '/tmp/other.md' }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores unknown tool names', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Read', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
expect(cb.onFileModified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles undefined input gracefully', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', undefined, VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles malformed JSON input gracefully', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', 'not-json', VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles undefined callbacks gracefully', () => {
|
||||
expect(() => detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
it('detects Bash redirect creating .md file in vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `echo "# Note" > ${VAULT}/note/new.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/new.md')
|
||||
})
|
||||
|
||||
it('detects Bash append redirect creating .md file', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `cat content.txt >> ${VAULT}/daily/2026-03-07.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('daily/2026-03-07.md')
|
||||
})
|
||||
|
||||
it('detects Bash tee command creating .md file', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `echo "content" | tee ${VAULT}/note/tee-note.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/tee-note.md')
|
||||
})
|
||||
|
||||
it('ignores Bash commands without file creation', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: 'ls -la' }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores Bash creating non-md files', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `echo "data" > ${VAULT}/config.json` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores Bash creating .md files outside vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: 'echo "text" > /tmp/other.md' }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses path field when file_path is missing', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ path: `${VAULT}/note/alt.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/alt.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBashFileCreation', () => {
|
||||
it('returns null for undefined input', () => {
|
||||
expect(parseBashFileCreation(undefined, VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed JSON', () => {
|
||||
expect(parseBashFileCreation('not json', VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no command field', () => {
|
||||
expect(parseBashFileCreation(JSON.stringify({ other: 'value' }), VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when command is not a string', () => {
|
||||
expect(parseBashFileCreation(JSON.stringify({ command: 42 }), VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('parses simple redirect', () => {
|
||||
const input = JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('note.md')
|
||||
})
|
||||
|
||||
it('parses append redirect', () => {
|
||||
const input = JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('sub/note.md')
|
||||
})
|
||||
|
||||
it('parses tee command', () => {
|
||||
const input = JSON.stringify({ command: `echo "data" | tee ${VAULT}/new.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
|
||||
})
|
||||
|
||||
it('parses tee -a (append) command', () => {
|
||||
const input = JSON.stringify({ command: `echo "extra" | tee -a ${VAULT}/new.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
|
||||
})
|
||||
|
||||
it('returns null for non-md redirect', () => {
|
||||
const input = JSON.stringify({ command: `echo "x" > ${VAULT}/file.txt` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -105,7 +105,10 @@ export function useAiAgent(
|
||||
if (abortRef.current.aborted) return
|
||||
markReasoningDone()
|
||||
setStatus('tool-executing')
|
||||
toolInputMapRef.current.set(toolId, { tool: toolName, input })
|
||||
// Preserve accumulated input — tool_progress events arrive with
|
||||
// input=undefined AFTER the assistant message set the full input.
|
||||
const prev = toolInputMapRef.current.get(toolId)
|
||||
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? prev?.input })
|
||||
update(m => {
|
||||
const existing = m.actions.find(a => a.toolId === toolId)
|
||||
if (existing) {
|
||||
@@ -205,13 +208,21 @@ function toVaultRelative(filePath: string, vaultPath: string): string | null {
|
||||
}
|
||||
|
||||
/** Detect file operations from completed tool calls and notify callbacks. */
|
||||
function detectFileOperation(
|
||||
export function detectFileOperation(
|
||||
toolName: string,
|
||||
input: string | undefined,
|
||||
vaultPath: string,
|
||||
callbacks: AgentFileCallbacks | undefined,
|
||||
) {
|
||||
if (!callbacks) return
|
||||
|
||||
// Handle Bash commands that create/write .md files
|
||||
if (toolName === 'Bash') {
|
||||
const mdPath = parseBashFileCreation(input, vaultPath)
|
||||
if (mdPath) callbacks.onFileCreated?.(mdPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (toolName !== 'Write' && toolName !== 'Edit') return
|
||||
const filePath = parseFilePath(input)
|
||||
if (!filePath || !filePath.endsWith('.md')) return
|
||||
@@ -224,6 +235,23 @@ function detectFileOperation(
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect .md file creation from a Bash command string. */
|
||||
export function parseBashFileCreation(input: string | undefined, vaultPath: string): string | null {
|
||||
if (!input) return null
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
const cmd = parsed.command ?? parsed.cmd
|
||||
if (typeof cmd !== 'string') return null
|
||||
// Match redirect patterns: > file.md, >> file.md, tee file.md, cat > file.md
|
||||
const match = cmd.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)
|
||||
if (!match) return null
|
||||
const filePath = match[1]
|
||||
return toVaultRelative(filePath, vaultPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a human-readable label for a tool call. */
|
||||
function formatToolLabel(toolName: string, input?: string): string {
|
||||
// Native Claude Code tools
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
}
|
||||
.ai-markdown a:hover { text-decoration: underline; }
|
||||
|
||||
.ai-markdown .chat-wikilink {
|
||||
display: inline;
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.ai-markdown .chat-wikilink:hover {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@@ -23,6 +23,12 @@ describe('buildAgentSystemPrompt', () => {
|
||||
expect(prompt).toContain('Vault context:')
|
||||
expect(prompt).toContain('Recent notes: foo, bar')
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const prompt = buildAgentSystemPrompt()
|
||||
expect(prompt).toContain('[[')
|
||||
expect(prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeAgent ---
|
||||
@@ -44,7 +50,7 @@ describe('streamClaudeAgent', () => {
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onToolStart).not.toHaveBeenCalled()
|
||||
|
||||
@@ -17,6 +17,7 @@ You have full shell access. Use bash for file operations, search, bulk edits.
|
||||
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
|
||||
|
||||
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
|
||||
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
|
||||
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
|
||||
|
||||
export function buildAgentSystemPrompt(vaultContext?: string): string {
|
||||
@@ -57,7 +58,7 @@ export async function streamClaudeAgent(
|
||||
): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
|
||||
@@ -52,6 +52,14 @@ describe('buildSystemPrompt', () => {
|
||||
expect(result.prompt).toContain('Hello world')
|
||||
expect(result.totalTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': 'content' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
expect(result.prompt).toContain('[[')
|
||||
expect(result.prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --- nextMessageId ---
|
||||
|
||||
@@ -34,6 +34,7 @@ export function buildSystemPrompt(
|
||||
const preamble = [
|
||||
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user has selected the following notes as context. Use them to answer questions accurately.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
|
||||
35
src/utils/chatWikilinks.ts
Normal file
35
src/utils/chatWikilinks.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const WIKILINK_SCHEME = 'wikilink://'
|
||||
|
||||
export { WIKILINK_SCHEME }
|
||||
|
||||
function encodeTarget(target: string): string {
|
||||
return encodeURIComponent(target).replace(/\(/g, '%28').replace(/\)/g, '%29')
|
||||
}
|
||||
|
||||
function escapeLinkText(text: string): string {
|
||||
return text.replace(/[[\]]/g, '\\$&')
|
||||
}
|
||||
|
||||
function replaceWikilinksInText(text: string): string {
|
||||
return text.replace(/\[\[([^\]]+)\]\]/g, (_, inner: string) => {
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
|
||||
const display = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : inner
|
||||
return `[${escapeLinkText(display)}](${WIKILINK_SCHEME}${encodeTarget(target)})`
|
||||
})
|
||||
}
|
||||
|
||||
/** Convert [[Target]] to markdown links, but skip code blocks and inline code. */
|
||||
export function preprocessWikilinks(md: string): string {
|
||||
const segments: string[] = []
|
||||
const codeRegex = /(```[\s\S]*?```|`[^`\n]+`)/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
while ((match = codeRegex.exec(md)) !== null) {
|
||||
segments.push(replaceWikilinksInText(md.slice(lastIndex, match.index)))
|
||||
segments.push(match[0])
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
segments.push(replaceWikilinksInText(md.slice(lastIndex)))
|
||||
return segments.join('')
|
||||
}
|
||||
46
tests/smoke/ai-chat-wikilink-clickable.spec.ts
Normal file
46
tests/smoke/ai-chat-wikilink-clickable.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('AI chat wikilink rendering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
|
||||
// Select a note first so the AI panel has context
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open AI Chat with Ctrl+I
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Send a message to get a mock response containing wikilinks
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
await input.fill('Tell me about this note')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
// Wait for mock response (contains [[Build Laputa App]] and [[Matteo Cellini]])
|
||||
const wikilink = page.locator('.chat-wikilink').first()
|
||||
await expect(wikilink).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify wikilink text and attributes
|
||||
await expect(wikilink).toHaveText('Build Laputa App')
|
||||
await expect(wikilink).toHaveAttribute('data-wikilink-target', 'Build Laputa App')
|
||||
await expect(wikilink).toHaveAttribute('role', 'link')
|
||||
|
||||
// Verify second wikilink
|
||||
const secondWikilink = page.locator('.chat-wikilink').nth(1)
|
||||
await expect(secondWikilink).toHaveText('Matteo Cellini')
|
||||
|
||||
// Verify multiple wikilinks rendered
|
||||
const allWikilinks = page.locator('.chat-wikilink')
|
||||
await expect(allWikilinks).toHaveCount(2)
|
||||
|
||||
// Verify wikilink has pointer cursor (is styled as clickable)
|
||||
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
|
||||
expect(cursor).toBe('pointer')
|
||||
})
|
||||
})
|
||||
114
tests/smoke/ai-notes-visibility-fix.spec.ts
Normal file
114
tests/smoke/ai-notes-visibility-fix.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
const NEW_NOTE_PATH = '/Users/luca/Laputa/note/ai-created-note.md'
|
||||
const NEW_NOTE_TITLE = 'AI Created Note'
|
||||
const NEW_NOTE_CONTENT = `---
|
||||
title: ${NEW_NOTE_TITLE}
|
||||
type: Note
|
||||
---
|
||||
|
||||
# ${NEW_NOTE_TITLE}
|
||||
|
||||
This note was created by the AI agent.
|
||||
`
|
||||
|
||||
function broadcast(wss: WebSocketServer, data: Record<string, unknown>) {
|
||||
const msg = JSON.stringify(data)
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until at least one WebSocket client connects. */
|
||||
function waitForClient(wss: WebSocketServer, timeoutMs = 10000): Promise<void> {
|
||||
if (wss.clients.size > 0) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('No WS client connected')), timeoutMs)
|
||||
wss.on('connection', () => { clearTimeout(timer); resolve() })
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('AI-created note visibility', () => {
|
||||
let wss: WebSocketServer
|
||||
/** When true, the intercepted list_vault response includes the new note. */
|
||||
let injectNote = false
|
||||
|
||||
test.beforeEach(async () => {
|
||||
wss = new WebSocketServer({ port: 9711 })
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
wss.close()
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
})
|
||||
|
||||
test('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => {
|
||||
// Intercept list_vault API calls. On reload (after vault_changed),
|
||||
// the injected note will be included in the response.
|
||||
await page.route('**/api/vault/list*', async (route) => {
|
||||
const response = await route.fetch()
|
||||
if (!injectNote) {
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
const entries = await response.json()
|
||||
const now = Date.now()
|
||||
entries.push({
|
||||
path: NEW_NOTE_PATH, filename: 'ai-created-note.md',
|
||||
title: NEW_NOTE_TITLE, isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: NEW_NOTE_CONTENT.length,
|
||||
snippet: 'This note was created by the AI agent.', wordCount: 10,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
visible: null, outgoingLinks: [], properties: {},
|
||||
})
|
||||
await route.fulfill({ json: entries })
|
||||
})
|
||||
|
||||
// Intercept content fetch for the new note
|
||||
await page.route('**/api/vault/content*ai-created-note*', async (route) => {
|
||||
await route.fulfill({ json: { content: NEW_NOTE_CONTENT } })
|
||||
})
|
||||
|
||||
// Intercept all-content to include new note
|
||||
await page.route('**/api/vault/all-content*', async (route) => {
|
||||
const response = await route.fetch()
|
||||
if (!injectNote) {
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
data[NEW_NOTE_PATH] = NEW_NOTE_CONTENT
|
||||
await route.fulfill({ json: data })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Wait for note list to render
|
||||
const noteList = page.locator('.app__note-list')
|
||||
await expect(noteList).toBeVisible()
|
||||
await expect(noteList.getByText(NEW_NOTE_TITLE)).not.toBeVisible()
|
||||
|
||||
// Enable injection for subsequent reloads
|
||||
injectNote = true
|
||||
|
||||
// Wait for the frontend's useAiActivity WS to connect to our bridge
|
||||
await waitForClient(wss)
|
||||
|
||||
// Simulate MCP broadcasting vault_changed then open_tab
|
||||
// (This is what happens when Claude Code calls open_note via MCP)
|
||||
broadcast(wss, { type: 'ui_action', action: 'vault_changed', path: 'note/ai-created-note.md' })
|
||||
|
||||
// Verify: note appears in note list after vault reload
|
||||
await expect(noteList.getByText(NEW_NOTE_TITLE)).toBeVisible({ timeout: 8000 })
|
||||
|
||||
// Send open_tab after vault is reloaded so the entry is in entriesByPath
|
||||
broadcast(wss, { type: 'ui_action', action: 'open_tab', path: NEW_NOTE_PATH })
|
||||
|
||||
// Verify: note content is displayed in the editor (proves tab opened)
|
||||
await expect(page.getByText('This note was created by the AI agent.')).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user