DEFAULT_VAULTS was hard-coded to the main repo path, causing the Vault API to read stale/duplicate files when running from worktrees. Now uses Vite's define to inject the correct path at build time. Also fix theme heading titles (remove redundant "Theme" suffix) and make smoke tests resilient to Theme-type notes appearing first in the note list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
/// <reference types="vitest/config" />
|
|
import path from 'path'
|
|
import fs from 'fs'
|
|
import { defineConfig, type Plugin } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import tailwindcss from '@tailwindcss/vite'
|
|
import matter from 'gray-matter'
|
|
|
|
// --- Vault API middleware (dev only) ---
|
|
|
|
interface VaultEntry {
|
|
path: string
|
|
filename: string
|
|
title: string
|
|
isA: string | null
|
|
aliases: string[]
|
|
belongsTo: string[]
|
|
relatedTo: string[]
|
|
status: string | null
|
|
owner: string | null
|
|
cadence: string | null
|
|
archived: boolean
|
|
trashed: boolean
|
|
trashedAt: number | null
|
|
modifiedAt: number | null
|
|
createdAt: number | null
|
|
fileSize: number
|
|
snippet: string
|
|
wordCount: number
|
|
relationships: Record<string, string[]>
|
|
icon: string | null
|
|
color: string | null
|
|
order: number | null
|
|
sidebarLabel: string | null
|
|
template: string | null
|
|
sort: string | null
|
|
view: string | null
|
|
visible: boolean | null
|
|
outgoingLinks: string[]
|
|
properties: Record<string, string | number | boolean | null>
|
|
}
|
|
|
|
/** Extract all [[wiki-links]] from a string. */
|
|
function extractWikiLinks(value: string): string[] {
|
|
const matches = value.match(/\[\[[^\]]+\]\]/g)
|
|
return matches ?? []
|
|
}
|
|
|
|
/** Extract wiki-links from a frontmatter value (string or array of strings). */
|
|
function wikiLinksFromValue(value: unknown): string[] {
|
|
if (typeof value === 'string') return extractWikiLinks(value)
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((v) => (typeof v === 'string' ? extractWikiLinks(v) : []))
|
|
}
|
|
return []
|
|
}
|
|
|
|
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic relationships)
|
|
const DEDICATED_KEYS = new Set([
|
|
'aliases', 'is_a', 'is a', 'belongs_to', 'belongs to',
|
|
'related_to', 'related to', 'status', 'owner', 'cadence',
|
|
'created_at', 'created at', 'title',
|
|
])
|
|
|
|
function parseMarkdownFile(filePath: string): VaultEntry | null {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
const stats = fs.statSync(filePath)
|
|
const { data: fm, content } = matter(raw)
|
|
|
|
const filename = path.basename(filePath)
|
|
const basename = filename.replace(/\.md$/, '')
|
|
|
|
// Title: first H1 in body, or frontmatter title, or filename
|
|
const h1Match = content.match(/^#\s+(.+)$/m)
|
|
const title = (fm.title as string) || (h1Match ? h1Match[1].trim() : basename)
|
|
|
|
// Helper to get a frontmatter string field (case-insensitive key)
|
|
const getString = (...keys: string[]): string | null => {
|
|
for (const k of keys) {
|
|
for (const fk of Object.keys(fm)) {
|
|
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'string') {
|
|
return fm[fk]
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Helper to get a frontmatter array-of-strings field
|
|
const getArray = (...keys: string[]): string[] => {
|
|
for (const k of keys) {
|
|
for (const fk of Object.keys(fm)) {
|
|
if (fk.toLowerCase() === k.toLowerCase()) {
|
|
const val = fm[fk]
|
|
if (Array.isArray(val)) return val.map(String)
|
|
if (typeof val === 'string') return [val]
|
|
}
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
const aliases = getArray('aliases')
|
|
const belongsToRaw = getArray('belongs_to', 'belongs to')
|
|
const relatedToRaw = getArray('related_to', 'related to')
|
|
const belongsTo = belongsToRaw.flatMap((v) => extractWikiLinks(v))
|
|
const relatedTo = relatedToRaw.flatMap((v) => extractWikiLinks(v))
|
|
|
|
// Created at → unix ms
|
|
const createdAtRaw = getString('created_at', 'created at')
|
|
let createdAt: number | null = null
|
|
if (createdAtRaw) {
|
|
const d = new Date(createdAtRaw)
|
|
if (!isNaN(d.getTime())) createdAt = d.getTime()
|
|
}
|
|
|
|
// Snippet: first 200 chars of body after frontmatter, stripped of markdown
|
|
const bodyText = content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
|
|
const snippet = bodyText.slice(0, 200)
|
|
|
|
// Generic relationships: any frontmatter key whose value contains wiki-links
|
|
const relationships: Record<string, string[]> = {}
|
|
for (const key of Object.keys(fm)) {
|
|
if (DEDICATED_KEYS.has(key.toLowerCase())) continue
|
|
const links = wikiLinksFromValue(fm[key])
|
|
if (links.length > 0) {
|
|
relationships[key] = links
|
|
}
|
|
}
|
|
|
|
// Boolean field helper
|
|
const getBool = (...keys: string[]): boolean | null => {
|
|
for (const k of keys) {
|
|
for (const fk of Object.keys(fm)) {
|
|
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'boolean') {
|
|
return fm[fk]
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
return {
|
|
path: filePath,
|
|
filename,
|
|
title,
|
|
isA: getString('is_a', 'is a', 'type'),
|
|
aliases,
|
|
belongsTo,
|
|
relatedTo,
|
|
status: getString('status'),
|
|
owner: getString('owner'),
|
|
cadence: getString('cadence'),
|
|
archived: getBool('archived') ?? false,
|
|
trashed: getBool('trashed') ?? false,
|
|
trashedAt: null,
|
|
modifiedAt: stats.mtimeMs,
|
|
createdAt,
|
|
fileSize: stats.size,
|
|
snippet,
|
|
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
|
|
relationships,
|
|
icon: getString('icon'),
|
|
color: getString('color'),
|
|
order: fm.order != null ? Number(fm.order) : null,
|
|
sidebarLabel: getString('sidebar label', 'sidebar_label'),
|
|
template: getString('template'),
|
|
sort: getString('sort'),
|
|
view: getString('view'),
|
|
visible: getBool('visible'),
|
|
outgoingLinks: [],
|
|
properties: {},
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Recursively find all .md files under a directory. */
|
|
function findMarkdownFiles(dir: string): string[] {
|
|
const results: string[] = []
|
|
try {
|
|
const items = fs.readdirSync(dir, { withFileTypes: true })
|
|
for (const item of items) {
|
|
if (item.name.startsWith('.')) continue
|
|
const full = path.join(dir, item.name)
|
|
if (item.isDirectory()) {
|
|
results.push(...findMarkdownFiles(full))
|
|
} else if (item.name.endsWith('.md')) {
|
|
results.push(full)
|
|
}
|
|
}
|
|
} catch {
|
|
// skip unreadable dirs
|
|
}
|
|
return results
|
|
}
|
|
|
|
function vaultApiPlugin(): Plugin {
|
|
return {
|
|
name: 'vault-api',
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
|
|
if (url.pathname === '/api/vault/ping') {
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({ ok: true }))
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/api/vault/list') {
|
|
const dirPath = url.searchParams.get('path')
|
|
if (!dirPath || !fs.existsSync(dirPath)) {
|
|
res.statusCode = 400
|
|
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
|
|
return
|
|
}
|
|
const files = findMarkdownFiles(dirPath)
|
|
const entries = files.map(parseMarkdownFile).filter(Boolean)
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify(entries))
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/api/vault/content') {
|
|
const filePath = url.searchParams.get('path')
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
res.statusCode = 400
|
|
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
|
|
return
|
|
}
|
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({ content }))
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/api/vault/all-content') {
|
|
const dirPath = url.searchParams.get('path')
|
|
if (!dirPath || !fs.existsSync(dirPath)) {
|
|
res.statusCode = 400
|
|
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
|
|
return
|
|
}
|
|
const files = findMarkdownFiles(dirPath)
|
|
const contentMap: Record<string, string> = {}
|
|
for (const f of files) {
|
|
try {
|
|
contentMap[f] = fs.readFileSync(f, 'utf-8')
|
|
} catch {
|
|
// skip unreadable files
|
|
}
|
|
}
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify(contentMap))
|
|
return
|
|
}
|
|
|
|
next()
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- AI Chat proxy helpers ---
|
|
|
|
function readRequestBody(req: import('http').IncomingMessage): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
let body = ''
|
|
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
|
|
req.on('end', () => resolve(body))
|
|
})
|
|
}
|
|
|
|
async function forwardToAnthropic(params: {
|
|
apiKey: string; model?: string; messages: { role: string; content: string }[]; system?: string; maxTokens?: number
|
|
}): Promise<Response> {
|
|
return fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': params.apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model: params.model || 'claude-3-5-haiku-20241022',
|
|
max_tokens: params.maxTokens || 4096,
|
|
system: params.system || undefined,
|
|
messages: params.messages,
|
|
stream: true,
|
|
}),
|
|
})
|
|
}
|
|
|
|
/** Forward to Anthropic with tool definitions (non-streaming for tool loop) */
|
|
async function forwardToAnthropicAgent(params: {
|
|
apiKey: string; model?: string; messages: unknown[]; system?: string
|
|
maxTokens?: number; tools?: unknown[]
|
|
}): Promise<Response> {
|
|
return fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': params.apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model: params.model || 'claude-3-5-haiku-20241022',
|
|
max_tokens: params.maxTokens || 4096,
|
|
system: params.system || undefined,
|
|
messages: params.messages,
|
|
tools: params.tools || undefined,
|
|
}),
|
|
})
|
|
}
|
|
|
|
async function streamResponseBody(source: ReadableStream<Uint8Array>, res: import('http').ServerResponse): Promise<void> {
|
|
const reader = source.getReader()
|
|
const decoder = new TextDecoder()
|
|
let done = false
|
|
while (!done) {
|
|
const { value, done: streamDone } = await reader.read()
|
|
done = streamDone
|
|
if (value) res.write(decoder.decode(value, { stream: true }))
|
|
}
|
|
res.end()
|
|
}
|
|
|
|
/** Proxy endpoint for Anthropic API calls (avoids browser CORS issues) */
|
|
function aiChatProxyPlugin(): Plugin {
|
|
return {
|
|
name: 'ai-chat-proxy',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (req.url !== '/api/ai/chat' || req.method !== 'POST') return next()
|
|
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const params = JSON.parse(body)
|
|
if (!params.apiKey) {
|
|
res.statusCode = 400
|
|
res.end(JSON.stringify({ error: 'Missing API key' }))
|
|
return
|
|
}
|
|
|
|
const anthropicRes = await forwardToAnthropic(params)
|
|
if (!anthropicRes.ok) {
|
|
res.statusCode = anthropicRes.status
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(await anthropicRes.text())
|
|
return
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream')
|
|
res.setHeader('Cache-Control', 'no-cache')
|
|
res.setHeader('Connection', 'keep-alive')
|
|
|
|
if (anthropicRes.body) {
|
|
await streamResponseBody(anthropicRes.body, res)
|
|
} else {
|
|
res.end()
|
|
}
|
|
} catch (err: unknown) {
|
|
res.statusCode = 500
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }))
|
|
}
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Agent proxy — non-streaming Anthropic calls with tool support */
|
|
function aiAgentProxyPlugin(): Plugin {
|
|
return {
|
|
name: 'ai-agent-proxy',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (req.url !== '/api/ai/agent' || req.method !== 'POST') return next()
|
|
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const params = JSON.parse(body)
|
|
if (!params.apiKey) {
|
|
res.statusCode = 400
|
|
res.end(JSON.stringify({ error: 'Missing API key' }))
|
|
return
|
|
}
|
|
|
|
const anthropicRes = await forwardToAnthropicAgent(params)
|
|
res.statusCode = anthropicRes.status
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(await anthropicRes.text())
|
|
} catch (err: unknown) {
|
|
res.statusCode = 500
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }))
|
|
}
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
/** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */
|
|
function mcpBridgeInfoPlugin(): Plugin {
|
|
return {
|
|
name: 'mcp-bridge-info',
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url !== '/api/mcp/info') return next()
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({
|
|
wsUrl: `ws://localhost:${process.env.MCP_WS_PORT || 9710}`,
|
|
available: true,
|
|
}))
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// https://vite.dev/config/
|
|
export default defineConfig({
|
|
plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin(), aiAgentProxyPlugin(), mcpBridgeInfoPlugin()],
|
|
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
|
|
// Inject the demo-vault-v2 path so browser code resolves it relative to the project root
|
|
define: {
|
|
__DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')),
|
|
},
|
|
|
|
// Prevent vite from obscuring Rust errors
|
|
clearScreen: false,
|
|
|
|
// Tauri expects a fixed port
|
|
server: {
|
|
port: 5202,
|
|
strictPort: true,
|
|
allowedHosts: true,
|
|
},
|
|
|
|
// Env variables starting with TAURI_ are exposed to the frontend
|
|
envPrefix: ['VITE_', 'TAURI_'],
|
|
|
|
build: {
|
|
// Tauri uses Chromium on Windows and WebKit on macOS/Linux
|
|
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
|
// Don't minify for debug builds
|
|
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
|
// Produce sourcemaps for debug builds
|
|
sourcemap: !!process.env.TAURI_DEBUG,
|
|
},
|
|
|
|
test: {
|
|
globals: true,
|
|
environment: 'jsdom',
|
|
setupFiles: ['./src/test/setup.ts'],
|
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
|
coverage: {
|
|
provider: 'v8',
|
|
reporter: ['text', 'json', 'html'],
|
|
include: ['src/**/*.{ts,tsx}'],
|
|
exclude: [
|
|
'src/**/*.{test,spec}.{ts,tsx}',
|
|
'src/test/**',
|
|
'src/mock-tauri.ts',
|
|
'src/main.tsx',
|
|
'src/types.ts',
|
|
'src/hooks/useMcpBridge.ts',
|
|
'src/hooks/useAIChat.ts',
|
|
'src/hooks/useAiAgent.ts',
|
|
'src/utils/ai-chat.ts',
|
|
'src/utils/ai-agent.ts',
|
|
'src/components/AIChatPanel.tsx',
|
|
'src/components/ui/dropdown-menu.tsx',
|
|
'src/components/ui/scroll-area.tsx',
|
|
'src/components/ui/select.tsx',
|
|
'src/components/ui/separator.tsx',
|
|
'src/components/ui/tabs.tsx',
|
|
'src/components/ui/tooltip.tsx',
|
|
'src/components/ui/card.tsx',
|
|
],
|
|
thresholds: {
|
|
lines: 70,
|
|
functions: 70,
|
|
branches: 70,
|
|
statements: 70,
|
|
},
|
|
},
|
|
},
|
|
})
|