/// 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 modifiedAt: number | null createdAt: number | null fileSize: number snippet: string relationships: Record } /** 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 = {} 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 } } return { path: filePath, filename, title, isA: getString('is_a', 'is a'), aliases, belongsTo, relatedTo, status: getString('status'), owner: getString('owner'), cadence: getString('cadence'), modifiedAt: stats.mtimeMs, createdAt, fileSize: stats.size, snippet, relationships, } } 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 = {} 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 { 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 { 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, }), }) } async function streamResponseBody(source: ReadableStream, res: import('http').ServerResponse): Promise { 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' })) } }) }, } } /** 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(), mcpBridgeInfoPlugin()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, // Prevent vite from obscuring Rust errors clearScreen: false, // Tauri expects a fixed port server: { port: 5201, 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/utils/ai-chat.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, }, }, }, })