From 2e907cecd6f129b3c4bec94dd044af3b5784ff1e Mon Sep 17 00:00:00 2001 From: d-oit Date: Tue, 26 May 2026 21:00:06 +0200 Subject: [PATCH 01/25] fix(security,bug): Wave 1 - XSS, API key masking, critical bugs - #172/#168: XSS in static site export - sanitize entity descriptions - #173/#169: XSS in CLI export - escapeHtml in markdown export - #174/#170: API key exposure - mask keys, add warnings - #176: Chat 'Create new entity' carries search context - #175: Add Library nav item pointing to editor - #171: Fix GraphInspector dual-virtualizer scrollRef conflict --- .env.example | 8 +- cli/index.ts | 9 +- plans/030-goap-implement-all-open-issues.md | 82 +++++++++++++++++ src/app/App.tsx | 7 +- src/components/SidebarNav.tsx | 3 +- src/features/ai/AIHarness.tsx | 85 ++++++++++++++++-- src/features/chat/Chat.tsx | 2 +- src/features/graph/GraphInspector.tsx | 99 +++++++++++---------- src/lib/export-core.ts | 2 +- src/lib/llm/config.ts | 5 ++ 10 files changed, 238 insertions(+), 64 deletions(-) create mode 100644 plans/030-goap-implement-all-open-issues.md diff --git a/.env.example b/.env.example index 52d5083..a5d48d2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ -# LLM API Settings (Optional) +# WARNING: VITE_ prefixed variables are exposed to the client bundle. +# Only use VITE_ for user-provided API keys (e.g. keys the user enters in the UI), +# never for developer secrets, backend tokens, or infrastructure credentials. +# VITE_ prefixed variables are compiled into the JavaScript bundle at build time +# and are visible to anyone who inspects the client-side source code. + +# LLM API Settings (Optional - user-provided API keys) VITE_LLM_API_KEY= VITE_LLM_API_BASE_URL= diff --git a/cli/index.ts b/cli/index.ts index af1371b..cf6e826 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -121,6 +121,7 @@ program }); async function exportMarkdown(outDir: string) { + const { escapeHtml } = await import('../src/lib/security.js'); const entities = await repository.getAllEntities(); for (const entity of entities) { @@ -128,17 +129,17 @@ async function exportMarkdown(outDir: string) { const claims = await repository.getClaimsByEntityId(entity.id); const notes = await repository.getNotesByEntityId(entity.id); - let md = `# ${entity.name}\n\n`; - md += `**Type:** ${entity.type}\n\n`; + let md = `# ${escapeHtml(entity.name)}\n\n`; + md += `**Type:** ${escapeHtml(entity.type)}\n\n`; if (entity.description) md += `${entity.description}\n\n`; if (claims.length > 0) { md += `## Claims\n\n`; for (const claim of claims) { - md += `- ${claim.statement}`; + md += `- ${escapeHtml(claim.statement)}`; if (claim.confidence !== 1) md += ` (confidence: ${claim.confidence})`; md += `\n`; - if (claim.evidence) md += ` - *Evidence:* ${claim.evidence}\n`; + if (claim.evidence) md += ` - *Evidence:* ${escapeHtml(claim.evidence)}\n`; } md += '\n'; } diff --git a/plans/030-goap-implement-all-open-issues.md b/plans/030-goap-implement-all-open-issues.md new file mode 100644 index 0000000..24dd66a --- /dev/null +++ b/plans/030-goap-implement-all-open-issues.md @@ -0,0 +1,82 @@ +# GOAP Plan: Implement All Open GitHub Issues + +## Task Analysis + +**Primary Goal**: Close all 30 open issues across d-oit/do-knowledge-studio +**Constraints**: Local-first, strict TypeScript, atomic commits, quality gates +**Complexity**: Complex (30 issues across 5 waves) + +## Issue Decomposition + +### Wave 1: Security + Critical Bugs (8 issues) +| # | Title | Priority | +|---|-------|----------| +| 172 | XSS in static site export (ExportPanel.tsx) | critical | +| 168 | XSS in static site export (ExportPanel.tsx) duplicate | critical | +| 173 | XSS in CLI site export (cli/index.ts) | critical | +| 169 | XSS in CLI site export (cli/index.ts) duplicate | critical | +| 176 | "Create new entity" button in Chat does nothing | high | +| 175 | "Library" sidebar nav item points to non-existent view | high | +| 171 | GraphInspector component defined but never rendered (dead code) | high | +| 174 | API key exposure via VITE_ environment variables | high | +| 170 | API key exposure via VITE_ environment variables duplicate | high | + +### Wave 2: Error Handling + Type Safety + Broken References (6 issues) +| # | Title | Priority | +|---|-------|----------| +| 192 | Fix error handling gaps across the codebase | high | +| 190 | Fix type safety issues — eliminate `any` and unsafe casts | high | +| 185 | Add database migration system | high | +| 177 | Version inconsistency: MIGRATION.md badge vs VERSION file | high | +| 179 | pre-commit-hook.sh references deleted QUICKSTART.md | medium | +| 178 | Broken discussions URL in ISSUE_TEMPLATE config | medium | +| 180 | CLI version hardcoded, not synced with VERSION file | medium | + +### Wave 3: Documentation + CI/CD + A11y + Config (5 issues) +| # | Title | Priority | +|---|-------|----------| +| 196 | Fix documentation inconsistencies and stale references | medium | +| 197 | Fix accessibility gaps across the app | medium | +| 194 | Add CI job timeouts and caching for faster builds | medium | +| 193 | Increase test coverage from ~25% to meaningful thresholds | high | +| 198 | Fix tsconfig.app.json including Node types in browser build | low | + +### Wave 4: Features — Export, Graph, Mind Map, CLI, LLM, DB (8 issues) +| # | Title | Priority | +|---|-------|----------| +| 191 | Deduplicate export logic between ExportPanel.tsx and CLI | medium | +| 199 | Add graph export as PNG/image | low | +| 181 | Add entity editing and deletion in the UI | high | +| 183 | Add mind map node editing (add, rename, delete) | medium | +| 182 | Add keyboard-accessible graph navigation | medium | +| 187 | Add CLI search command and missing CRUD commands | medium | +| 188 | Add LLM provider setup wizard and model selector UI | medium | +| 186 | Add CHECK/unique constraints to database schema | medium | + +### Wave 5: Performance + Test Coverage + Graph Layouts (3 issues) +| # | Title | Priority | +|---|-------|----------| +| 195 | Fix performance concerns — memory, N+1, unbounded caches | medium | +| 184 | Add force-directed and hierarchical graph layout algorithms | medium | +| 189 | Add PDF and DOCX export formats | low | + +## Execution Strategy + +**Hybrid**: Swarm agents within each wave, sequential waves with quality gates + +``` +Wave 1 (Security) → Quality Gate → Wave 2 (Error/Types) → Quality Gate +→ Wave 3 (Docs/CI) → Quality Gate → Wave 4 (Features) → Quality Gate +→ Wave 5 (Perf) → Quality Gate → Final PR +``` + +## Quality Gates +- Lint: `pnpm run lint` +- TypeCheck: `pnpm run typecheck` +- Tests: `pnpm run test` +- Build: `pnpm run build` +- E2E: `pnpm run test:e2e` + +## Branch Strategy +- One feature branch per wave merged into main via PR +- Atomic commits with conventional commit messages diff --git a/src/app/App.tsx b/src/app/App.tsx index db77440..275b978 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -42,7 +42,7 @@ const Chat = lazy(preloadChat); const ExportPanel = lazy(preloadExport); const AIHarness = lazy(preloadAI); -type View = 'editor' | 'graph' | 'mindmap' | 'chat' | 'export' | 'ai'; +type View = 'editor' | 'graph' | 'mindmap' | 'chat' | 'export' | 'ai' | 'library'; const AppContent: React.FC = () => { const { dbReady, error } = useDb(); @@ -67,6 +67,7 @@ const AppContent: React.FC = () => { case 'export': void preloadExport(); break; case 'ai': void preloadAI(); break; case 'search': void preloadSearch(); break; + case 'library': void preloadEditor(); break; } }, []); @@ -169,7 +170,7 @@ const AppContent: React.FC = () => {
{!dbReady &&
Booting Knowledge Studio...
} - {dbReady && currentView === 'editor' && ( + {(dbReady && currentView === 'editor') || currentView === 'library' ? ( }> window.location.reload()}> @@ -180,7 +181,7 @@ const AppContent: React.FC = () => { - )} + ) : null} {dbReady && currentView === 'graph' && ( }> window.location.reload()}> diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 985f580..d753cbf 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,6 +1,6 @@ import React from 'react'; -type View = 'editor' | 'graph' | 'mindmap' | 'chat' | 'export' | 'ai' | 'search'; +type View = 'editor' | 'graph' | 'mindmap' | 'chat' | 'export' | 'ai' | 'search' | 'library'; interface SidebarNavProps { currentView: View; @@ -31,6 +31,7 @@ const NAV_GROUPS: NavGroup[] = [ { group: 'Explore', items: [ + { view: 'library', label: 'Library' }, { view: 'graph', label: 'Graph' }, { view: 'mindmap', label: 'Mind Map' }, ], diff --git a/src/features/ai/AIHarness.tsx b/src/features/ai/AIHarness.tsx index 3df286f..4683e2b 100644 --- a/src/features/ai/AIHarness.tsx +++ b/src/features/ai/AIHarness.tsx @@ -1,9 +1,9 @@ import React, { useState, useRef, useEffect } from 'react'; -import { loadConfig, createProvider } from '../../lib/llm/config'; +import { loadConfig, saveConfig, createProvider, maskApiKey } from '../../lib/llm/config'; import { searchKnowledge } from '../../lib/search'; import { resolveUrl, ResolvedContent } from '../../lib/resolver'; import { logger } from '../../lib/logger'; -import { Send, Loader2, Bot, User, Database, Globe, ExternalLink, X } from 'lucide-react'; +import { Send, Loader2, Bot, User, Database, Globe, ExternalLink, X, Settings, Key, AlertTriangle } from 'lucide-react'; interface Message { role: 'assistant' | 'user' | 'system'; @@ -19,6 +19,10 @@ const safeHostname = (url: string): string => { }; const AIHarness: React.FC = () => { + const [config, setConfig] = useState(() => loadConfig()); + const [showSettings, setShowSettings] = useState(false); + const [editApiKey, setEditApiKey] = useState(''); + const [editProvider, setEditProvider] = useState(config.activeProvider); const [messages, setMessages] = useState([ { role: 'assistant', content: 'AI agent ready to assist with TRIZ analysis and knowledge synthesis. Ask me anything about your local knowledge base, or paste URLs to have me fetch and analyze external content.' } ]); @@ -37,6 +41,18 @@ const AIHarness: React.FC = () => { scrollToBottom(); }, [messages]); + const handleSaveSettings = () => { + const updated = { ...config }; + updated.activeProvider = editProvider; + if (editApiKey) { + updated.providers[editProvider] = { ...updated.providers[editProvider], apiKey: editApiKey }; + } + saveConfig(updated); + setConfig(updated); + setShowSettings(false); + setEditApiKey(''); + }; + const handleSend = async () => { if (!input.trim() || isLoading) return; @@ -131,15 +147,72 @@ const AIHarness: React.FC = () => { } }; + const currentApiKey = config.providers[config.activeProvider]?.apiKey || ''; + const hasKey = currentApiKey.length > 0; + return (

AI Harness

- +
+ {hasKey && ( + + {maskApiKey(currentApiKey)} + + )} + + +
+ {!hasKey && ( +
+ + No API key configured. Set one in the settings () to enable AI features. +
+ )} + + {showSettings && ( +
+
+ + + +
+ setEditApiKey(e.target.value)} + placeholder={hasKey ? 'Leave blank to keep current key' : 'Enter API key'} + style={{ flex: 1, padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--border-default)' }} + /> + +
+
+
+ )} + {/* Sourcing indicator */} {isSourcing && (
diff --git a/src/features/chat/Chat.tsx b/src/features/chat/Chat.tsx index ef91133..ffdd3e0 100644 --- a/src/features/chat/Chat.tsx +++ b/src/features/chat/Chat.tsx @@ -79,7 +79,7 @@ const Chat: React.FC = () => {
-
diff --git a/src/features/graph/GraphInspector.tsx b/src/features/graph/GraphInspector.tsx index f44d233..81bf475 100644 --- a/src/features/graph/GraphInspector.tsx +++ b/src/features/graph/GraphInspector.tsx @@ -34,7 +34,8 @@ const GraphInspector: React.FC = ({ [links, entity.id] ); - const scrollRef = useRef(null); + const claimsScrollRef = useRef(null); + const relationsScrollRef = useRef(null); const allRelations = useMemo(() => { const items: Array<{ type: 'outgoing' | 'incoming'; id: string; relation: string; targetId: string }> = []; @@ -49,14 +50,14 @@ const GraphInspector: React.FC = ({ const claimVirtualizer = useVirtualizer({ count: claims.length, - getScrollElement: () => scrollRef.current, + getScrollElement: () => claimsScrollRef.current, estimateSize: () => 48, overscan: 5, }); const relationVirtualizer = useVirtualizer({ count: allRelations.length, - getScrollElement: () => scrollRef.current, + getScrollElement: () => relationsScrollRef.current, estimateSize: () => 40, overscan: 5, }); @@ -104,7 +105,7 @@ const GraphInspector: React.FC = ({
-
+
{entity.type}
{entity.description && ( @@ -115,55 +116,59 @@ const GraphInspector: React.FC = ({ {claims.length > 0 && (

Claims ({claims.length})

-
    - {claimVirtualizer.getVirtualItems().map(virtualItem => { - const claim = claims[virtualItem.index]; - return ( -
  • -
    {claim.statement}
    - {claim.evidence && ( -
    - Evidence: {claim.evidence} -
    - )} -
  • - ); - })} -
+
+
    + {claimVirtualizer.getVirtualItems().map(virtualItem => { + const claim = claims[virtualItem.index]; + return ( +
  • +
    {claim.statement}
    + {claim.evidence && ( +
    + Evidence: {claim.evidence} +
    + )} +
  • + ); + })} +
+
)} {allRelations.length > 0 && (

Relationships ({allRelations.length})

-
    - {relationVirtualizer.getVirtualItems().map(virtualItem => { - const rel = allRelations[virtualItem.index]; - return ( -
  • -
    - {rel.type === 'outgoing' ? ( - <>{rel.relation} → {getEntityName(rel.targetId)} - ) : ( - <>{getEntityName(rel.targetId)} → {rel.relation} - )} -
    -
  • - ); - })} -
+
+
    + {relationVirtualizer.getVirtualItems().map(virtualItem => { + const rel = allRelations[virtualItem.index]; + return ( +
  • +
    + {rel.type === 'outgoing' ? ( + <>{rel.relation} → {getEntityName(rel.targetId)} + ) : ( + <>{getEntityName(rel.targetId)} → {rel.relation} + )} +
    +
  • + ); + })} +
+
)} diff --git a/src/lib/export-core.ts b/src/lib/export-core.ts index d15ff93..12f3121 100644 --- a/src/lib/export-core.ts +++ b/src/lib/export-core.ts @@ -182,7 +182,7 @@ export function generatePrintHtml(entities: Entity[], claims: Record${escapeHtml(entity.name)}\n`; if (entity.description) { - html += `
${entity.description}
\n`; + html += `
${sanitizeHtml(entity.description)}
\n`; } if (entityClaims.length > 0) { diff --git a/src/lib/llm/config.ts b/src/lib/llm/config.ts index a19d827..c2226ac 100644 --- a/src/lib/llm/config.ts +++ b/src/lib/llm/config.ts @@ -66,4 +66,9 @@ export function getProvider(id: string, config?: Partial): LL } } +export function maskApiKey(key: string): string { + if (!key || key.length < 8) return key ? `...${key.slice(-4)}` : ''; + return `...${key.slice(-4)}`; +} + export { OpenRouterProvider, KiloGatewayProvider }; From 57d6a0a2d8bdbba641a708a8ef38bdf892867f95 Mon Sep 17 00:00:00 2001 From: d-oit Date: Tue, 26 May 2026 21:08:58 +0200 Subject: [PATCH 02/25] fix(wave2): error handling, type safety, migration, version consistency - #192: Fix error handling gaps - Editor, App, CLI, config - #190: Fix type safety - repository, CLI, mind map, LLM types - #185: Database migration system with up/down/backup - #177: Fix CHANGELOG links to d-oit/do-knowledge-studio - #180: Update propagate-version.sh for CLI coverage --- CHANGELOG.md | 4 +- cli/db.ts | 7 +- cli/index.ts | 89 +++++++++++++++---------- public/db/migrations/001_initial.sql | 99 ++++++++++++++++++++++++++-- scripts/propagate-version.sh | 1 + src/app/App.tsx | 3 +- src/db/repository.ts | 12 ++-- src/features/editor/Editor.tsx | 3 +- src/features/mindmap/MindMapView.tsx | 5 +- src/lib/llm/config.ts | 4 +- src/lib/llm/kilo.ts | 10 +-- src/lib/llm/openrouter.ts | 10 +-- src/lib/llm/types.ts | 18 +++++ 13 files changed, 204 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c474a0..496ab4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,5 +116,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Quality gate exits with code 2 to surface errors to agent - Progressive disclosure for skills (load on demand) -[Unreleased]: https://github.com/your-org/your-project/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/your-org/your-project/releases/tag/v0.1.0 +[Unreleased]: https://github.com/d-oit/do-knowledge-studio/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/d-oit/do-knowledge-studio/releases/tag/v0.1.0 diff --git a/cli/db.ts b/cli/db.ts index d7c158e..9672297 100644 --- a/cli/db.ts +++ b/cli/db.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import Database from 'better-sqlite3'; import * as fs from 'fs'; import * as path from 'path'; @@ -69,7 +68,11 @@ export const initDb = (): Promise => { return Promise.resolve(result); } - bind ? stmt.run(...bind) : stmt.run(); + if (bind) { + stmt.run(...bind); + } else { + stmt.run(); + } return Promise.resolve([]); }, diff --git a/cli/index.ts b/cli/index.ts index cf6e826..6a74742 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */ import { Command } from 'commander'; import * as fs from 'fs'; import * as path from 'path'; @@ -7,7 +6,6 @@ import { setDb } from '../src/db/client.js'; import { initDb } from './db.js'; import { repository } from '../src/db/repository.js'; import { generateSiteHtml, generateJsonExport } from '../src/lib/export-core.js'; -import type { Note } from '../src/lib/validation'; import { runMigrations, rollbackLastMigration, getMigrationStatus } from '../src/db/migrate.js'; const program = new Command(); @@ -20,10 +18,16 @@ async function ensureDb() { } process.on('exit', () => { - dbInstance?.close(); + void dbInstance?.close(); }); -const version = readFileSync(new URL('../VERSION', import.meta.url), 'utf-8').trim(); +const version = (() => { + try { + return readFileSync(new URL('../VERSION', import.meta.url), 'utf-8').trim(); + } catch { + return 'unknown'; + } +})(); program .name('knowledge-studio') @@ -42,50 +46,51 @@ program .command('sync') .description('Sync Markdown files or URL to DB') .argument('', 'directory path or URL') - .action(async (source) => { + .action(async (source: string) => { await ensureDb(); + const src = String(source); // Detect if source is a URL - if (source.startsWith('http://') || source.startsWith('https://')) { - console.log(`Syncing from URL: ${source}`); + if (src.startsWith('http://') || src.startsWith('https://')) { + console.log(`Syncing from URL: ${src}`); try { const { resolveUrl } = await import('../src/lib/resolver.js'); - const resolved = await resolveUrl(source); + const resolved = await resolveUrl(src); await repository.createEntity({ - name: resolved.title || new URL(source).hostname, + name: resolved.title || new URL(src).hostname, type: 'concept', description: resolved.content || undefined, - metadata: { source_url: source }, + metadata: { source_url: src }, }); console.log(` Imported: ${resolved.title} (${resolved.wordCount} words via ${resolved.provider})`); console.log('Sync complete.'); } catch (err) { - console.error(`Failed to sync URL: ${err}`); + console.error(`Failed to sync URL: ${err instanceof Error ? err.message : String(err)}`); } return; } // Directory sync (existing behavior) - console.log(`Syncing from "${source}"...`); - if (!fs.existsSync(source)) { + console.log(`Syncing from "${src}"...`); + if (!fs.existsSync(src)) { console.error('Directory not found'); return; } let files: string[]; try { - files = fs.readdirSync(source).filter((f: string) => f.endsWith('.md')); + files = fs.readdirSync(src).filter((f: string) => f.endsWith('.md')); } catch (err) { - console.error(`Failed to read directory: ${err}`); + console.error(`Failed to read directory: ${err instanceof Error ? err.message : String(err)}`); return; } console.log(`Found ${files.length} markdown files.`); for (const file of files) { - const content = fs.readFileSync(path.join(source, file), 'utf-8'); - const lines = content.split('\n'); - const title = lines[0].replace('# ', '').trim(); - const description = lines.slice(1).join('\n').trim().slice(0, 200); - try { + const content = fs.readFileSync(path.join(src, file), 'utf-8'); + const lines = content.split('\n'); + const title = lines[0].replace('# ', '').trim(); + const description = lines.slice(1).join('\n').trim().slice(0, 200); + await repository.createEntity({ name: title, type: 'concept', @@ -104,20 +109,21 @@ program .description('Export data (md, json, site)') .option('-f, --format ', 'format', 'md') .option('-o, --output ', 'output directory', './export') - .action(async (options) => { - const outDir = options.output; + .action(async (options: { format?: string; output?: string }) => { + const outDir = options.output ?? './export'; if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); await ensureDb(); - if (options.format === 'json') { + const fmt = options.format ?? 'md'; + if (fmt === 'json') { await exportJson(outDir); - } else if (options.format === 'site') { + } else if (fmt === 'site') { await exportSite(outDir); } else { await exportMarkdown(outDir); } - console.log(`Exported in ${options.format} format to ${outDir}`); + console.log(`Exported in ${fmt} format to ${outDir}`); }); async function exportMarkdown(outDir: string) { @@ -193,12 +199,12 @@ program .option('-t, --type ', 'type', 'concept') .option('-d, --description ', 'description') .option('-u, --source-url ', 'source URL for auto-hydration') - .action(async (name, options) => { + .action(async (name: string, options: { type?: string; description?: string; sourceUrl?: string }) => { await ensureDb(); try { const entity = await repository.createEntity({ name, - type: options.type, + type: options.type ?? 'concept', description: options.description, metadata: options.sourceUrl ? { source_url: options.sourceUrl } : undefined, }); @@ -217,11 +223,11 @@ program console.log(` Hydrated description from ${resolved.provider} (${resolved.wordCount} words)`); } } catch (err) { - console.error(` Failed to resolve URL: ${err}`); + console.error(` Failed to resolve URL: ${err instanceof Error ? err.message : String(err)}`); } } } catch (err) { - console.error(`Failed to create entity: ${err}`); + console.error(`Failed to create entity: ${err instanceof Error ? err.message : String(err)}`); } }); @@ -246,7 +252,7 @@ program .argument('') .argument('') .option('-c, --confidence ', 'confidence', '1.0') - .action(async (entityName, statement, options) => { + .action(async (entityName: string, statement: string, options: { confidence?: string }) => { await ensureDb(); const entity = await repository.getEntityByName(entityName); if (!entity || !entity.id) { @@ -257,11 +263,11 @@ program const claim = await repository.createClaim({ entity_id: entity.id, statement, - confidence: parseFloat(options.confidence), + confidence: parseFloat(options.confidence ?? '1.0'), }); console.log(`Claim added to ${entity.name}: ${claim.statement}`); } catch (err) { - console.error(`Failed to create claim: ${err}`); + console.error(`Failed to create claim: ${err instanceof Error ? err.message : String(err)}`); } }); @@ -292,7 +298,7 @@ program await rollbackLastMigration(dbInstance!); console.log('Rollback complete.'); } catch (err) { - console.error(`Rollback failed: ${err}`); + console.error(`Rollback failed: ${err instanceof Error ? err.message : String(err)}`); } }); @@ -313,4 +319,21 @@ program } }); +program + .command('db:backup') + .description('Backup the SQLite database') + .argument('[path]', 'output path for the backup file') + .action(async (pathArg: string | undefined) => { + await ensureDb(); + const backupPath = pathArg ?? `.studio-cli-backup-${Date.now()}.db`; + const resolvedPath = path.resolve(process.cwd(), backupPath); + console.log(`Backing up database to ${resolvedPath}...`); + try { + await dbInstance!.exec({ sql: `VACUUM INTO '${resolvedPath.replace(/'/g, "''")}'` }); + console.log(`Backup created: ${resolvedPath}`); + } catch (err) { + console.error(`Backup failed: ${err instanceof Error ? err.message : String(err)}`); + } + }); + program.parse(); diff --git a/public/db/migrations/001_initial.sql b/public/db/migrations/001_initial.sql index f0f5b9a..201b310 100644 --- a/public/db/migrations/001_initial.sql +++ b/public/db/migrations/001_initial.sql @@ -1,7 +1,98 @@ -- UP --- This matches the current schema in public/db/schema.sql --- It is skipped for existing databases (already applied) -PRAGMA user_version = 1; +-- Initial schema: entities, claims, notes, links, graph_snapshots, web_cache, fts5 indexes +CREATE TABLE IF NOT EXISTS entities ( + id UUID PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))), + name TEXT NOT NULL, + type TEXT NOT NULL, + description TEXT, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS claims ( + id UUID PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))), + entity_id UUID NOT NULL, + statement TEXT NOT NULL, + evidence TEXT, + confidence REAL DEFAULT 1.0, + source TEXT, + verification_status TEXT DEFAULT 'unverified', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS notes ( + id UUID PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))), + entity_id UUID, + content TEXT NOT NULL, + format TEXT DEFAULT 'markdown', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS links ( + id UUID PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))), + source_id UUID NOT NULL, + target_id UUID NOT NULL, + relation TEXT NOT NULL, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_id) REFERENCES entities(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES entities(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS graph_snapshots ( + id UUID PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))), + name TEXT NOT NULL, + nodes_json TEXT NOT NULL, + edges_json TEXT NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_graph_snapshots_created_at ON graph_snapshots(created_at); +CREATE INDEX IF NOT EXISTS idx_claims_entity_id ON claims(entity_id); +CREATE INDEX IF NOT EXISTS idx_links_source_id ON links(source_id); +CREATE INDEX IF NOT EXISTS idx_links_target_id ON links(target_id); + +CREATE VIRTUAL TABLE IF NOT EXISTS entity_search_idx USING fts5( + name, + description, + tokenize='porter unicode61', + detail=none, + content='' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS claim_search_idx USING fts5( + statement, + tokenize='porter unicode61', + detail=none, + content='' +); + +CREATE TABLE IF NOT EXISTS web_cache ( + url TEXT PRIMARY KEY, + content TEXT NOT NULL, + format TEXT DEFAULT 'markdown', + title TEXT, + resolved_at DATETIME DEFAULT CURRENT_TIMESTAMP, + metadata TEXT +); -- DOWN -PRAGMA user_version = 0; +DROP TABLE IF EXISTS web_cache; +DROP TABLE IF EXISTS claim_search_idx; +DROP TABLE IF EXISTS entity_search_idx; +DROP INDEX IF EXISTS idx_links_target_id; +DROP INDEX IF EXISTS idx_links_source_id; +DROP INDEX IF EXISTS idx_claims_entity_id; +DROP INDEX IF EXISTS idx_graph_snapshots_created_at; +DROP TABLE IF EXISTS graph_snapshots; +DROP TABLE IF EXISTS links; +DROP TABLE IF EXISTS notes; +DROP TABLE IF EXISTS claims; +DROP TABLE IF EXISTS entities; diff --git a/scripts/propagate-version.sh b/scripts/propagate-version.sh index 6206233..98f80f0 100755 --- a/scripts/propagate-version.sh +++ b/scripts/propagate-version.sh @@ -28,6 +28,7 @@ FILES_TO_UPDATE=( "README.md" "agents-docs/MIGRATION.md" "package.json" + "cli/index.ts" ) UPDATED=0 diff --git a/src/app/App.tsx b/src/app/App.tsx index 275b978..3f227ca 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -13,6 +13,7 @@ import MobileDrawer from '../components/MobileDrawer'; import ErrorBoundary from '../components/ErrorBoundary'; import ThemeSwitcher from '../components/ThemeSwitcher'; const CommandPalette = lazy(() => import('../components/CommandPalette')); +import { escapeHtml } from '../lib/security'; import { EditorSkeleton, GraphSkeleton, @@ -151,7 +152,7 @@ const AppContent: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, []); - if (error) return
{error}
; + if (error) return
{typeof error === 'string' ? escapeHtml(error) : String(error)}
; return (
diff --git a/src/db/repository.ts b/src/db/repository.ts index 878c42b..bef4313 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -73,7 +73,8 @@ export class Repository { }); const rows = z.array(z.unknown()).parse(result); const parsed = this.parseMetadata(EntitySchema, rows[0]); - return { ...parsed, rowid: (rows[0]).rowid }; + const row = rows[0] as Record; + return { ...parsed, rowid: row.rowid as number }; } catch (err) { logger.error('Failed to create entity', err); throw new AppError('Failed to create entity', 'DB_ERROR', err); @@ -407,7 +408,10 @@ export class Repository { rowMode: 'object', }); const rows = z.array(z.unknown()).parse(results); - return rows.map((r) => ({ ...this.parseMetadata(ClaimSchema, r), rowid: (r).rowid })); + return rows.map((r) => { + const row = r as Record; + return { ...this.parseMetadata(ClaimSchema, r), rowid: row.rowid as number }; + }); } catch (err) { logger.error('Failed to fetch claims', err); throw new AppError('Failed to fetch claims', 'DB_ERROR', err); @@ -719,7 +723,7 @@ export class Repository { url: String(r.url), content: String(r.content), format: String(r.format), - title: r.title ? String(r.title) : undefined, + title: typeof r.title === 'string' ? r.title : undefined, resolved_at: String(r.resolved_at), }; } catch (err) { @@ -840,7 +844,7 @@ export class Repository { } } - private parseMetadata(schema: T, row: unknown): z.infer { + private parseMetadata>(schema: T, row: unknown): z.infer { const r = { ...(row as Record) }; if (r && typeof r.metadata === 'string') { try { diff --git a/src/features/editor/Editor.tsx b/src/features/editor/Editor.tsx index 257a934..309fe12 100644 --- a/src/features/editor/Editor.tsx +++ b/src/features/editor/Editor.tsx @@ -186,8 +186,9 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => { editor.commands.setContent('

'); } } catch (err) { + const msg = err instanceof Error ? err.message : String(err); logger.error('Failed to save entity', err); - setStatus({ type: 'error', message: 'Save failed. See console for details.' }); + setStatus({ type: 'error', message: `Save failed: ${msg}` }); } }, [title, editor, type, sourceUrl, editingEntityId, onEditComplete]); diff --git a/src/features/mindmap/MindMapView.tsx b/src/features/mindmap/MindMapView.tsx index b569262..9c2e4fd 100644 --- a/src/features/mindmap/MindMapView.tsx +++ b/src/features/mindmap/MindMapView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import MindElixir, { type MindElixirData, type MindElixirInstance } from 'mind-elixir'; -import { Entity, Link } from '../../lib/validation'; +import type { Entity, Link } from '../../lib/validation'; import { repository } from '../../db/repository'; import { logger } from '../../lib/logger'; import { upsertToSearchIndex } from '../../lib/search'; @@ -100,7 +100,8 @@ const MindMapView: React.FC = ({ keypress: true, }; - const instance: MindElixirInstance = new (MindElixir as new (options: Record) => MindElixirInstance)(options); + const MindElixirCtor = MindElixir as new (options: Record) => MindElixirInstance; + const instance: MindElixirInstance = new MindElixirCtor(options); mindInstance.current = instance; instance.init({ nodeData: treeData diff --git a/src/lib/llm/config.ts b/src/lib/llm/config.ts index c2226ac..3644efb 100644 --- a/src/lib/llm/config.ts +++ b/src/lib/llm/config.ts @@ -27,10 +27,10 @@ export function loadConfig(): LLMConfig { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { - return { ...DEFAULT_CONFIG, ...JSON.parse(stored) }; + return { ...DEFAULT_CONFIG, ...JSON.parse(stored) as Partial }; } } catch (e) { - throw new Error(`Failed to parse LLM config: ${e instanceof Error ? e.message : String(e)}`); + console.warn('Failed to parse stored LLM config, falling back to defaults', e); } return { ...DEFAULT_CONFIG }; } diff --git a/src/lib/llm/kilo.ts b/src/lib/llm/kilo.ts index 3d3f7d3..acba975 100644 --- a/src/lib/llm/kilo.ts +++ b/src/lib/llm/kilo.ts @@ -1,4 +1,4 @@ -import type { LLMProvider, LLMRequest, LLMResponse, LLMStreamChunk, LLMProviderConfig } from './types'; +import type { LLMProvider, LLMRequest, LLMResponse, LLMStreamChunk, LLMProviderConfig, OpenAIChatResponse, OpenAIErrorResponse } from './types'; const KILO_BASE_URL = 'https://api.kilo.ai/api/gateway'; @@ -32,11 +32,11 @@ export class KiloGatewayProvider implements LLMProvider { }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) as OpenAIErrorResponse; throw new Error(`Kilo Gateway error: ${error.error?.message || response.statusText}`); } - const data: { choices: Array<{ message?: { content?: string } }>; model?: string; usage?: { prompt_tokens: number; completion_tokens: number } } = await response.json(); + const data = await response.json() as OpenAIChatResponse; return { content: data.choices[0]?.message?.content || '', model: data.model || request.model, @@ -61,7 +61,7 @@ export class KiloGatewayProvider implements LLMProvider { }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) as OpenAIErrorResponse; throw new Error(`Kilo Gateway error: ${error.error?.message || response.statusText}`); } @@ -88,7 +88,7 @@ export class KiloGatewayProvider implements LLMProvider { } try { - const parsed = JSON.parse(data); + const parsed = JSON.parse(data) as OpenAIChatResponse; const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { yield { content, done: false }; diff --git a/src/lib/llm/openrouter.ts b/src/lib/llm/openrouter.ts index 6cd08d2..9b17671 100644 --- a/src/lib/llm/openrouter.ts +++ b/src/lib/llm/openrouter.ts @@ -1,4 +1,4 @@ -import type { LLMProvider, LLMRequest, LLMResponse, LLMStreamChunk, LLMProviderConfig } from './types'; +import type { LLMProvider, LLMRequest, LLMResponse, LLMStreamChunk, LLMProviderConfig, OpenAIChatResponse, OpenAIErrorResponse } from './types'; const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; @@ -32,11 +32,11 @@ export class OpenRouterProvider implements LLMProvider { }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) as OpenAIErrorResponse; throw new Error(`OpenRouter error: ${error.error?.message || response.statusText}`); } - const data: { choices: Array<{ message?: { content?: string } }>; model?: string; usage?: { prompt_tokens: number; completion_tokens: number } } = await response.json(); + const data = await response.json() as OpenAIChatResponse; return { content: data.choices[0]?.message?.content || '', model: data.model || request.model, @@ -61,7 +61,7 @@ export class OpenRouterProvider implements LLMProvider { }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) as OpenAIErrorResponse; throw new Error(`OpenRouter error: ${error.error?.message || response.statusText}`); } @@ -88,7 +88,7 @@ export class OpenRouterProvider implements LLMProvider { } try { - const parsed = JSON.parse(data); + const parsed = JSON.parse(data) as OpenAIChatResponse; const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { yield { content, done: false }; diff --git a/src/lib/llm/types.ts b/src/lib/llm/types.ts index b393579..8456d70 100644 --- a/src/lib/llm/types.ts +++ b/src/lib/llm/types.ts @@ -39,3 +39,21 @@ export interface LLMProvider { chatStream(request: LLMRequest): AsyncGenerator; isConfigured(): boolean; } + +/** OpenAI-compatible chat completion response body. */ +export interface OpenAIChatResponse { + choices: Array<{ + message?: { content?: string }; + delta?: { content?: string }; + }>; + model?: string; + usage?: { + prompt_tokens: number; + completion_tokens: number; + }; +} + +/** OpenAI-compatible error response body. */ +export interface OpenAIErrorResponse { + error?: { message?: string }; +} From 7f967712b95f6fa1b71f08bbebafd2b511a5ce02 Mon Sep 17 00:00:00 2001 From: d-oit Date: Tue, 26 May 2026 21:13:46 +0200 Subject: [PATCH 03/25] fix(wave3): docs, CI/CD, a11y, tsconfig, test coverage - #196: Fix doc inconsistencies - QUICKSTART refs, npm->pnpm, remove RUST/SUCCESS_TEST - #194: Add CI timeouts, caching, path filters - #197: Fix a11y gaps - CommandPalette, ExportPanel, AIHarness, GraphView, MindMap - #193: Add tests for ExportPanel, Chat, AIHarness - #198: tsconfig.app.json already fixed --- .env.example | 6 ++ .github/workflows/ci-and-labels.yml | 4 +- .github/workflows/commitlint.yml | 12 ++++ agents-docs/MIGRATION.md | 4 +- agents-docs/RUST.md | 55 --------------- agents-docs/SUCCESS_TEST.md | 2 - agents-docs/WORKFLOW.md | 19 ------ docs/SETUP.md | 22 +++--- export/index.html | 26 ++++++- plans/03-core-implementation.md | 12 ++-- src/components/CommandPalette.tsx | 16 ++++- src/components/ThemeSwitcher.tsx | 35 +++++++++- .../__tests__/CommandPalette.test.tsx | 2 +- src/features/ai/AIHarness.tsx | 7 +- src/features/ai/__tests__/AIHarness.test.tsx | 68 +++++++++++++++++++ src/features/chat/__tests__/Chat.test.tsx | 44 ++++++++++++ src/features/export/ExportPanel.tsx | 2 +- .../export/__tests__/ExportPanel.test.tsx | 50 ++++++++++++++ src/features/graph/GraphView.tsx | 7 +- src/features/mindmap/MindMapView.tsx | 27 +++++++- 20 files changed, 308 insertions(+), 112 deletions(-) delete mode 100644 agents-docs/RUST.md delete mode 100644 agents-docs/SUCCESS_TEST.md create mode 100644 src/features/ai/__tests__/AIHarness.test.tsx create mode 100644 src/features/chat/__tests__/Chat.test.tsx create mode 100644 src/features/export/__tests__/ExportPanel.test.tsx diff --git a/.env.example b/.env.example index a5d48d2..d83a4fd 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,14 @@ # and are visible to anyone who inspects the client-side source code. # LLM API Settings (Optional - user-provided API keys) +# Used by the AI Harness (src/features/ai/) and the web doc resolver +# (src/lib/resolver.ts) for powering AI-assisted knowledge workflows. +# VITE_LLM_API_KEY: API key for the active LLM provider (OpenRouter, OpenAI, etc.) +# VITE_LLM_API_BASE_URL: Custom base URL for the LLM API endpoint VITE_LLM_API_KEY= VITE_LLM_API_BASE_URL= # Environment +# Used to control runtime behavior: "development" enables debug logging and +# dev-only features; "production" strips dev paths. VITE_ENV=development diff --git a/.github/workflows/ci-and-labels.yml b/.github/workflows/ci-and-labels.yml index 5bed7ef..fb1aa3d 100644 --- a/.github/workflows/ci-and-labels.yml +++ b/.github/workflows/ci-and-labels.yml @@ -51,7 +51,7 @@ jobs: quality-gate: name: Quality Gate runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 needs: changes if: needs.changes.outputs.any_code == 'true' steps: @@ -74,7 +74,7 @@ jobs: unit-tests: name: Unit Tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 needs: [changes, quality-gate] if: needs.changes.outputs.any_code == 'true' steps: diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 7ac4f52..2cdb52c 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -7,10 +7,22 @@ on: branches: - main - develop + paths-ignore: + - 'docs/**' + - '**.md' + - 'NOTICE' + - 'LICENSE' + - '.gitignore' pull_request: branches: - main - develop + paths-ignore: + - 'docs/**' + - '**.md' + - 'NOTICE' + - 'LICENSE' + - '.gitignore' permissions: contents: read diff --git a/agents-docs/MIGRATION.md b/agents-docs/MIGRATION.md index 64c9548..10bc7cf 100644 --- a/agents-docs/MIGRATION.md +++ b/agents-docs/MIGRATION.md @@ -769,7 +769,7 @@ my-project/ After migration: -1. **Train Your Team**: Share this guide and [QUICKSTART.md](QUICKSTART.md) +1. **Train Your Team**: Share this guide and [README.md](README.md) 2. **Customize AGENTS.md**: Add project-specific patterns and conventions 3. **Add More Skills**: Copy additional skills from the template as needed 4. **Create Sub-Agents**: Define specialized agents for common tasks @@ -782,7 +782,7 @@ After migration: | Resource | Purpose | |----------|---------| | [README.md](README.md) | Template overview | -| [QUICKSTART.md](QUICKSTART.md) | 5-minute setup guide | +| [README.md](README.md) | 5-minute setup guide | | [AGENTS.md](AGENTS.md) | Agent instruction format | | [agents-docs/AVAILABLE_SKILLS.md](agents-docs/AVAILABLE_SKILLS.md) | Skill authoring guide | | [agents-docs/SUB-AGENTS.md](agents-docs/SUB-AGENTS.md) | Sub-agent patterns | diff --git a/agents-docs/RUST.md b/agents-docs/RUST.md deleted file mode 100644 index 000a203..0000000 --- a/agents-docs/RUST.md +++ /dev/null @@ -1,55 +0,0 @@ -# Rust Development Patterns - -> Reference doc - not loaded by default. -> Remove this file if your project is not Rust. - -## Toolchain - -- Stable toolchain, edition 2021 -- `cargo fmt` + `cargo clippy -- -D warnings` must pass before every commit -- All fallible public APIs return `Result` -- Errors defined via `thiserror`; propagation via `anyhow` or `?` - -## Async and Concurrency - -- Async I/O via Tokio; CPU parallelism via Rayon -- Do NOT share a single Connection across async tasks via `RwLock` - Use per-operation `connect()` instead - cheap and avoids Send/Sync issues -- Gate WASM threading: `#[cfg(not(target_arch = "wasm32"))]` - -## Numeric Safety - -- `f32::total_cmp()` for float sorting - **never** `partial_cmp().unwrap()` (panics on NaN) -- Seeded RNG (`StdRng::seed_from_u64(42)`) in tests for determinism - -## Memory and Performance - -- Dense matrices for large N are infeasible - use CSR sparse format -- `Vec>` has allocator overhead; prefer contiguous CSR buffers -- Memory locality often dominates arithmetic throughput for large sparse structures -- No connection pooling for local SQLite - no benefit, adds overhead - -## Code Organization - -- Max 500 lines per source file - split into focused sub-modules if exceeded -- No hardcoded magic numbers - named constants or config only -- Never create unused code - verify at least one usage site before adding APIs -- Architecture diagrams in ```mermaid``` blocks, never raw ASCII art - -## CI Validation - -```bash -cargo fmt --check -cargo clippy -- -D warnings -cargo test -cargo build --release -``` - -## WASM - -```bash -cargo build --target wasm32-unknown-unknown -wasm-pack build --target web -``` - -Gate all threading/I/O with `#[cfg(not(target_arch = "wasm32"))]`. \ No newline at end of file diff --git a/agents-docs/SUCCESS_TEST.md b/agents-docs/SUCCESS_TEST.md deleted file mode 100644 index bbbf2e4..0000000 --- a/agents-docs/SUCCESS_TEST.md +++ /dev/null @@ -1,2 +0,0 @@ -# Success Test -Final test with all fixes applied. diff --git a/agents-docs/WORKFLOW.md b/agents-docs/WORKFLOW.md index cbb4b22..f9ad8fc 100644 --- a/agents-docs/WORKFLOW.md +++ b/agents-docs/WORKFLOW.md @@ -86,22 +86,3 @@ The workflow performs: 3. Parallel web research with automated JSON/SHA256 hardening 4. Multi-agent synthesis 5. (Optional) GitHub Pull Request creation with Actions monitoring - -## Swarm Worktree Workflow - -Use the swarm worktree workflow for complex analysis tasks requiring parallel web research. - -```bash -# Execute full workflow with quality profile -./scripts/swarm-worktree-web-research.sh "Analysis Topic" - -# Run with custom profile and clean up after -./scripts/swarm-worktree-web-research.sh --profile balanced --cleanup "Topic" -``` - -The workflow performs: -1. Environment validation -2. Git worktree creation for context isolation -3. Parallel web research with automated JSON/SHA256 hardening -4. Multi-agent synthesis -5. (Optional) GitHub Pull Request creation with Actions monitoring diff --git a/docs/SETUP.md b/docs/SETUP.md index 23497ed..292562d 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -5,7 +5,7 @@ This guide covers setting up the development environment and using the CLI for d ## Prerequisites - **Node.js**: v20 or higher -- **npm**: v10 or higher +- **pnpm**: v10 or higher - **Browser**: Chrome, Edge, or any browser supporting OPFS (Origin Private File System). ## Installation @@ -13,14 +13,14 @@ This guide covers setting up the development environment and using the CLI for d 1. Clone the repository. 2. Install dependencies: ```bash - npm install + pnpm install ``` ## Development Start the Vite development server: ```bash -npm run dev +pnpm run dev ``` Open `http://localhost:5173` in your browser. @@ -30,13 +30,13 @@ The CLI interacts with the database in a Node.js environment. Note that while th ### Initialize Workspace ```bash -npm run cli -- init +pnpm run cli -- init ``` ### Sync Markdown Files Populate your database from a directory of markdown files. The first line of each file (H1) is treated as the entity name. ```bash -npm run cli -- sync ./path/to/markdown/files +pnpm run cli -- sync ./path/to/markdown/files ``` ### Export Data @@ -44,29 +44,29 @@ Export your knowledge base to various formats. **Markdown:** ```bash -npm run cli -- export --format md --output ./export/markdown +pnpm run cli -- export --format md --output ./export/markdown ``` **JSON:** ```bash -npm run cli -- export --format json --output ./export/json +pnpm run cli -- export --format json --output ./export/json ``` **Static Site:** ```bash -npm run cli -- export --format site --output ./export/site +pnpm run cli -- export --format site --output ./export/site ``` ### Manage Entities and Claims ```bash # Create an entity -npm run cli -- entity-create "Artificial Intelligence" --type concept --description "The simulation of human intelligence by machines." +pnpm run cli -- entity-create "Artificial Intelligence" --type concept --description "The simulation of human intelligence by machines." # List entities -npm run cli -- entity-list +pnpm run cli -- entity-list # Create a claim for an entity -npm run cli -- claim-create "Artificial Intelligence" "AI can solve complex problems" --confidence 0.95 +pnpm run cli -- claim-create "Artificial Intelligence" "AI can solve complex problems" --confidence 0.95 ``` ## Environment Variables diff --git a/export/index.html b/export/index.html index 80bd303..d7b4f74 100644 --- a/export/index.html +++ b/export/index.html @@ -1 +1,25 @@ -Knowledge Studio

Local Knowledge Base

Exported from Studio

\ No newline at end of file + + + + + +Knowledge Studio — Static Export + + + +

Local Knowledge Base

+

Exported from Knowledge Studio

+
+

Your knowledge base content goes here.

+
+ + diff --git a/plans/03-core-implementation.md b/plans/03-core-implementation.md index bf2be01..295966a 100644 --- a/plans/03-core-implementation.md +++ b/plans/03-core-implementation.md @@ -21,7 +21,7 @@ 4. Add file-based sync: Browser imports from `sync/` directory **Effort**: 6-8h **Dependencies**: None (independent task) -**Validation**: `npm run cli -- --help` shows working commands, `sync` writes to DB +**Validation**: `pnpm run cli -- --help` shows working commands, `sync` writes to DB --- @@ -43,7 +43,7 @@ 4. Update JobCoordinator to track real export progress **Effort**: 4-6h **Dependencies**: OPFS utilities (can build alongside) -**Validation**: Export buttons produce real files, `npm test` passes +**Validation**: Export buttons produce real files, `pnpm test` passes --- @@ -60,7 +60,7 @@ 3. Target: 70%+ coverage overall, 80%+ for critical paths **Effort**: 2-3h **Dependencies**: 3.1 and 3.2 (test real implementations) -**Validation**: `npm run test:coverage` meets targets +**Validation**: `pnpm run test:coverage` meets targets --- @@ -84,7 +84,7 @@ --- ## Completion Criteria -- [ ] CLI commands work with real Node.js SQLite adapter -- [ ] Export buttons produce real Markdown/JSON/static site files +- [x] CLI commands work with real Node.js SQLite adapter +- [x] Export buttons produce real Markdown/JSON/static site files - [ ] Test coverage meets targets (70% overall, 80% critical) -- [ ] All quality gates pass: `npm test`, `npm run lint`, `npm run typecheck` +- [ ] All quality gates pass: `pnpm test`, `pnpm run lint`, `pnpm run typecheck` diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ff2fa4b..b58480c 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -147,11 +147,17 @@ const CommandPalette: React.FC = ({ isOpen, onClose, onView value={query} onChange={e => setQuery(e.target.value)} onKeyDown={handleKeyDown} + role="combobox" + aria-autocomplete="list" + aria-label="Search commands and knowledge" + aria-expanded={totalItems > 0} + aria-controls="command-palette-listbox" + aria-activedescendant={totalItems > 0 ? `command-item-${selectedIndex}` : undefined} />
ESC
-
+
{isSearching && } {!isSearching && filteredCommands.length > 0 && (
@@ -159,12 +165,14 @@ const CommandPalette: React.FC = ({ isOpen, onClose, onView {filteredCommands.map((cmd, i) => (
setSelectedIndex(i)} onClick={executeSelected} - role="button" + role="option" tabIndex={0} onKeyDown={e => e.key === 'Enter' && executeSelected()} + aria-selected={selectedIndex === i} > {cmd.label} @@ -182,12 +190,14 @@ const CommandPalette: React.FC = ({ isOpen, onClose, onView return (
setSelectedIndex(idx)} onClick={executeSelected} - role="button" + role="option" tabIndex={0} onKeyDown={e => e.key === 'Enter' && executeSelected()} + aria-selected={selectedIndex === idx} >
diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx index 7185a0a..7b56b37 100644 --- a/src/components/ThemeSwitcher.tsx +++ b/src/components/ThemeSwitcher.tsx @@ -66,6 +66,7 @@ interface ThemeSwitcherProps { const ThemeSwitcher: React.FC = ({ compact = false }) => { const [activeTheme, setActiveTheme] = React.useState(getStoredTheme); const [isOpen, setIsOpen] = React.useState(false); + const [focusedIndex, setFocusedIndex] = React.useState(-1); React.useEffect(() => { document.documentElement.setAttribute('data-theme', activeTheme); @@ -88,12 +89,33 @@ const ThemeSwitcher: React.FC = ({ compact = false }) => { setIsOpen(false); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedIndex(prev => Math.min(prev + 1, THEME_OPTIONS.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' && focusedIndex >= 0) { + handleSelect(THEME_OPTIONS[focusedIndex].theme); + } else if (e.key === 'Escape') { + setIsOpen(false); + } + }; + + const handleDropdownOpen = () => { + setIsOpen(true); + setFocusedIndex(0); + }; + if (compact) { return (
{isOpen && ( -
    - {THEME_OPTIONS.map((opt) => ( +
      = 0 ? `theme-option-${focusedIndex}` : undefined} + onKeyDown={handleKeyDown} + > + {THEME_OPTIONS.map((opt, i) => (
    • -
@@ -242,7 +242,7 @@ const AIHarness: React.FC = () => {
)} -
+
{messages.map((m, i) => (
@@ -268,6 +268,7 @@ const AIHarness: React.FC = () => { onKeyPress={e => e.key === 'Enter' && handleSend()} placeholder="Ask the AI agent..." disabled={isLoading} + aria-label="Ask the AI agent" /> + + )} + + {wizardStep === 1 && ( + <> +

Choose Provider & Model

+
+ + + + +
+
+ + +
+ + )} + + {wizardStep === 2 && ( + <> +

Enter API Key

+

+ Your API key stays local in your browser. Never shared with anyone. +

+ setWizardApiKey(e.target.value)} + placeholder={`Enter ${wizardProvider} API key`} + style={{ width: '100%', padding: '10px', borderRadius: '6px', border: '1px solid var(--border-default, #ddd)', boxSizing: 'border-box' }} + autoFocus + /> +
+ + +
+ + )} + + +
+
+ )} +

AI Harness

@@ -160,7 +403,13 @@ const AIHarness: React.FC = () => { )}
- {!hasKey && ( + {!hasKey && !showWizard && (
No API key configured. Set one in the settings () to enable AI features. @@ -188,13 +437,31 @@ const AIHarness: React.FC = () => { + + + +
{ placeholder={hasKey ? 'Leave blank to keep current key' : 'Enter API key'} style={{ flex: 1, padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--border-default)' }} /> -
@@ -213,7 +480,6 @@ const AIHarness: React.FC = () => {
)} - {/* Sourcing indicator */} {isSourcing && (
@@ -222,7 +488,6 @@ const AIHarness: React.FC = () => {
)} - {/* Resolved source chips */} {resolvedSources.length > 0 && (
{resolvedSources.map((s, i) => ( @@ -249,7 +514,16 @@ const AIHarness: React.FC = () => { {m.role === 'assistant' ? : } {m.role === 'assistant' ? 'Assistant' : 'You'}
- {m.content} + {m.role === 'assistant' ? ( + + ) : ( + m.content + )} + {m.tokenUsage && ( +
+ {m.tokenUsage.input + m.tokenUsage.output} tokens +
+ )}
))} {isLoading && ( @@ -260,23 +534,51 @@ const AIHarness: React.FC = () => {
-
- setInput(e.target.value)} - onKeyPress={e => e.key === 'Enter' && handleSend()} - placeholder="Ask the AI agent..." - disabled={isLoading} - aria-label="Ask the AI agent" - /> - +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask the AI agent..." + disabled={isLoading} + aria-label="Ask the AI agent" + /> + +
+
+ + Model: {currentModel ? currentModel.split('/').pop() : 'none'} + + + {sessionTokens.input + sessionTokens.output > 0 && ( + Tokens: {sessionTokens.input + sessionTokens.output} + )} + + + {getRateLimitInfo().count > 0 && `${getRateLimitInfo().count}/${getRateLimitInfo().limit} req/min`} + + +
); }; export default AIHarness; - diff --git a/src/features/ai/__tests__/AIHarness.test.tsx b/src/features/ai/__tests__/AIHarness.test.tsx index bdb4731..b855499 100644 --- a/src/features/ai/__tests__/AIHarness.test.tsx +++ b/src/features/ai/__tests__/AIHarness.test.tsx @@ -43,6 +43,7 @@ import AIHarness from '../AIHarness'; describe('AIHarness', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.setItem('dks:ai-wizard-seen', 'true'); }); it('renders without crashing', () => { diff --git a/src/features/graph/GraphView.tsx b/src/features/graph/GraphView.tsx index 8599add..b585d66 100644 --- a/src/features/graph/GraphView.tsx +++ b/src/features/graph/GraphView.tsx @@ -536,6 +536,9 @@ const GraphView: React.FC = ({ const visibleNodes = nodes.filter(n => n !== 'placeholder'); const currentIdx = selectedNode ? visibleNodes.indexOf(selectedNode) : focusRingIndex; + // Arrow keys with modifier = pan camera + const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey; + switch (e.key) { case 'Tab': { e.preventDefault(); @@ -549,26 +552,62 @@ const GraphView: React.FC = ({ } case 'ArrowLeft': { e.preventDefault(); - const camera = sigma.getCamera(); - camera.setState({ x: camera.x + 50 / camera.ratio }); + if (hasModifier) { + const camera = sigma.getCamera(); + camera.setState({ x: camera.x + 50 / camera.ratio }); + } else if (selectedNode) { + // Navigate to nearest neighbor in the left/up direction (prefer source nodes) + const neighbors = graph.neighbors(selectedNode); + if (neighbors.length > 0) { + setSelectedNode(neighbors[neighbors.length - 1]); + } + } break; } case 'ArrowRight': { e.preventDefault(); - const camera = sigma.getCamera(); - camera.setState({ x: camera.x - 50 / camera.ratio }); + if (hasModifier) { + const camera = sigma.getCamera(); + camera.setState({ x: camera.x - 50 / camera.ratio }); + } else if (selectedNode) { + // Navigate to nearest neighbor in the right/down direction (prefer target nodes) + const neighbors = graph.neighbors(selectedNode); + if (neighbors.length > 0) { + setSelectedNode(neighbors[0]); + } + } break; } case 'ArrowUp': { e.preventDefault(); - const camera = sigma.getCamera(); - camera.setState({ y: camera.y + 50 / camera.ratio }); + if (hasModifier) { + const camera = sigma.getCamera(); + camera.setState({ y: camera.y + 50 / camera.ratio }); + } else if (visibleNodes.length > 0) { + // Previous node in list + const dir = -1; + const next = ((currentIdx + dir) % visibleNodes.length + visibleNodes.length) % visibleNodes.length; + if (visibleNodes[next]) { + setSelectedNode(visibleNodes[next]); + setFocusRingIndex(next); + } + } break; } case 'ArrowDown': { e.preventDefault(); - const camera = sigma.getCamera(); - camera.setState({ y: camera.y - 50 / camera.ratio }); + if (hasModifier) { + const camera = sigma.getCamera(); + camera.setState({ y: camera.y - 50 / camera.ratio }); + } else if (visibleNodes.length > 0) { + // Next node in list + const dir = 1; + const next = ((currentIdx + dir) % visibleNodes.length + visibleNodes.length) % visibleNodes.length; + if (visibleNodes[next]) { + setSelectedNode(visibleNodes[next]); + setFocusRingIndex(next); + } + } break; } case '=': @@ -672,10 +711,21 @@ const GraphView: React.FC = ({ tabIndex={-1} aria-roledescription="Interactive knowledge graph showing entities and their relationships" /> -
- {selectedNode - ? `Selected entity: ${entities.find(e => e.id === selectedNode)?.name || selectedNode}. ${effectiveData.links.filter(l => l.source_id === selectedNode || l.target_id === selectedNode).length} connections.` - : 'Knowledge graph. No entity selected. Use Tab to navigate nodes.'} +
+ {(() => { + if (!selectedNode) return 'Knowledge graph. No entity selected. Use Tab or arrow keys to navigate nodes.'; + const entity = entities.find(e => e.id === selectedNode); + const name = entity?.name || selectedNode; + const connections = effectiveData.links.filter(l => l.source_id === selectedNode || l.target_id === selectedNode); + const neighborNames = connections + .map(l => { + const neighborId = l.source_id === selectedNode ? l.target_id : l.source_id; + const neighbor = entities.find(e => e.id === neighborId); + return neighbor?.name || neighborId; + }) + .filter(Boolean); + return `Selected: ${name}. ${connections.length} connections${neighborNames.length > 0 ? ': ' + neighborNames.slice(0, 5).join(', ') + (neighborNames.length > 5 ? ` and ${neighborNames.length - 5} more` : '') : ''}. Press Tab to next, Enter to inspect, Escape to deselect.`; + })()}
{selectedNode && (() => { const entity = entities.find(e => e.id === selectedNode); diff --git a/src/features/mindmap/MindMapView.tsx b/src/features/mindmap/MindMapView.tsx index f7d197c..c8f571e 100644 --- a/src/features/mindmap/MindMapView.tsx +++ b/src/features/mindmap/MindMapView.tsx @@ -5,7 +5,7 @@ import { repository } from '../../db/repository'; import { logger } from '../../lib/logger'; import { upsertToSearchIndex } from '../../lib/search'; import { perf } from '../../lib/perf'; -import { ChevronDown, Layers, Filter, Info, ChevronRight } from 'lucide-react'; +import { ChevronDown, Layers, Filter, Info, ChevronRight, Plus, GitBranch, Pencil, Trash2, Image } from 'lucide-react'; const COLLAPSED_BY_DEFAULT_THRESHOLD = 20; const EXPENSIVE_RECALC_THRESHOLD = 50; @@ -18,10 +18,6 @@ interface Props { onEntityClick?: (entityId: string) => void; } -interface Bus { - addListener: (event: string, handler: (node: { id: string }) => void) => void; -} - function buildTree( currentId: string, depth: number, @@ -55,13 +51,14 @@ const MindMapView: React.FC = ({ onEntityClick }) => { const containerRef = useRef(null); - const mindInstance = useRef<{ init: (data: { nodeData: MindElixirData['nodeData'] }) => void; bus: Bus } | null>(null); + const mindInstance = useRef(null); const treeDataRef = useRef(''); const [rootId, setRootId] = useState(propsRootEntity.id || ''); const [maxDepth, setMaxDepth] = useState(2); const [relationFilter, setRelationFilter] = useState('all'); const [collapsedByDefault, setCollapsedByDefault] = useState(entities.length > COLLAPSED_BY_DEFAULT_THRESHOLD); const [selectedNodeName, setSelectedNodeName] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); const rootEntity = useMemo(() => entities.find(e => e.id === rootId) || propsRootEntity, @@ -94,7 +91,7 @@ const MindMapView: React.FC = ({ const options = { el: containerRef.current, direction: 2, - draggable: true, + editable: true, contextMenu: !isLargeMap, toolBar: !isLargeMap, nodeMenu: true, @@ -109,11 +106,13 @@ const MindMapView: React.FC = ({ }); perf.measure('mindmap-init', 'mindmap-mount'); - mindInstance.current.bus.addListener('selectNode', (node) => { - const label = node.id ? (entities.find(e => e.id === node.id)?.name || null) : null; + mindInstance.current.bus.addListener('selectNode', (node: { id?: string }) => { + const nodeId = node.id || null; + setSelectedNodeId(nodeId); + const label = nodeId ? (entities.find(e => e.id === nodeId)?.name || null) : null; setSelectedNodeName(label); - if (node.id && onEntityClick) { - onEntityClick(node.id); + if (nodeId && onEntityClick) { + onEntityClick(nodeId); } }); @@ -146,6 +145,8 @@ const MindMapView: React.FC = ({ if (obj?.topic) { void (async () => { try { + const parentObj = obj.parent as Record | undefined; + const parentId = parentObj?.id as string | undefined; const newEntity = await repository.createEntity({ name: obj.topic as string, type: 'note', @@ -153,7 +154,18 @@ const MindMapView: React.FC = ({ metadata: {}, }); logger.info('Created entity from mind map child', { id: newEntity.id, name: obj.topic }); - if (rootId && newEntity.id) { + const topicEl = mindInstance.current?.findEle(obj.id as string); + if (topicEl) { + topicEl.nodeObj.id = newEntity.id!; + } + const validParent = parentId && /^[0-9a-f-]{36}$/i.test(parentId); + if (validParent && newEntity.id) { + await repository.createLink({ + source_id: parentId, + target_id: newEntity.id, + relation: 'hierarchy', + }); + } else if (!validParent && rootId && newEntity.id) { await repository.createLink({ source_id: rootId, target_id: newEntity.id, @@ -213,6 +225,52 @@ const MindMapView: React.FC = ({ setRootId(propsRootEntity.id || ''); }, [propsRootEntity.id]); + const handleAddChild = useCallback(() => { + if (mindInstance.current) { + void mindInstance.current.addChild(); + } + }, []); + + const handleAddSibling = useCallback(() => { + if (mindInstance.current && selectedNodeId) { + void mindInstance.current.insertSibling('after'); + } + }, [selectedNodeId]); + + const handleRename = useCallback(() => { + if (mindInstance.current) { + void mindInstance.current.beginEdit(); + } + }, []); + + const handleDelete = useCallback(() => { + if (mindInstance.current && selectedNodeId) { + const topic = mindInstance.current.findEle(selectedNodeId); + if (topic) { + void mindInstance.current.removeNodes([topic]); + } + } + }, [selectedNodeId]); + + const handleExportPng = useCallback(async () => { + if (mindInstance.current) { + try { + const blob = await mindInstance.current.exportPng(); + if (blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.download = `mindmap-${Date.now()}.png`; + a.href = url; + a.click(); + URL.revokeObjectURL(url); + logger.info('Mind map exported as PNG'); + } + } catch (err) { + logger.error('Failed to export mind map as PNG', err); + } + } + }, []); + return (
@@ -270,6 +328,58 @@ const MindMapView: React.FC = ({ {collapsedByDefault ? 'Expand' : 'Compact'} + + + + + + + + + + + +
Tab: Add Child | F2: Rename | Del: Delete diff --git a/src/lib/export-core.ts b/src/lib/export-core.ts index 12f3121..e4627d7 100644 --- a/src/lib/export-core.ts +++ b/src/lib/export-core.ts @@ -106,6 +106,36 @@ export function generateSiteHtml(data: ExportData): string { return html; } +export function generateEntityMarkdown( + entity: Entity, + claims: Claim[], + notes: Note[], +): string { + let md = `# ${escapeHtml(entity.name)}\n\n`; + md += `**Type:** ${escapeHtml(entity.type)}\n\n`; + if (entity.description) md += `${entity.description}\n\n`; + + if (claims.length > 0) { + md += `## Claims\n\n`; + for (const claim of claims) { + md += `- ${escapeHtml(claim.statement)}`; + if (claim.confidence !== 1) md += ` (confidence: ${claim.confidence})`; + md += `\n`; + if (claim.evidence) md += ` - *Evidence:* ${escapeHtml(claim.evidence)}\n`; + } + md += '\n'; + } + + if (notes.length > 0) { + md += `## Notes\n\n`; + for (const note of notes) { + md += `${note.content}\n\n`; + } + } + + return md; +} + export function generateMarkdownExport(data: ExportData): string { const { entities, claims, notes } = data; let fullContent = ''; @@ -115,27 +145,7 @@ export function generateMarkdownExport(data: ExportData): string { const entityClaims = claims[entity.id] ?? []; const entityNotes = notes[entity.id] ?? []; - fullContent += `# ${escapeHtml(entity.name)}\n\n`; - fullContent += `**Type:** ${escapeHtml(entity.type)}\n\n`; - if (entity.description) fullContent += `${entity.description}\n\n`; - - if (entityClaims.length > 0) { - fullContent += `## Claims\n\n`; - for (const claim of entityClaims) { - fullContent += `- ${escapeHtml(claim.statement)}`; - if (claim.confidence !== 1) fullContent += ` (confidence: ${claim.confidence})`; - fullContent += `\n`; - if (claim.evidence) fullContent += ` - *Evidence:* ${escapeHtml(claim.evidence)}\n`; - } - fullContent += '\n'; - } - - if (entityNotes.length > 0) { - fullContent += `## Notes\n\n`; - for (const note of entityNotes) { - fullContent += `${note.content}\n\n`; - } - } + fullContent += generateEntityMarkdown(entity, entityClaims, entityNotes); fullContent += '\n---\n\n'; } diff --git a/src/lib/llm/config.ts b/src/lib/llm/config.ts index 3644efb..326ea53 100644 --- a/src/lib/llm/config.ts +++ b/src/lib/llm/config.ts @@ -15,10 +15,12 @@ const DEFAULT_CONFIG: LLMConfig = { openrouter: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '', + defaultModel: 'google/gemini-2.0-flash-lite-preview-02-05:free', }, kilo: { baseURL: 'https://api.kilo.ai/api/gateway', apiKey: '', + defaultModel: 'meta-llama/llama-3.1-8b-instruct', }, }, }; diff --git a/src/lib/llm/index.ts b/src/lib/llm/index.ts index 7ed25d4..7cea5c1 100644 --- a/src/lib/llm/index.ts +++ b/src/lib/llm/index.ts @@ -4,3 +4,24 @@ export { KiloGatewayProvider } from './kilo'; export { loadConfig, saveConfig, createProvider, getProvider, type LLMConfig } from './config'; export { OPENROUTER_FREE_MODELS } from './openrouter'; export { KILO_FREE_MODELS } from './kilo'; + +export const PROVIDER_MODELS: Record> = { + openrouter: { + 'Gemini 2.0 Flash Lite': 'google/gemini-2.0-flash-lite-preview-02-05:free', + 'OpenRouter Free': 'openrouter/free', + 'Nemotron 3 Super': 'nvidia/nemotron-3-super:free', + 'Trinity Large': 'arcee-ai/trinity-large-preview:free', + 'GLM 4.5 Air': 'z-ai/glm-4.5-air:free', + 'GPT-OSS 120B': 'openai/gpt-oss-120b:free', + 'Qwen3 Coder': 'qwen/qwen3-coder-480b-a35b:free', + 'LLaMA 3.3 70B': 'meta-llama/llama-3.3-70b-instruct:free', + }, + kilo: { + 'Kilo Auto': 'kilo-auto/free', + 'DoLa Seed 2.0 Pro': 'bytedance-seed/dola-seed-2.0-pro:free', + 'Grok Code Fast': 'x-ai/grok-code-fast-1:optimized:free', + 'Nemotron 3 Super': 'nvidia/nemotron-3-super-120b-a12b:free', + 'Trinity Large': 'arcee-ai/trinity-large-thinking:free', + 'OpenRouter Free': 'openrouter/free', + }, +}; diff --git a/src/lib/llm/kilo.ts b/src/lib/llm/kilo.ts index acba975..22156a6 100644 --- a/src/lib/llm/kilo.ts +++ b/src/lib/llm/kilo.ts @@ -70,6 +70,7 @@ export class KiloGatewayProvider implements LLMProvider { const decoder = new TextDecoder(); let buffer = ''; + let streamUsage: { inputTokens: number; outputTokens: number } | undefined; while (true) { const { done, value } = await reader.read(); @@ -83,12 +84,18 @@ export class KiloGatewayProvider implements LLMProvider { if (!line.startsWith('data: ')) continue; const data = line.slice(6); if (data === '[DONE]') { - yield { content: '', done: true }; + yield { content: '', done: true, usage: streamUsage }; return; } try { const parsed = JSON.parse(data) as OpenAIChatResponse; + if (parsed.usage) { + streamUsage = { + inputTokens: parsed.usage.prompt_tokens, + outputTokens: parsed.usage.completion_tokens, + }; + } const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { yield { content, done: false }; diff --git a/src/lib/llm/markdown.tsx b/src/lib/llm/markdown.tsx new file mode 100644 index 0000000..1886767 --- /dev/null +++ b/src/lib/llm/markdown.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { sanitizeHtml } from '../security'; + +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (char) => map[char]); +} + +function markdownToHtml(markdown: string): string { + const lines = markdown.split('\n'); + const html: string[] = []; + let inCodeBlock = false; + let codeBlockContent: string[] = []; + let codeBlockLang = ''; + let inList: 'ul' | 'ol' | null = null; + let listItems: string[] = []; + + function flushList() { + if (inList && listItems.length > 0) { + const tag = inList; + html.push(`<${tag}>`); + for (const item of listItems) { + html.push(`
  • ${item}
  • `); + } + html.push(``); + listItems = []; + inList = null; + } + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('```')) { + if (inCodeBlock) { + html.push(`
    ${codeBlockContent.join('\n')}
    `); + codeBlockContent = []; + codeBlockLang = ''; + inCodeBlock = false; + } else { + flushList(); + inCodeBlock = true; + codeBlockLang = line.slice(3).trim(); + } + continue; + } + + if (inCodeBlock) { + codeBlockContent.push(escapeHtml(line)); + continue; + } + + const processedLine = processInline(line); + + if (/^#{1,6}\s/.test(line)) { + flushList(); + const level = line.match(/^#+/)![0].length; + const text = line.slice(level).trim(); + html.push(`${processInline(text)}`); + } else if (/^[-*+]\s/.test(line)) { + if (inList !== 'ul') { + flushList(); + inList = 'ul'; + } + listItems.push(processInline(line.replace(/^[-*+]\s/, ''))); + } else if (/^\d+[.)]\s/.test(line)) { + if (inList !== 'ol') { + flushList(); + inList = 'ol'; + } + listItems.push(processInline(line.replace(/^\d+[.)]\s/, ''))); + } else if (line.trim() === '') { + flushList(); + if (i > 0 && lines[i - 1].trim() !== '' && !lines[i - 1].startsWith('#')) { + html.push('

    '); + } + } else { + flushList(); + if (i === 0 || lines[i - 1].trim() === '' || lines[i - 1].startsWith('#')) { + html.push(`

    ${processedLine}`); + } else { + html.push(`
    ${processedLine}`); + } + if (i + 1 >= lines.length || lines[i + 1].trim() === '') { + html.push('

    '); + } + } + } + + if (inCodeBlock) { + html.push(`
    ${codeBlockContent.join('\n')}
    `); + } + flushList(); + + return html.join('\n'); +} + +function processInline(text: string): string { + const escaped = escapeHtml(text); + let result = escaped; + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + result = result.replace(/`([^`]+)`/g, '$1'); + result = result.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + result = result.replace(/\*(.+?)\*/g, '$1'); + result = result.replace(/___(.+?)___/g, '$1'); + result = result.replace(/__(.+?)__/g, '$1'); + result = result.replace(/_(.+?)_/g, '$1'); + result = result.replace(/~~(.+?)~~/g, '$1'); + return result; +} + +interface MarkdownRendererProps { + content: string; +} + +const MarkdownRenderer: React.FC = ({ content }) => { + const html = markdownToHtml(content); + const sanitized = sanitizeHtml(html); + return
    ; +}; + +export default MarkdownRenderer; diff --git a/src/lib/llm/openrouter.ts b/src/lib/llm/openrouter.ts index 9b17671..e24864c 100644 --- a/src/lib/llm/openrouter.ts +++ b/src/lib/llm/openrouter.ts @@ -70,6 +70,7 @@ export class OpenRouterProvider implements LLMProvider { const decoder = new TextDecoder(); let buffer = ''; + let streamUsage: { inputTokens: number; outputTokens: number } | undefined; while (true) { const { done, value } = await reader.read(); @@ -83,12 +84,18 @@ export class OpenRouterProvider implements LLMProvider { if (!line.startsWith('data: ')) continue; const data = line.slice(6); if (data === '[DONE]') { - yield { content: '', done: true }; + yield { content: '', done: true, usage: streamUsage }; return; } try { const parsed = JSON.parse(data) as OpenAIChatResponse; + if (parsed.usage) { + streamUsage = { + inputTokens: parsed.usage.prompt_tokens, + outputTokens: parsed.usage.completion_tokens, + }; + } const content = parsed.choices?.[0]?.delta?.content || ''; if (content) { yield { content, done: false }; diff --git a/src/lib/llm/types.ts b/src/lib/llm/types.ts index 8456d70..afa0893 100644 --- a/src/lib/llm/types.ts +++ b/src/lib/llm/types.ts @@ -23,11 +23,16 @@ export interface LLMResponse { export interface LLMStreamChunk { content: string; done: boolean; + usage?: { + inputTokens: number; + outputTokens: number; + }; } export interface LLMProviderConfig { apiKey?: string; baseURL: string; + defaultModel?: string; defaultHeaders?: Record; } From e6d8b3a9072aba5077e51d47a91b2e7d4341da92 Mon Sep 17 00:00:00 2001 From: d-oit Date: Tue, 26 May 2026 21:27:29 +0200 Subject: [PATCH 05/25] perf(wave5): pagination, chunked search, batch queries, graph layouts - #195: Pagination for getAllEntities/AllLinks, chunked search init, batch query - #184: Force-directed + circular graph layouts with node pinning - #189: PDF/DOCX export already implemented --- package.json | 1 + pnpm-lock.yaml | 13 +++ src/db/repository.ts | 103 +++++++++++++++++- src/features/graph/GraphControls.tsx | 16 ++- src/features/graph/GraphView.tsx | 56 ++++++++-- .../graph/__tests__/GraphControls.test.tsx | 9 +- src/lib/__tests__/search_init_perf.test.ts | 10 +- src/lib/__tests__/search_perf.test.ts | 27 +++-- src/lib/search.ts | 86 ++++++++------- 9 files changed, 249 insertions(+), 72 deletions(-) diff --git a/package.json b/package.json index 2bebb99..f2aec09 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "docx": "^9.7.0", "dompurify": "^3.4.6", "graphology": "^0.25.4", + "graphology-layout-forceatlas2": "^0.10.1", "lucide-react": "^1.16.0", "mind-elixir": "^5.10.0", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 430a0de..5133946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: graphology: specifier: ^0.25.4 version: 0.25.4(graphology-types@0.24.8) + graphology-layout-forceatlas2: + specifier: ^0.10.1 + version: 0.10.1(graphology-types@0.24.8) lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.6) @@ -1828,6 +1831,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphology-layout-forceatlas2@0.10.1: + resolution: {integrity: sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==} + peerDependencies: + graphology-types: '>=0.19.0' + graphology-types@0.24.8: resolution: {integrity: sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==} @@ -4848,6 +4856,11 @@ snapshots: graphemer@1.4.0: {} + graphology-layout-forceatlas2@0.10.1(graphology-types@0.24.8): + dependencies: + graphology-types: 0.24.8 + graphology-utils: 2.5.2(graphology-types@0.24.8) + graphology-types@0.24.8: {} graphology-utils@2.5.2(graphology-types@0.24.8): diff --git a/src/db/repository.ts b/src/db/repository.ts index bef4313..600aee8 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -91,12 +91,27 @@ export class Repository { return this.db.transaction(statements); } - /** Get all entities, ordered by name. */ - async getAllEntities(): Promise { + /** + * Get entities, ordered by name. + * Supports optional cursor-based pagination via limit/offset. + * @param options - Optional limit and offset for pagination. + */ + async getAllEntities(options?: { limit?: number; offset?: number }): Promise { perf.mark('sqlite-query'); try { + let sql = `SELECT * FROM entities ORDER BY name ASC`; + const bind: (string | number)[] = []; + if (options?.limit !== undefined) { + sql += ` LIMIT ?`; + bind.push(options.limit); + } + if (options?.offset !== undefined) { + sql += ` OFFSET ?`; + bind.push(options.offset); + } const results = await this.db.exec({ - sql: `SELECT * FROM entities ORDER BY name ASC`, + sql, + bind: bind.length > 0 ? bind : undefined, returnValue: 'resultRows', rowMode: 'object', }); @@ -469,6 +484,67 @@ export class Repository { }, {} as Record); } + /** + * Batch-load entities with their claims in a single query via LEFT JOIN. + * Eliminates N+1 round-trips when both entities and claims are needed. + * @returns Entities keyed by id, each with a claims array. + */ + async getAllEntitiesWithClaims(): Promise> { + perf.mark('sqlite-query'); + try { + const results = await this.db.exec({ + sql: `SELECT e.*, c.id as c_id, c.entity_id as c_entity_id, c.statement as c_statement, + c.evidence as c_evidence, c.confidence as c_confidence, c.source as c_source, + c.verification_status as c_verification_status, c.created_at as c_created_at, + c.updated_at as c_updated_at + FROM entities e + LEFT JOIN claims c ON e.id = c.entity_id + ORDER BY e.name ASC`, + returnValue: 'resultRows', + rowMode: 'object', + }); + const rows = z.array(z.unknown()).parse(results); + + const result = new Map(); + for (const row of rows) { + const r = row as Record; + const entityId = String(r.id); + + if (!result.has(entityId)) { + result.set(entityId, { + entity: this.parseMetadata(EntitySchema, row), + claims: [], + }); + } + + if (r.c_id !== null) { + const claimRow = { ...r }; + // Map prefixed columns back to claim property names + claimRow.id = r.c_id; + claimRow.entity_id = r.c_entity_id; + claimRow.statement = r.c_statement; + claimRow.evidence = r.c_evidence; + claimRow.confidence = r.c_confidence; + claimRow.source = r.c_source; + claimRow.verification_status = r.c_verification_status; + claimRow.created_at = r.c_created_at; + claimRow.updated_at = r.c_updated_at; + // Clean up prefixed keys + for (const key of Object.keys(claimRow)) { + if (key.startsWith('c_')) delete claimRow[key]; + } + result.get(entityId)!.claims.push(this.parseMetadata(ClaimSchema, claimRow)); + } + } + + perf.measure('sqlite-query-entities-claims', 'sqlite-query'); + return result; + } catch (err) { + logger.error('Failed to batch-load entities with claims', err); + throw new AppError('Failed to batch-load entities with claims', 'DB_ERROR', err); + } + } + /** * Get all notes grouped by entity_id for batch export. */ @@ -650,12 +726,27 @@ export class Repository { } } - /** Get all links in the database. */ - async getAllLinks(): Promise { + /** + * Get all links in the database. + * Supports optional cursor-based pagination via limit/offset. + * @param options - Optional limit and offset for pagination. + */ + async getAllLinks(options?: { limit?: number; offset?: number }): Promise { perf.mark('sqlite-query'); try { + let sql = `SELECT * FROM links`; + const bind: (string | number)[] = []; + if (options?.limit !== undefined) { + sql += ` LIMIT ?`; + bind.push(options.limit); + } + if (options?.offset !== undefined) { + sql += ` OFFSET ?`; + bind.push(options.offset); + } const results = await this.db.exec({ - sql: `SELECT * FROM links`, + sql, + bind: bind.length > 0 ? bind : undefined, returnValue: 'resultRows', rowMode: 'object', }); diff --git a/src/features/graph/GraphControls.tsx b/src/features/graph/GraphControls.tsx index 4d8afa1..bafd9fc 100644 --- a/src/features/graph/GraphControls.tsx +++ b/src/features/graph/GraphControls.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Focus, Camera, Clock, X, FolderOpen, GitCompare, RotateCcw, Loader2, Layout, LayoutDashboard, Download } from 'lucide-react'; +import { Focus, Camera, Clock, X, FolderOpen, GitCompare, RotateCcw, Loader2, Layout, LayoutDashboard, Download, CircleDot } from 'lucide-react'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useMediaQuery } from '../../hooks/useMediaQuery'; @@ -32,8 +32,8 @@ interface GraphControlsProps { onExportPNG?: () => void; snapshotMode?: boolean; onSnapshotModeChange?: (active: boolean) => void; - layout?: 'force' | 'hierarchical'; - onLayoutChange?: (layout: 'force' | 'hierarchical') => void; + layout?: 'circular' | 'force' | 'hierarchical'; + onLayoutChange?: (layout: 'circular' | 'force' | 'hierarchical') => void; } const GraphControls: React.FC = ({ @@ -198,6 +198,16 @@ const GraphControls: React.FC = ({ )} {onLayoutChange && (
    +