diff --git a/BACKLOG.md b/BACKLOG.md index 8b755ef..e2d98df 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -138,91 +138,86 @@ - **Robust Windows Handling**: Increased timeouts to 8 minutes and implemented non-blocking cleanup to prevent `EBUSY` resource locks. - **UI Integration**: Added a dedicated web-grounding toggle and descriptive feedback in the sidebar settings. -### [#016] TypeError in TileCard (icon of undefined) -- **Status**: `Closed` | **Resolved**: 2026-05-03 -- **Labels**: `Bug`, `Stability`, `UI` -- **Summary**: Fixed a runtime crash where `TileCard` and other UI components failed when encountering unknown content types hallucinated by the LLM. Implemented `getSafeContentTypeConfig` and added input validation. - ---- - -## πŸ”­ Upcoming Features - ### [#022] Obsidian Plugin β€” Core Infrastructure -- **Status**: `In Progress` | **Priority**: `P1` | **Labels**: `Feature`, `Obsidian`, `Architecture` -- **Created**: 2026-05-11 -- **Description**: Port the nodepad React UI into Obsidian as a first-class plugin using the `TextFileView` API. `.nodepad` files live directly in the vault and auto-save on every state change β€” no manual export step. Renders all three view modes (Tiling, Kanban, Graph) inside an Obsidian leaf, themed via Obsidian's own CSS variables. -- **Scope**: - - `plugin/src/main.ts` β€” registers `.nodepad` extension, ribbon icon, command palette entry +- **Status**: `Closed` | **Resolved**: 2026-05-11 +- **Labels**: `Feature`, `Obsidian`, `Architecture` +- **Summary**: Ported the nodepad React UI into Obsidian as a first-class plugin using the `TextFileView` API. `.nodepad` files live directly in the vault and auto-save on every state change. All three view modes (Tiling, Kanban, Graph) render inside an Obsidian leaf, themed via Obsidian's own CSS variables. +- **Technical Highlights**: + - `plugin/src/main.ts` β€” registers `.nodepad` extension, ribbon icon, command palette entry, folder right-click menu - `plugin/src/view.tsx` β€” mounts React into the Obsidian leaf, reads/writes vault file via `requestSave()` - - `plugin/src/styles.css` β€” maps Tailwind tokens to Obsidian CSS variables for automatic theme adaptation - - `plugin/esbuild.config.mjs` β€” bundles all shared `lib/` and `components/` from the local fork - - Component patches β€” `isPlugin` mode in `VimInput` (hides Projects nav), portal scoping in `StatusBar`, `AboutPanel`, `Sheet` -- **Reference**: Upstream PR [mskayyali/nodepad#47](https://github.com/mskayyali/nodepad/pull/47) β€” cherry-picking plugin infrastructure only, skipping Anthropic provider additions. + - `plugin/src/styles.css` β€” maps Tailwind tokens to Obsidian CSS variables; SVG and button `!important` overrides for Obsidian CSS cascade conflicts + - `plugin/esbuild.config.mjs` β€” CJS bundle of all shared `lib/` and `components/` from the local fork via path aliases + - Component patches β€” `isPlugin` mode in `VimInput` (hides Projects nav), portal scoping in `StatusBar`, `AboutPanel`, `Sheet`; transparent SVG `` fix for graph pan/zoom; minimap inline padding override ### [#023] Obsidian Plugin β€” Obsidian Settings UI -- **Status**: `Open` | **Priority**: `P1` | **Labels**: `Feature`, `Obsidian`, `UX` -- **Created**: 2026-05-11 -- **Description**: In-Obsidian settings tab (Settings β†’ Nodepad) for configuring the AI provider, model, and API key without touching the web app. Settings are stored in `.obsidian/plugins/nodepad/data.json`, local to the vault and never synced externally. -- **Scope**: - - Provider dropdown (OpenRouter, OpenAI, Z.ai, Ollama, Gemini CLI) - - API key field (hidden for keyless providers like Gemini CLI and local Ollama) - - Model ID input with per-provider defaults - - `plugin/src/settings.ts` β€” `NodepadSettingTab` extending Obsidian's `PluginSettingTab` +- **Status**: `Closed` | **Resolved**: 2026-05-11 +- **Labels**: `Feature`, `Obsidian`, `UX` +- **Summary**: Implemented an in-Obsidian settings tab (Settings β†’ Nodepad) for all five AI providers. Settings stored in `.obsidian/plugins/nodepad/data.json`, local to the vault. +- **Technical Highlights**: + - Provider dropdown (OpenRouter, OpenAI, Z.ai, Ollama, Gemini CLI) with per-provider key persistence via `providerKeys` record + - API key field with eye icon toggle (hidden for keyless providers); Ollama model auto-discovery on switch + - Web-grounding toggle with provider-specific descriptions for all applicable providers ### [#024] Obsidian Plugin β€” CLI Provider Bridge (`child_process`) -- **Status**: `Open` | **Priority**: `P1` | **Labels**: `Feature`, `Obsidian`, `Architecture` -- **Created**: 2026-05-11 -- **Description**: Shared infrastructure in `plugin/src/ai-adapter.ts` for invoking local CLI tools as subprocesses via Node.js `child_process` inside Obsidian's Electron environment. This is the prerequisite that makes both Gemini CLI and future Claude Code work in the plugin β€” neither can be called via HTTP, both need to be spawned as local processes. The web app already handles this server-side in `/api/ai`; this is the equivalent for the plugin context. -- **Scope**: - - `spawnCLI(binary, args, stdinPayload)` β€” generic subprocess helper with stdout capture and timeout - - Stdin piping for high-context prompts (mirrors existing web app pattern) - - JSON extraction from CLI response wrappers (reuse logic from `lib/ai-ghost.ts`) - - Error handling: binary not found, non-zero exit code, malformed output +- **Status**: `Closed` | **Resolved**: 2026-05-11 +- **Labels**: `Feature`, `Obsidian`, `Architecture` +- **Summary**: Generic `spawnCLI()` subprocess helper in `plugin/src/ai-adapter.ts` that enables CLI tools (Gemini CLI, future Claude Code) to be invoked directly from Obsidian's Electron environment. +- **Technical Highlights**: + - Stdin piping for high-context prompts (bypasses Windows argument length limits) + - JSON wrapper extraction from CLI response formats + - 8-minute timeout with non-blocking cleanup to prevent `EBUSY` resource locks on Windows ### [#025] Obsidian Plugin β€” Ollama Provider Support -- **Status**: `Open` | **Priority**: `P1` | **Labels**: `Feature`, `Obsidian`, `Ollama` -- **Created**: 2026-05-11 -- **Description**: Enable Ollama (both local and Cloud) as an AI provider within the Obsidian plugin. Ollama uses a different request shape than OpenAI-compatible providers (`/api/chat` instead of `/chat/completions`, no `response_format`, `stream: false`). Since `requestUrl()` in Obsidian's Electron bypasses CORS, local Ollama (`localhost:11434`) works directly without the `/api/ai` proxy β€” actually cleaner than the web app path. -- **Scope**: - - Ollama request shape in `plugin/src/ai-adapter.ts` (port from `lib/ai-enrich.ts`) - - Cloud vs local routing (mirrors existing `getBaseUrl` logic) - - Dynamic model discovery via `requestUrl("http://localhost:11434/api/tags")` - - No RAG/embedding support in initial version (scoped to enrichment and ghost synthesis) +- **Status**: `Closed` | **Resolved**: 2026-05-11 +- **Labels**: `Feature`, `Obsidian`, `Ollama` +- **Summary**: Enabled Ollama (local and Cloud) with Hybrid RAG in the Obsidian plugin. `requestUrl()` in Obsidian's Electron bypasses CORS, so local Ollama works directly without a proxy. +- **Technical Highlights**: + - Local vs Cloud routing; dynamic model discovery via `/api/tags` + - Hybrid RAG pipeline (web search β†’ `embeddinggemma` vectorization β†’ cosine similarity β†’ top-5 injection) ported from web app server route to plugin adapter + - `getProviderHeaders()` for correct `Authorization` handling per routing mode ### [#026] Obsidian Plugin β€” Gemini CLI Provider Support -- **Status**: `Open` | **Priority**: `P1` | **Labels**: `Feature`, `Obsidian`, `Gemini-CLI` -- **Created**: 2026-05-11 -- **Description**: Enable Gemini CLI as a provider in the Obsidian plugin via the `child_process` bridge (#024). In the web app, Gemini CLI calls are handled server-side in `/api/ai`; in the plugin they are spawned directly from Obsidian's Electron process. Requires Gemini CLI to be installed and authenticated on the host machine. Web grounding via Gemini's native `google_web_search` tool is preserved since it runs inside the CLI itself. -- **Scope**: - - `fetchGeminiCLI(prompt, options)` in `plugin/src/ai-adapter.ts` using the `child_process` bridge - - Stdin piping for enrichment prompts (same pattern as web app) - - `--policy simple` flag for structured output, two-stage web grounding for RAG - - Settings UI: no API key field; show auth status / binary detection - - Graceful error if `gemini` binary is not found on PATH - -### [#027] Obsidian Plugin β€” Structured Study Guide Export to Vault -- **Status**: `Open` | **Priority**: `P2` | **Labels**: `Feature`, `Obsidian`, `AI` -- **Created**: 2026-05-11 -- **Description**: On-demand command that takes enriched blocks from the current `.nodepad` canvas and writes a structured Obsidian-native `.md` file into the vault. Unlike the existing one-shot markdown export, this produces properly formatted study guides with frontmatter, `[[backlinks]]` to related vault notes, section headers per category, and source citations. Acts as the bridge from nodepad's "raw idea staging area" into Obsidian's permanent knowledge graph. -- **Scope**: - - AI enrichment pass over all blocks to generate section groupings and a study guide outline - - Markdown generation with YAML frontmatter (`tags`, `created`, `source: nodepad`) - - `[[wikilink]]` insertion for terms that match existing vault note titles (via `app.vault.getMarkdownFiles()`) - - Output path: configurable folder (default: vault root), slugified from canvas name - - Command palette entry: "Export as Study Guide" -- **Open Questions**: - - Should the export be a destructive replacement (overwrite) or always create a new versioned file? - - Should tags be pulled from nodepad's `category` field or generated fresh by the AI? - -### [#028] Obsidian Plugin β€” Human-in-the-Loop Review Before Vault Write -- **Status**: `Open` | **Priority**: `P2` | **Labels**: `Feature`, `Obsidian`, `UX` +- **Status**: `Closed` | **Resolved**: 2026-05-11 +- **Labels**: `Feature`, `Obsidian`, `Gemini-CLI` +- **Summary**: Enabled Gemini CLI as a provider inside Obsidian via the `child_process` bridge. Web grounding runs natively inside the CLI (no separate RAG pass needed). +- **Technical Highlights**: + - Two-stage pipeline (Stage 1: agentic web research; Stage 2: `--policy simple` structured JSON enrichment) + - Keyless auth detection in settings UI; graceful error if `gemini` binary not on PATH + - JSON stats parsing for Ollama-style terminal logs (tool usage, model selection) + +### [#016] TypeError in TileCard (icon of undefined) +- **Status**: `Closed` | **Resolved**: 2026-05-03 +- **Labels**: `Bug`, `Stability`, `UI` +- **Summary**: Fixed a runtime crash where `TileCard` and other UI components failed when encountering unknown content types hallucinated by the LLM. Implemented `getSafeContentTypeConfig` and added input validation. + +--- + +## πŸ”­ Upcoming Features + +### [#027] Synthesis Document Generation +- **Status**: `Closed (web-app)` Β· `Open (plugin port)` | **Priority**: `P2` | **Labels**: `Feature`, `AI` +- **Created**: 2026-05-11 | **Web-app resolved**: 2026-05-11 +- **Design spec**: [`docs/synthesis-document-plan.md`](docs/synthesis-document-plan.md) +- **Description**: On-demand command ("Generate Synthesis Document") that consolidates enriched nodes from a `.nodepad` canvas into a structured, contextualized Obsidian markdown document. Unlike the raw markdown export which dumps nodes grouped by type, this pipeline expands sparse notes into self-contained statements, clusters them into coherent thematic sections by meaning, and adds expounding prompts that push thinking into adjacent territory the notes don't cover. Acts as the bridge from nodepad's raw idea staging area into Obsidian's permanent knowledge graph. +- **Pipeline**: + - **Phase 0** (human): User adds a `reference`/`entity` node naming the source material. No code needed. + - **Phase 1** (no AI): Build edge map from `influencedByIndices` graph; detect source anchor nodes. + - **Phase 2a + 2b** (parallel AI calls): Decontextualize each node into a self-contained statement (Call A) while simultaneously clustering nodes into named sections (Call B). + - **Phase 2c** (sequential AI call): Merge A + B results; generate section intros, expounding prompts, gap markers, and overall summary. + - **Phase 3** (no AI): Render to Obsidian-native markdown, inject `[[wikilinks]]`, write to vault. + - **Phase 4** (human + external tools): User reviews output against source material via Claude Code, Gemini CLI, or NotebookLM. No code needed. +- **New files**: + - `plugin/src/synthesis.ts` β€” pipeline orchestration + - `lib/synthesis-export.ts` β€” Phase 3 markdown renderer +- **Modified files**: + - `plugin/src/main.ts` β€” register command + - `plugin/src/view.tsx` β€” expose `generateSynthesisDocument()` on the view + - `plugin/src/ai-adapter.ts` β€” add `callDecontextualize`, `callCluster`, `callSynthesize` + +### [#028] Obsidian Plugin β€” Human-in-the-Loop Review (Phase 4) +- **Status**: `Open` | **Priority**: `P3` | **Labels**: `Feature`, `Obsidian`, `UX` - **Created**: 2026-05-11 -- **Description**: Before any structured `.md` file is written to the vault (#027), present the user with a review panel showing the proposed study guide. The user can edit sections, approve, or cancel. Ensures the AI output is supervised before it becomes a permanent vault note. Fits the stated design goal: the AI enriches, the human decides. -- **Scope**: - - Preview panel (modal or sidebar) showing the rendered markdown before write - - Section-level approve/reject toggles (keep this section, drop that one) - - Inline editing of AI-generated text before commit - - "Write to Vault" confirmation button triggers the actual `app.vault.create()` call +- **Description**: Phase 4 of the Synthesis Document pipeline β€” reviewing the generated document against the source material and making corrections. This requires no plugin code: the output is a standard Obsidian markdown file that any AI tool with vault access (Claude Code, Gemini CLI, NotebookLM) can read, annotate, and help correct. The review process itself is pedagogically valuable β€” identifying and correcting AI errors demonstrates understanding of the source material. If a built-in review UI is later desired (diff view, section toggles), it can be added as a separate issue. --- diff --git a/app/page.tsx b/app/page.tsx index ab4433f..b987939 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,12 +13,26 @@ import { IntroModal } from "@/components/intro-modal" import type { TextBlock } from "@/components/tile-card" import type { ContentType } from "@/lib/content-types" import { INITIAL_PROJECTS } from "@/lib/initial-data" -import { useAISettings } from "@/lib/ai-settings" +import { useAISettings, loadAIConfig } from "@/lib/ai-settings" import { enrichBlockClient } from "@/lib/ai-enrich" import { generateGhostClient } from "@/lib/ai-ghost" import { exportToMarkdown, downloadMarkdown, copyToClipboard } from "@/lib/export" import { downloadNodepadFile, parseNodepadFile, NodepadParseError } from "@/lib/nodepad-format" import { detectContentType } from "@/lib/detect-content-type" +import { + generateSynthesisDocument, + callPolish, + getSourceAnchors, + type CallTiming, + type ProgressEvent, +} from "@/lib/synthesis" +import { + renderSynthesisDocument, + renderPolishedDocument, + slugifySynthesis, +} from "@/lib/synthesis-export" +import { SynthesisConfirmDialog } from "@/components/synthesis-confirm-dialog" +import { SynthesisProgressPanel } from "@/components/synthesis-progress-panel" function generateId() { return Math.random().toString(36).substring(2, 10) @@ -60,6 +74,16 @@ export default function Page() { const [undoToast, setUndoToast] = useState(null) const undoToastTimer = useRef(null) + // ── Synthesis state ───────────────────────────────────────────────────────── + const [synthConfirmOpen, setSynthConfirmOpen] = useState(false) + const [synthProgressOpen, setSynthProgressOpen] = useState(false) + const [synthCalls, setSynthCalls] = useState([]) + const [synthActive, setSynthActive] = useState(false) + const [synthTotalStart, setSynthTotalStart] = useState(undefined) + const [synthSourceAnchors, setSynthSourceAnchors] = useState([]) + const [synthBlockCount, setSynthBlockCount] = useState(0) + const synthEnablePolish = useRef(false) + const pushHistory = useCallback((projectId: string, currentBlocks: TextBlock[]) => { if (!blockHistoryRef.current[projectId]) blockHistoryRef.current[projectId] = [] const stack = blockHistoryRef.current[projectId] @@ -878,8 +902,15 @@ export default function Page() { } return prev }) + } else if (cmd === "synthesis-doc") { + const proj = projectsRef.current.find(p => p.id === activeProjectId) + if (proj) { + setSynthSourceAnchors(getSourceAnchors(proj.blocks)) + setSynthBlockCount(proj.blocks.length) + setSynthConfirmOpen(true) + } } - + // Handle type overrides else if (cmd === "task" && text) addBlock(text, "task") else if (cmd === "thesis" && text) addBlock(text, "thesis") @@ -887,6 +918,94 @@ export default function Page() { setIsCommandKOpen(false) }, [clearBlocks, addBlock, activeProjectId]) + // ── Synthesis: start generation after confirm dialog ───────────────────────── + const startSynthesis = useCallback((enablePolish: boolean) => { + const proj = projectsRef.current.find(p => p.id === activeProjectId) + if (!proj) return + + synthEnablePolish.current = enablePolish + setSynthConfirmOpen(false) + setSynthCalls([]) + setSynthActive(true) + setSynthProgressOpen(false) + + const generationStart = Date.now() // local β€” used for accurate wall-clock timing + setSynthTotalStart(generationStart) // state β€” used for the progress pill display + + const blocks = proj.blocks + const name = proj.name + const slug = slugifySynthesis(name) + + const onProgress = (event: ProgressEvent) => { + setSynthCalls(prev => { + const next = [...prev] + if (event.type === "phase_start") { + next.push({ + id: event.id, label: event.label, status: "running", + startTime: Date.now(), + isParallel: event.id.startsWith("callC-") || event.id === "callA" || event.id === "callB", + }) + } else if (event.type === "phase_done") { + const t = next.find(t => t.id === event.id) + if (t) { t.status = "done"; t.durationMs = event.durationMs } + } else if (event.type === "error") { + const t = next.find(t => t.id === event.id) + if (t) t.status = "error" + } + return next + }) + } + + generateSynthesisDocument(blocks, onProgress) + .then(async ({ outline, decontextualized, clusters, timings }) => { + // Wall-clock time for the raw file = time from start to Call C completion + const rawWallClockMs = Date.now() - generationStart + const rawMd = renderSynthesisDocument(name, outline, decontextualized, clusters, timings, false, rawWallClockMs) + downloadMarkdown(`${slug}-synthesis.md`, rawMd) + + if (enablePolish) { + const dStart = Date.now() + setSynthCalls(prev => [...prev, { + id: "callD", label: "Final editorial polish", + status: "running", startTime: dStart, isParallel: false, + }]) + + try { + const config = loadAIConfig() + if (!config) throw new Error("No AI config for polish") + + const draftForPolish = renderSynthesisDocument(name, outline, decontextualized, clusters, [], false) + const polishedText = await callPolish(draftForPolish, config) + const polishDuration = Date.now() - dStart + + setSynthCalls(prev => prev.map(c => + c.id === "callD" ? { ...c, status: "done", durationMs: polishDuration } : c + )) + + const fullTimings: CallTiming[] = [ + ...timings, + { id: "callD", label: "Final editorial polish", status: "done", durationMs: polishDuration }, + ] + // Wall-clock for polished = full elapsed including Call D + const polishedWallClockMs = Date.now() - generationStart + const polishedMd = renderPolishedDocument(polishedText, fullTimings, polishedWallClockMs) + downloadMarkdown(`${slug}-synthesis-polished.md`, polishedMd) + } catch (e) { + setSynthCalls(prev => prev.map(c => + c.id === "callD" ? { ...c, status: "error" } : c + )) + console.error("[synthesis polish]", e) + } + } + + setSynthActive(false) + }) + .catch((err: Error) => { + console.error("[synthesis]", err) + setSynthActive(false) + }) + }, [activeProjectId]) + return (
{/* Hidden file input for .nodepad import */} @@ -1028,6 +1147,25 @@ export default function Page() { />
+ {/* Synthesis confirm dialog */} + setSynthConfirmOpen(false)} + /> + + {/* Synthesis progress pill + dialog */} + setSynthProgressOpen(true)} + onDialogClose={() => setSynthProgressOpen(false)} + /> + {/* Undo toast */} {undoToast && ( diff --git a/components/synthesis-confirm-dialog.tsx b/components/synthesis-confirm-dialog.tsx new file mode 100644 index 0000000..86bedef --- /dev/null +++ b/components/synthesis-confirm-dialog.tsx @@ -0,0 +1,176 @@ +"use client" + +import * as React from "react" +import { createPortal } from "react-dom" +import { motion, AnimatePresence } from "framer-motion" +import { AlertTriangle, BookOpen, CheckSquare, Square, X } from "lucide-react" +import type { TextBlock } from "@/components/tile-card" + +interface SynthesisConfirmDialogProps { + isOpen: boolean + sourceAnchors: TextBlock[] + blockCount: number + onConfirm: (enablePolish: boolean) => void + onCancel: () => void + container?: HTMLElement +} + +export function SynthesisConfirmDialog({ + isOpen, + sourceAnchors, + blockCount, + onConfirm, + onCancel, + container, +}: SynthesisConfirmDialogProps) { + const [enablePolish, setEnablePolish] = React.useState(false) + + // Reset polish toggle when dialog re-opens + React.useEffect(() => { + if (isOpen) setEnablePolish(false) + }, [isOpen]) + + if (!isOpen) return null + + const hasAnchors = sourceAnchors.length > 0 + + const content = ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Dialog */} + +
e.stopPropagation()} + > + {/* Header */} +
+
+

+ Generate Synthesis Document +

+

+ {blockCount} note{blockCount !== 1 ? "s" : ""} Β· {hasAnchors ? `${sourceAnchors.length} source anchor${sourceAnchors.length !== 1 ? "s" : ""} detected` : "no source anchors"} +

+
+ +
+ +
+ + {/* Source anchors */} +
+
+ + + Source Anchors + +
+ {hasAnchors ? ( +
    + {sourceAnchors.map(a => ( +
  • + {a.text.length > 80 ? a.text.slice(0, 80) + "…" : a.text} +
  • + ))} +
+ ) : ( +
+ +

+ No source reference nodes detected. Adding a{" "} + reference node naming your + source material significantly improves output quality. You can proceed + without one or cancel to add one first. +

+
+ )} +
+ + {/* Polish toggle */} +
+ +
+ + {/* Warnings */} +
+

+ Β·{" "} + Notes added after clicking Generate will not be included in this synthesis. +

+

+ Β·{" "} + Multiple parallel and sequential AI calls will fire simultaneously β€” expect + a spike in provider activity and corresponding token costs. +

+

+ Β·{" "} + Estimated time varies by provider and model. Expect ~5 minutes for the + raw document{enablePolish ? ", longer with polish enabled" : ""}. +

+
+
+ + {/* Actions */} +
+ + +
+
+
+ + )} +
+ ) + + return container ? createPortal(content, container) : content +} diff --git a/components/synthesis-progress-panel.tsx b/components/synthesis-progress-panel.tsx new file mode 100644 index 0000000..fe112f9 --- /dev/null +++ b/components/synthesis-progress-panel.tsx @@ -0,0 +1,271 @@ +"use client" + +import * as React from "react" +import { createPortal } from "react-dom" +import { motion, AnimatePresence } from "framer-motion" +import { CheckCircle, AlertCircle, X, ChevronUp } from "lucide-react" +import type { CallTiming } from "@/lib/synthesis" +import { formatDuration } from "@/lib/synthesis" + +interface SynthesisProgressPanelProps { + calls: CallTiming[] + isActive: boolean + totalStartMs?: number + isDialogOpen: boolean + onPillClick: () => void + onDialogClose: () => void + container?: HTMLElement +} + +// ── Elapsed timer for running calls ────────────────────────────────────────── + +function useElapsed(active: boolean): number { + const [now, setNow] = React.useState(Date.now()) + React.useEffect(() => { + if (!active) return + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, [active]) + return now +} + +// ── Single call row ─────────────────────────────────────────────────────────── + +function CallRow({ call, now }: { call: CallTiming; now: number }) { + const elapsed = call.startTime ? now - call.startTime : 0 + + return ( +
+ {/* Status icon */} +
+ {call.status === "done" && ( + + )} + {call.status === "error" && ( + + )} + {call.status === "running" && ( + + )} + {call.status === "pending" && ( +
+ )} +
+ + {/* Label */} + + {call.isParallel && βˆ₯} + {call.label} + + + {/* Duration */} + + {call.status === "done" && call.durationMs != null ? formatDuration(call.durationMs) : + call.status === "running" && call.startTime ? `${Math.round(elapsed / 1000)}s…` : + call.status === "error" ? "error" : + "β€”"} + +
+ ) +} + +// ── Progress dialog ─────────────────────────────────────────────────────────── + +function ProgressDialog({ + calls, + isActive, + totalStartMs, + onClose, +}: { + calls: CallTiming[] + isActive: boolean + totalStartMs?: number + onClose: () => void +}) { + const now = useElapsed(isActive) + const doneCount = calls.filter(c => c.status === "done").length + const totalCount = calls.length + const totalElapsed = totalStartMs ? now - totalStartMs : 0 + + // Group: parallel calls together, sequential separate + const parallelCalls = calls.filter(c => c.isParallel) + const sequentialCalls = calls.filter(c => !c.isParallel) + + return ( + <> + + +
e.stopPropagation()} + > + {/* Header */} +
+
+

+ {isActive ? "Synthesis in progress" : "Synthesis complete"} +

+

+ {isActive + ? `${doneCount} of ${totalCount} calls done Β· ${Math.round(totalElapsed / 1000)}s elapsed` + : `${doneCount} calls Β· ${formatDuration(totalElapsed)}` + } +

+
+ +
+ + {/* Call list */} +
+ {parallelCalls.length > 0 && ( + <> +

+ Parallel calls +

+ {parallelCalls.map(c => )} + + )} + {parallelCalls.length > 0 && sequentialCalls.length > 0 && ( +
+ )} + {sequentialCalls.length > 0 && ( + <> +

+ Sequential calls +

+ {sequentialCalls.map(c => )} + + )} +
+ + {/* Legend */} +
+ βˆ₯ parallel + Β· sequential +
+
+ + + ) +} + +// ── Bottom pill ─────────────────────────────────────────────────────────────── + +export function SynthesisProgressPanel({ + calls, + isActive, + totalStartMs, + isDialogOpen, + onPillClick, + onDialogClose, + container, +}: SynthesisProgressPanelProps) { + const now = useElapsed(isActive) + const doneCount = calls.filter(c => c.status === "done").length + const totalCount = calls.length + const hasError = calls.some(c => c.status === "error") + + const [dismissed, setDismissed] = React.useState(false) + + // Reset dismissed state when a new synthesis starts + React.useEffect(() => { + if (isActive) setDismissed(false) + }, [isActive]) + + const pillLabel = hasError + ? "Synthesis error β€” click for details" + : isActive + ? totalCount > 0 + ? `Synthesising β€” ${doneCount}/${totalCount} calls done` + : "Synthesising…" + : `Synthesis done β€” ${totalStartMs ? formatDuration(now - totalStartMs) : ""}` + + const showPill = calls.length > 0 && !dismissed + + const panel = ( + <> + {/* Bottom pill */} + + {showPill && ( + + + {/* Dismiss β€” only shown when not active */} + {!isActive && ( + + )} + + )} + + + {/* Progress dialog */} + + {isDialogOpen && ( + + )} + + + ) + + return container ? createPortal(panel, container) : panel +} diff --git a/components/vim-input.tsx b/components/vim-input.tsx index b0d2c3c..cd50ecf 100644 --- a/components/vim-input.tsx +++ b/components/vim-input.tsx @@ -5,7 +5,7 @@ import { motion, AnimatePresence } from "framer-motion" import { Trello, Grid, Trash2, Clipboard, Download, FolderOpen, FolderPlus, BookOpen, Sparkles, - FolderDown, FolderInput, GitFork + FolderDown, FolderInput, GitFork, ScrollText } from "lucide-react" import { Command } from "cmdk" import { useModKey } from "@/lib/utils" @@ -14,8 +14,9 @@ const ALL_ACTION_ITEMS = [ { id: "export-nodepad", icon: FolderDown, label: "Export", sub: ".nodepad", pluginOnly: false }, { id: "import-nodepad", icon: FolderInput, label: "Import", sub: ".nodepad", pluginOnly: false }, { id: "export-md", icon: Download, label: "Export", sub: "markdown", pluginOnly: true }, - { id: "copy-md", icon: Clipboard, label: "Copy", sub: "markdown", pluginOnly: true }, - { id: "clear", icon: Trash2, label: "Clear", sub: "canvas", pluginOnly: true }, + { id: "copy-md", icon: Clipboard, label: "Copy", sub: "markdown", pluginOnly: true }, + { id: "synthesis-doc", icon: ScrollText, label: "Synthesis", sub: "document", pluginOnly: false }, + { id: "clear", icon: Trash2, label: "Clear", sub: "canvas", pluginOnly: true }, ] // ─── Props ─────────────────────────────────────────────────────────────────── diff --git a/docs/synthesis-document-plan.md b/docs/synthesis-document-plan.md new file mode 100644 index 0000000..4f665fa --- /dev/null +++ b/docs/synthesis-document-plan.md @@ -0,0 +1,384 @@ +# Synthesis Document Generation β€” Implementation Plan + +> Backlog: [#027](../BACKLOG.md) + +## What it is + +A three-phase AI pipeline that consolidates enriched nodes from a `.nodepad` canvas into a structured, contextualized Obsidian markdown document. Unlike the raw markdown export (which groups nodes by content type), the Synthesis Document expands sparse notes into self-contained statements, clusters them into thematic sections by meaning, and adds expounding prompts that push thinking beyond what the notes cover. + +The core problem it solves: nodepad notes are intentionally unstructured fragments. They only make full sense in the context of the source material being studied. The pipeline uses the enrichment graph (`influencedByIndices` edges, annotations, source anchor nodes) to reconstruct as much of that context as possible before synthesizing. + +--- + +## Pipeline Overview + +``` +Phase 0 [Human] Add source anchor node(s) to canvas +Phase 1 [No AI] Build edge map from block graph + ↓ +Phase 2a [AI Call A] ─────────────────────────────────────────── \ + Decontextualize each node into a self-contained statement β”œβ”€β†’ Phase 2c [AI Call C] β†’ Phase 3 [No AI] β†’ Vault +Phase 2b [AI Call B] ─────────────────────────────────────────── / + Cluster nodes into named sections + ↓ +Phase 4 [Human] Review output against source via external tools +``` + +Calls A and B run in parallel (`Promise.all`). Call C is sequential after both complete. + +--- + +## Phase 0 β€” Source Anchor (Human, no code) + +The user adds a `reference` or `entity` type node to the canvas naming the source material being studied. + +**Example:** *"Das Kapital, Chapter 1 β€” Karl Marx, 1867"* + +The pipeline detects these automatically (see Phase 1). If none exist, generation proceeds with a non-blocking warning: *"No source node found β€” adding a reference node naming your source improves output quality."* + +--- + +## Phase 1 β€” Edge Map Builder (No AI) + +**File:** `plugin/src/synthesis.ts` + +Builds a serialized representation of the block graph for use by both parallel AI calls. + +**Logic:** +1. Iterate all blocks on the canvas. +2. For each block, resolve `influencedByIndices` to get the actual neighbor block objects. +3. Identify source anchor candidates: + - Always: `contentType === "reference"` + - Heuristic: `contentType === "entity"` blocks referenced by many others but with few outgoing connections themselves +4. Separate anchor nodes from regular nodes. + +**Output types:** + +```typescript +interface NodeWithContext { + id: string + text: string + contentType: ContentType + category?: string + annotation?: string + neighborIds: string[] + neighborTexts: string[] // text + annotation of each neighbor, concatenated +} + +interface EdgeMap { + nodes: NodeWithContext[] + sourceAnchors: NodeWithContext[] +} +``` + +--- + +## Phase 2a β€” Decontextualization (AI Call A, parallel) + +**File:** `plugin/src/ai-adapter.ts` β†’ `callDecontextualize(plugin, edgeMap)` + +**Model:** Current provider's primary model (same as enrichment). + +**Task:** Rewrite each node as a self-contained statement using only the provided neighboring notes and source anchor as context. No external knowledge fill. + +**Prompt:** + +``` +GROUNDING RULES β€” CRITICAL: +Use ONLY the provided notes and context. Do not draw on external knowledge to fill gaps. +If a note is too sparse to expand meaningfully, return its original text unchanged. + +SOURCE MATERIAL: {sourceAnchors} + +For each note below, rewrite it as a single self-contained statement. +Use only the note's annotation and neighboring notes as context. +Resolve implicit references (pronouns, abbreviations, topic shortcuts). +Do not add information not present in the provided context. + +Notes: +[ + { + "id": "abc123", + "text": "labour theory β€” socially necessary time", + "annotation": "Marx argues value is determined by socially necessary labour time", + "neighbors": [ + "Karl Marx β€” author of Das Kapital, foundational work of Marxist economic theory", + "Surplus value: the difference between value produced by labour and wages paid" + ] + }, + ... +] + +Return ONLY valid JSON β€” an array with one entry per input note, in the same order: +[{ "id": "abc123", "statement": "..." }, ...] +``` + +**Output:** `DecontextualizedNode[]` + +```typescript +interface DecontextualizedNode { + id: string + statement: string +} +``` + +**Error handling:** +- Statement identical to original text β†’ valid, keep it (note was too sparse to expand). +- Parse failure β†’ fall back to `text + ". " + annotation` concatenated as the statement. + +--- + +## Phase 2b β€” Clustering (AI Call B, parallel with A) + +**File:** `plugin/src/ai-adapter.ts` β†’ `callCluster(plugin, edgeMap)` + +**Model:** Lightest/fastest model available for the current provider. This is the cheapest call in the pipeline β€” the output is only node ID groupings, no prose. + +**Task:** Group nodes into semantically coherent named sections. Nodes with no connections go into "General." + +**Prompt:** + +``` +Group the following research notes into semantically coherent sections for a synthesis document. + +RULES: +- Use the connection hints to guide grouping, but group by meaning β€” not just graph proximity +- Notes with no connections go into a section named "General" +- Aim for 3–7 sections; merge thin sections rather than leaving singletons +- Name each section descriptively (2–5 words) +- Every note ID must appear in exactly one section + +SOURCE MATERIAL: {sourceAnchors} + +Notes: +[ + { + "id": "abc123", + "text": "labour theory β€” socially necessary time", + "category": "Economics", + "contentType": "definition", + "connectedIds": ["def456", "ghi789"] + }, + ... +] + +Return ONLY valid JSON: +[{ "sectionName": "Labour Theory of Value", "nodeIds": ["abc123", "def456"] }, ...] +``` + +**Output:** `ClusterAssignment[]` + +```typescript +interface ClusterAssignment { + sectionName: string + nodeIds: string[] +} +``` + +**Post-processing (no AI):** +- Verify every node ID appears in exactly one section. +- Any missing IDs β†’ append to "General". +- Any duplicate assignments β†’ keep first occurrence. + +--- + +## Phase 2c β€” Synthesis (AI Call C, sequential) + +**File:** `plugin/src/ai-adapter.ts` β†’ `callSynthesize(plugin, mergedSections, sourceAnchors)` + +**Model:** Best available for the current provider. + +**Pre-merge (no AI):** Join Call A and Call B results by node ID before constructing the prompt: + +```typescript +const mergedSections = clusterAssignments.map(cluster => ({ + candidateHeading: cluster.sectionName, + statements: cluster.nodeIds + .map(id => decontextualizedNodes.find(n => n.id === id)) + .filter(Boolean), +})) +``` + +**Task:** Write section headings, intros, expounding prompts, gap markers, and an overall summary. Strictly grounded on the provided statements β€” no external knowledge fill. + +**Prompt:** + +``` +You are generating a Synthesis Document from research notes. + +A Synthesis Document consolidates sparse, fragmented notes into a coherent document +about the ideas and concepts in the nodespace. It is not a summary β€” it organizes notes +into their full meaning and pushes thinking outward through open questions. + +GROUNDING RULES β€” CRITICAL: +- Only use the provided statements and source material +- Do not supplement gaps with external knowledge +- Where notes are insufficient, flag as a gap explicitly + +SOURCE MATERIAL: {sourceAnchors} + +For each section: +1. Write a clear section heading (improve on the candidate name if needed) +2. Write a 1–2 sentence intro: what should the reader understand from this section? +3. Write 2–3 expounding prompts β€” open questions that push thinking into adjacent territory + the notes do NOT cover. These should not test recall; they should invite exploration. + Good example: "Are there economic systems that critique capital accumulation without + requiring collective ownership of production?" + Bad example: "What is the labour theory of value?" (that's just recall) +4. List any gaps: concepts implied by the notes but not explained within them + +Also write a 2–3 sentence overall summary of the entire nodespace. + +Sections: +[ + { + "candidateHeading": "Labour Theory of Value", + "statements": [ + { "id": "abc123", "statement": "In Das Kapital, Marx argues that a commodity's value..." }, + { "id": "def456", "statement": "Surplus value is the difference between..." } + ] + }, + ... +] + +Return ONLY valid JSON: +{ + "summary": "...", + "sections": [{ + "heading": "...", + "intro": "...", + "nodeIds": ["abc123", "def456"], + "expandingPrompts": ["...", "..."], + "gaps": ["..."] + }] +} +``` + +**Output:** `SynthesisOutline` + +```typescript +interface SynthesisOutline { + summary: string + sections: Array<{ + heading: string + intro: string + nodeIds: string[] + expandingPrompts: string[] + gaps: string[] + }> +} +``` + +--- + +## Phase 3 β€” Markdown Renderer (No AI) + +**File:** `lib/synthesis-export.ts` + +Pure function, no Obsidian dependencies. Usable from both the plugin and the web app. + +```typescript +export function renderSynthesisDocument( + canvasName: string, + outline: SynthesisOutline, + decontextualized: DecontextualizedNode[], + clusterAssignments: ClusterAssignment[], + date: string, +): string +``` + +**Output format:** + +```markdown +--- +title: "Synthesis: Canvas Name" +tags: [synthesis, nodepad] +created: 2026-05-11 +source: nodepad +--- + +# Canvas Name β€” Synthesis + +> 2–3 sentence summary of the entire nodespace. + +--- + +## Labour Theory of Value + +1–2 sentence intro explaining what the reader should understand. + +In Das Kapital, Marx argues that a commodity's value is determined by the socially +necessary labour time required to produce it β€” the foundation for his theory of +surplus value. +> *Source: nodes abc123, def456* + +Surplus value is the difference between the value workers produce and the wages +they receive, which Marx identifies as the mechanism of capitalist profit extraction. +> *Source: node def456* + +> [!example]- Expounding Prompts +> 1. Are there economic systems that critique capital accumulation without requiring collective ownership of production? +> 2. How does Marx's theory of value hold up against modern algorithmic pricing, where marginal cost approaches zero? + +> [!question]- Gaps in your notes +> - Rate of exploitation is referenced but not defined within your notes. + +--- + +## [Next section] +... + +--- +*Generated by [nodepad](https://nodepad.space) from 24 nodes Β· 11 May 2026* +``` + +**Wikilink injection (Obsidian plugin only):** +After rendering, scan the output text for terms that exactly match existing vault note titles via `app.vault.getMarkdownFiles()`. Wrap matched terms with `[[double brackets]]`. Case-insensitive, whole-word match only. Runs as a string post-pass before vault write. + +**Output path:** +- Default: vault root +- Filename: `{canvas-name}-synthesis.md` (slugified) +- If file exists: append `-2`, `-3`, etc. (never overwrite) + +--- + +## Progress UX + +The command shows incremental `Notice` updates during generation: + +``` +Generating synthesis… (1/3) Decontextualizing nodes +Generating synthesis… (2/3) Clustering sections +Generating synthesis… (3/3) Writing synthesis +Synthesis document created: my-canvas-synthesis.md +``` + +On completion, the generated file opens in a new Obsidian leaf. + +On any error, a `Notice` shows the error message and generation stops cleanly. + +--- + +## New Files + +| File | Purpose | +|---|---| +| `plugin/src/synthesis.ts` | Pipeline orchestration (Phases 1–3) | +| `lib/synthesis-export.ts` | Phase 3 markdown renderer (pure, no Obsidian deps) | + +## Modified Files + +| File | Change | +|---|---| +| `plugin/src/main.ts` | Register "Generate Synthesis Document" command | +| `plugin/src/view.tsx` | Expose `generateSynthesisDocument()` on `NodepadView` | +| `plugin/src/ai-adapter.ts` | Add `callDecontextualize`, `callCluster`, `callSynthesize` | + +--- + +## Open Questions + +1. **Model tiers:** Should Call B (clustering) explicitly use a lighter model, or always use the provider's primary? Recommendation: use primary for all three in v1; add per-call model override in a follow-up. +2. **Output folder:** Should there be a plugin setting for output path, or always write to vault root? Recommendation: vault root in v1, add setting later. +3. **Minimum block count:** Should the command be disabled when fewer than N blocks exist? Recommendation: warn at < 3 blocks, don't hard-block. +4. **Web app version:** A download variant (instead of vault write) would make this feature available outside Obsidian. Scoped out of this issue β€” separate backlog item if desired. diff --git a/lib/synthesis-export.ts b/lib/synthesis-export.ts new file mode 100644 index 0000000..30f1eb2 --- /dev/null +++ b/lib/synthesis-export.ts @@ -0,0 +1,170 @@ +import type { + DecontextualizedNode, + ClusterAssignment, + SynthesisOutline, + CallTiming, +} from "@/lib/synthesis" +import { formatDuration } from "@/lib/synthesis" + +function formatDate(): string { + return new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }) +} + +export function slugifySynthesis(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .slice(0, 50) +} + +function renderTimingTable(timings: CallTiming[], isPolished: boolean, wallClockMs?: number): string { + if (timings.length === 0) return "" + + const lines: string[] = [] + + lines.push(`> [!info]- Generation Statistics`) + lines.push(`> | Step | Duration |`) + lines.push(`> |---|---|`) + + for (const t of timings) { + const dur = t.durationMs != null ? formatDuration(t.durationMs) : "β€”" + const prefix = t.isParallel ? "*(parallel)* " : "" + lines.push(`> | ${prefix}${t.label} | ${dur} |`) + } + + // Use actual wall-clock time for Total β€” parallel calls run simultaneously + // so summing individual durations would give an inflated number + if (wallClockMs != null && wallClockMs > 0) { + lines.push(`> | **Total (wall-clock)** | **${formatDuration(wallClockMs)}** |`) + } + if (isPolished) lines.push(`> `) + if (isPolished) lines.push(`> *Includes final editorial polish (Call D).*`) + + return lines.join("\n") +} + +export function renderSynthesisDocument( + canvasName: string, + outline: SynthesisOutline, + decontextualized: DecontextualizedNode[], + clusters: ClusterAssignment[], + timings: CallTiming[], + isPolished = false, + wallClockMs?: number, +): string { + const date = formatDate() + const totalNodes = clusters.reduce((sum, c) => sum + c.nodeIds.length, 0) + const stmtMap = new Map(decontextualized.map(n => [n.id, n.statement])) + + const lines: string[] = [] + + // ── YAML front matter ─────────────────────────────────────────────────────── + lines.push(`---`) + lines.push(`title: "${isPolished ? "Synthesis (Polished)" : "Synthesis"}: ${canvasName}"`) + lines.push(`tags: [synthesis, nodepad${isPolished ? ", polished" : ""}]`) + lines.push(`created: ${new Date().toISOString().slice(0, 10)}`) + lines.push(`source: nodepad`) + if (wallClockMs != null && wallClockMs > 0) lines.push(`generated_in: "${formatDuration(wallClockMs)}"`) + lines.push(`---`) + lines.push(``) + + // ── Title ─────────────────────────────────────────────────────────────────── + lines.push(`# ${canvasName} β€” Synthesis${isPolished ? " (Polished)" : ""}`) + lines.push(``) + lines.push(`---`) + lines.push(``) + + // ── Sections ──────────────────────────────────────────────────────────────── + for (const section of outline.sections) { + lines.push(`## ${section.heading}`) + lines.push(``) + + if (section.intro) { + lines.push(section.intro) + lines.push(``) + } + + // Synthesis prose β€” the main output + if (section.sectionSynthesis) { + lines.push(section.sectionSynthesis) + lines.push(``) + } + + // Source notes β€” collapsible reference + const sectionStatements = section.nodeIds + .map(id => ({ id, stmt: stmtMap.get(id) })) + .filter((s): s is { id: string; stmt: string } => !!s.stmt) + + if (sectionStatements.length > 0) { + lines.push(`> [!note]- Source Notes (${sectionStatements.length})`) + for (const { id, stmt } of sectionStatements) { + const truncated = stmt.length > 120 ? stmt.slice(0, 120) + "…" : stmt + lines.push(`> - ${truncated} *(node \`${id.slice(0, 8)}\`)*`) + } + lines.push(``) + } + + // Expounding prompts + if (section.expandingPrompts.length > 0) { + lines.push(`> [!example]- Expounding Prompts`) + section.expandingPrompts.forEach((p, i) => lines.push(`> ${i + 1}. ${p}`)) + lines.push(``) + } + + // Gaps + if (section.gaps.length > 0) { + lines.push(`> [!question]- Gaps in your notes`) + section.gaps.forEach(g => lines.push(`> - ${g}`)) + lines.push(``) + } + + lines.push(`---`) + lines.push(``) + } + + // ── Timing footer ─────────────────────────────────────────────────────────── + if (timings.length > 0) { + lines.push(renderTimingTable(timings, isPolished, wallClockMs)) + lines.push(``) + } + + // ── Footer ────────────────────────────────────────────────────────────────── + lines.push(`*Generated by [nodepad](https://nodepad.space) from ${totalNodes} node${totalNodes !== 1 ? "s" : ""} Β· ${date}*`) + lines.push(``) + + return lines.join("\n") +} + +export function renderPolishedDocument( + polishedText: string, + timings: CallTiming[], + wallClockMs?: number, +): string { + const timingBlock = renderTimingTable(timings, true, wallClockMs) + const footer = `\n${timingBlock}\n\n*Generated by [nodepad](https://nodepad.space) Β· ${formatDate()}*\n` + + // Inject generated_in into the frontmatter using actual wall-clock time + let text = polishedText + if (wallClockMs != null && wallClockMs > 0) { + const frontmatterEnd = text.indexOf("\n---", 4) + if (frontmatterEnd !== -1) { + const hasDuration = /^generated_in:/m.test(text.slice(0, frontmatterEnd)) + if (hasDuration) { + text = text.replace(/^generated_in:.*$/m, `generated_in: "${formatDuration(wallClockMs)}"`) + } else { + text = text.slice(0, frontmatterEnd) + + `\ngenerated_in: "${formatDuration(wallClockMs)}"` + + text.slice(frontmatterEnd) + } + } + } + + // Append timing + footer, replacing the raw footer if Call D preserved it + const lastFooterMatch = text.match(/\n\*Generated by \[nodepad\][\s\S]*$/) + if (lastFooterMatch) { + return text.slice(0, text.lastIndexOf(lastFooterMatch[0])) + footer + } + return text.trimEnd() + "\n\n" + footer +} diff --git a/lib/synthesis.ts b/lib/synthesis.ts new file mode 100644 index 0000000..c3184a7 --- /dev/null +++ b/lib/synthesis.ts @@ -0,0 +1,567 @@ +"use client" + +import { loadAIConfig, getBaseUrl, getProviderHeaders, type AIConfig } from "@/lib/ai-settings" +import type { TextBlock } from "@/components/tile-card" + +// ── Progress events ─────────────────────────────────────────────────────────── + +export type ProgressEvent = + | { type: "phase_start"; id: string; label: string } + | { type: "phase_done"; id: string; durationMs: number } + | { type: "error"; id: string; message: string } + +export interface CallTiming { + id: string + label: string + status: "pending" | "running" | "done" | "error" + startTime?: number + durationMs?: number + isParallel?: boolean +} + +// ── Data types ──────────────────────────────────────────────────────────────── + +export interface NodeWithContext { + id: string + text: string + contentType: string + category?: string + annotation?: string + neighborIds: string[] + neighborTexts: string[] +} + +export interface EdgeMap { + nodes: NodeWithContext[] + sourceAnchors: NodeWithContext[] +} + +export interface DecontextualizedNode { + id: string + statement: string +} + +export interface ClusterAssignment { + sectionName: string + nodeIds: string[] +} + +export interface SynthesisSection { + heading: string + intro: string + sectionSynthesis: string + nodeIds: string[] + expandingPrompts: string[] + gaps: string[] +} + +export interface SynthesisOutline { + sections: SynthesisSection[] +} + +export interface SynthesisResult { + outline: SynthesisOutline + decontextualized: DecontextualizedNode[] + clusters: ClusterAssignment[] + timings: CallTiming[] +} + +// ── Phase 1: Edge map builder ───────────────────────────────────────────────── + +export function buildEdgeMap(blocks: TextBlock[]): EdgeMap { + const byId = new Map(blocks.map(b => [b.id, b])) + const sourceAnchors: NodeWithContext[] = [] + const nodes: NodeWithContext[] = [] + + for (const block of blocks) { + const neighborIds = (block.influencedBy ?? []).filter(id => byId.has(id)) + const neighborTexts = neighborIds + .map(id => byId.get(id)!) + .map(n => [n.text, n.annotation].filter(Boolean).join(" β€” ")) + + const node: NodeWithContext = { + id: block.id, + text: block.text, + contentType: block.contentType, + category: block.category, + annotation: block.annotation, + neighborIds, + neighborTexts, + } + + if (block.contentType === "reference") { + sourceAnchors.push(node) + } else { + nodes.push(node) + } + } + + return { nodes, sourceAnchors } +} + +// ── Shared AI helpers ───────────────────────────────────────────────────────── + +function getTargetUrl(config: AIConfig): string { + const base = getBaseUrl(config) + return config.provider === "ollama" ? `${base}/api/chat` : `${base}/chat/completions` +} + +function buildPayload( + config: AIConfig, + messages: { role: string; content: string }[], + maxTokens: number, + wantJson = true, +) { + const isOllama = config.provider === "ollama" + return isOllama + ? { model: config.modelId, messages, stream: false, options: { temperature: 0.2 } } + : { + model: config.modelId, + max_tokens: maxTokens, + messages, + temperature: 0.2, + ...(wantJson ? { response_format: { type: "json_object" } } : {}), + } +} + +// ── Injectable transport ────────────────────────────────────────────────────── +// The web-app routes through /api/ai; the plugin uses requestUrl/spawnCLI +// directly. Pass a SynthesisCallFn to swap the transport layer. + +export type SynthesisCallFn = ( + config: AIConfig, + targetUrl: string, + payload: object, +) => Promise + +async function defaultCallAI(config: AIConfig, targetUrl: string, payload: object): Promise { + const response = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: targetUrl, + method: "POST", + headers: getProviderHeaders(config), + body: payload, + }), + }) + + if (!response.ok) { + const text = await response.text().catch(() => "") + throw new Error(`AI request failed (${response.status}): ${text.slice(0, 200)}`) + } + + const data = await response.json() + const content = config.provider === "ollama" + ? data.message?.content + : data.choices?.[0]?.message?.content + + if (!content) throw new Error("Empty response from AI provider") + return content as string +} + +function dumpRawResponse(label: string, raw: string): void { + const blob = new Blob([raw], { type: "text/plain;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${label}-raw.txt` + a.click() + URL.revokeObjectURL(url) +} + +function extractJson(text: string): string { + // Fenced code block takes priority + const fenced = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/) + if (fenced) return fenced[1].trim() + + const objStart = text.indexOf("{") + const arrStart = text.indexOf("[") + + // Use whichever opening delimiter appears first in the text. + // This correctly handles: + // - Object responses that contain arrays (synthesis): { comes before any [ + // - Array responses (decontextualize, cluster): [ comes before any { + if (objStart !== -1 && (arrStart === -1 || objStart <= arrStart)) { + const objEnd = text.lastIndexOf("}") + if (objEnd > objStart) return text.slice(objStart, objEnd + 1) + } + if (arrStart !== -1) { + const arrEnd = text.lastIndexOf("]") + if (arrEnd > arrStart) return text.slice(arrStart, arrEnd + 1) + } + return text.trim() +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return `<1s` + const s = ms / 1000 + if (s < 60) return `${s.toFixed(1)}s` + const m = Math.floor(s / 60) + const rem = Math.round(s % 60) + return `${m}m ${rem}s` +} + +// ── Call A: Decontextualisation ─────────────────────────────────────────────── + +const DECONTEXTUALIZE_SYSTEM = `You rewrite sparse research notes into self-contained statements. + +RULES: +- Use ONLY the note's text, annotation, and provided neighbouring notes as context +- Do NOT draw on external knowledge β€” if a note is too sparse to expand, return its text unchanged +- Resolve pronouns, abbreviations, and topic shortcuts using only the provided context +- One statement per note β€” a single clear sentence or short paragraph +- Preserve factual meaning exactly; do not add claims absent from the context + +OUTPUT: You MUST return a JSON array with one entry per input note, in the same order: +[{ "id": "...", "statement": "..." }, ...]` + +export async function callDecontextualize( + edgeMap: EdgeMap, + config: AIConfig, + callFn: SynthesisCallFn = defaultCallAI, +): Promise { + const sourceCtx = edgeMap.sourceAnchors.length > 0 + ? `SOURCE MATERIAL:\n${edgeMap.sourceAnchors.map(a => `- ${a.text}`).join("\n")}\n\n` + : "" + + const notesJson = JSON.stringify( + edgeMap.nodes.map(n => ({ + id: n.id, + text: n.text, + annotation: n.annotation ?? "", + neighbors: n.neighborTexts, + })), + null, 2, + ) + + const targetUrl = getTargetUrl(config) + const payload = buildPayload(config, [ + { role: "system", content: DECONTEXTUALIZE_SYSTEM }, + { role: "user", content: `${sourceCtx}Notes to expand:\n${notesJson}` }, + ], 6000) + + const raw = await callFn(config, targetUrl, payload) + + let parsed: DecontextualizedNode[] = [] + try { parsed = JSON.parse(extractJson(raw)) } catch { /* fall through */ } + + const resultMap = new Map(parsed.map(n => [n.id, n.statement])) + return edgeMap.nodes.map(n => ({ id: n.id, statement: resultMap.get(n.id) || n.text })) +} + +// ── Call B: Clustering ──────────────────────────────────────────────────────── + +const CLUSTER_SYSTEM = `You group research notes into semantically coherent sections. + +RULES: +- Group by meaning and conceptual relationship β€” connection hints guide but do not dictate grouping +- Notes with no connections go into "General" unless they clearly fit an existing section +- Aim for 3–7 sections; merge thin sections rather than leaving singletons +- Every note ID must appear in exactly one section +- Name each section clearly (2–5 words) + +OUTPUT: You MUST return a JSON array: +[{ "sectionName": "...", "nodeIds": ["id1", "id2"] }, ...]` + +export async function callCluster( + edgeMap: EdgeMap, + config: AIConfig, + callFn: SynthesisCallFn = defaultCallAI, +): Promise { + const sourceCtx = edgeMap.sourceAnchors.length > 0 + ? `SOURCE MATERIAL:\n${edgeMap.sourceAnchors.map(a => `- ${a.text}`).join("\n")}\n\n` + : "" + + const notesJson = JSON.stringify( + edgeMap.nodes.map(n => ({ + id: n.id, + text: n.text, + category: n.category ?? "", + contentType: n.contentType, + connectedIds: n.neighborIds, + })), + null, 2, + ) + + const targetUrl = getTargetUrl(config) + const payload = buildPayload(config, [ + { role: "system", content: CLUSTER_SYSTEM }, + { role: "user", content: `${sourceCtx}Notes to cluster:\n${notesJson}` }, + ], 2000) + + const raw = await callFn(config, targetUrl, payload) + + let parsed: ClusterAssignment[] = [] + try { parsed = JSON.parse(extractJson(raw)) } catch { + return [{ sectionName: "Notes", nodeIds: edgeMap.nodes.map(n => n.id) }] + } + + const seen = new Set() + const allIds = new Set(edgeMap.nodes.map(n => n.id)) + const cleaned: ClusterAssignment[] = [] + + for (const section of parsed) { + const unique = (section.nodeIds ?? []).filter(id => allIds.has(id) && !seen.has(id)) + unique.forEach(id => seen.add(id)) + if (unique.length > 0) cleaned.push({ sectionName: section.sectionName, nodeIds: unique }) + } + + const missing = edgeMap.nodes.map(n => n.id).filter(id => !seen.has(id)) + if (missing.length > 0) { + const gi = cleaned.findIndex(s => s.sectionName === "General") + if (gi >= 0) cleaned[gi].nodeIds.push(...missing) + else cleaned.push({ sectionName: "General", nodeIds: missing }) + } + + return cleaned +} + +// ── Call CΓ—N: Per-cluster synthesis ────────────────────────────────────────── + +const CLUSTER_SYNTHESIZE_SYSTEM = `You are one of several parallel processes each generating the synthesis for one section of a multi-section document. + +IMPORTANT β€” PARALLEL PROCESSES: +Other independent processes are simultaneously generating synthesis for every other section. +You are responsible for your assigned target section ONLY. This means: +- Do NOT explain concepts from other sections, even if it would help clarify your own. + If a concept from another section is directly relevant, reference it by section name only + (e.g. "as explored in the Ideal Theory section") β€” never explain it yourself. +- Do NOT generate expounding prompts about topics already covered in other sections. +- Do NOT flag gaps that are addressed in any other section of this document. + +GROUNDING RULES: +- Use only the provided statements, annotations, and source material +- Do not draw on external knowledge +- Where notes are insufficient, flag as a gap β€” only if not addressed in another section + +For your target section, produce: +1. heading β€” a clear improved section heading (2–5 words) +2. intro β€” 1–2 sentences: what will the reader understand from this section? +3. sectionSynthesis β€” 2–4 cohesive paragraphs explaining the section as a whole. + Weave the statements AND their annotations into flowing prose. Do NOT list notes + individually β€” synthesise them into a unified explanation. Use annotations for depth. +4. expandingPrompts β€” 2–3 open questions pushing thinking into territory NOT covered + anywhere in this document. Do not ask about topics other sections address. +5. gaps β€” concepts implied by this section's notes but unexplained here AND not addressed + in any other section. Empty array if none. + +OUTPUT: Valid JSON only, no markdown, no explanation: +{ + "heading": "...", + "intro": "...", + "sectionSynthesis": "...", + "expandingPrompts": ["...", "..."], + "gaps": ["..."] +}` + +type RawClusterResult = Omit + +export async function callSynthesizeCluster( + targetCluster: ClusterAssignment, + allClusters: ClusterAssignment[], + decontextualized: DecontextualizedNode[], + edgeMap: EdgeMap, + config: AIConfig, + callFn: SynthesisCallFn = defaultCallAI, +): Promise { + const stmtMap = new Map(decontextualized.map(n => [n.id, n.statement])) + const annotMap = new Map(edgeMap.nodes.map(n => [n.id, n.annotation ?? ""])) + + const nodeSection = new Map() + for (const cluster of allClusters) { + for (const id of cluster.nodeIds) nodeSection.set(id, cluster.sectionName) + } + + const sourceCtx = edgeMap.sourceAnchors.length > 0 + ? `SOURCE MATERIAL:\n${edgeMap.sourceAnchors.map(a => `- ${a.text}`).join("\n")}\n\n` + : "" + + const documentStructure = allClusters + .map((c, i) => `Section ${i + 1}: "${c.sectionName}" (${c.nodeIds.length} notes)`) + .join("\n") + + const allNotesJson = JSON.stringify( + edgeMap.nodes.map(n => ({ + id: n.id, + section: nodeSection.get(n.id) ?? "General", + statement: stmtMap.get(n.id) ?? n.text, + annotation: annotMap.get(n.id) ?? "", + })), + null, 2, + ) + + const userMessage = [ + sourceCtx, + `FULL DOCUMENT STRUCTURE:\n${documentStructure}`, + `\nALL NOTES (statements + annotations):\n${allNotesJson}`, + `\n---\nYOUR TARGET SECTION: "${targetCluster.sectionName}"`, + `Node IDs assigned to this section: [${targetCluster.nodeIds.join(", ")}]`, + `\nGenerate the synthesis for this section only.`, + ].join("\n") + + const targetUrl = getTargetUrl(config) + const payload = buildPayload(config, [ + { role: "system", content: CLUSTER_SYNTHESIZE_SYSTEM }, + { role: "user", content: userMessage }, + ], 4000) + + const raw = await callFn(config, targetUrl, payload) + + try { + const jsonStr = extractJson(raw) + + let obj: RawClusterResult + try { + obj = JSON.parse(jsonStr) + } catch { + // LaTeX backslash fix: LLMs often emit \mathbb, \frac etc. as single backslashes + // inside JSON strings, which are invalid escape sequences. Escape any \ not already + // part of a valid JSON escape sequence and retry once. + const fixed = jsonStr.replace(/\\(?!["\\/bfnrtu])/g, "\\\\") + obj = JSON.parse(fixed) + } + + return { + heading: obj.heading ?? targetCluster.sectionName, + intro: obj.intro ?? "", + sectionSynthesis: obj.sectionSynthesis ?? "", + expandingPrompts: Array.isArray(obj.expandingPrompts) ? obj.expandingPrompts : [], + gaps: Array.isArray(obj.gaps) ? obj.gaps : [], + } + } catch { + const safeLabel = targetCluster.sectionName.replace(/\s+/g, "-").replace(/[^a-z0-9-]/gi, "") + dumpRawResponse(`synthesis-callC-${safeLabel}`, raw) + throw new Error(`Synthesis (${targetCluster.sectionName}): could not parse AI response. Raw saved to file.`) + } +} + +// ── Call D: Final editorial polish ──────────────────────────────────────────── + +const POLISH_SYSTEM = `You are performing a final editorial polish on a synthesis document. + +The document was generated section-by-section by independent parallel processes. Your job +is to refine it as a whole for coherence, flow, and consistency across sections. + +You MAY: +- Refine section headings to form a more coherent document sequence +- Adjust intro sentences to acknowledge adjacent sections where natural +- Add brief cross-references between sections where concepts connect +- Standardise terminology used inconsistently across sections +- Tighten synthesis paragraphs for clarity and cross-section flow + +You MUST NOT: +- Change any line starting with "> *Source: node" β€” these are factual attributions +- Rewrite or remove the [!example], [!question], [!note], or [!info] callout blocks +- Add information not present in the draft +- Alter factual claims in synthesis paragraphs β€” editorial refinement only + +Return the complete refined markdown document and nothing else.` + +export async function callPolish( + draftMarkdown: string, + config: AIConfig, + callFn: SynthesisCallFn = defaultCallAI, +): Promise { + const targetUrl = getTargetUrl(config) + const payload = buildPayload(config, [ + { role: "system", content: POLISH_SYSTEM }, + { role: "user", content: `Polish the following synthesis document:\n\n${draftMarkdown}` }, + ], 8000, false) + + return callFn(config, targetUrl, payload) +} + +// ── Main orchestration ──────────────────────────────────────────────────────── + +export async function generateSynthesisDocument( + blocks: TextBlock[], + onProgress: (event: ProgressEvent) => void, + callFn?: SynthesisCallFn, + injectedConfig?: AIConfig, +): Promise { + const config = injectedConfig ?? loadAIConfig() + if (!config) throw new Error("No AI provider configured. Add an API key in Settings.") + const fn = callFn ?? defaultCallAI + + const enrichedBlocks = blocks.filter(b => !b.isEnriching && !b.isError) + if (enrichedBlocks.length === 0) throw new Error("No notes to synthesise. Add some notes to the canvas first.") + + const timings: CallTiming[] = [] + + function startCall(id: string, label: string, isParallel = false): number { + const startTime = Date.now() + timings.push({ id, label, status: "running", startTime, isParallel }) + onProgress({ type: "phase_start", id, label }) + return startTime + } + + function doneCall(id: string, startTime: number) { + const durationMs = Date.now() - startTime + const t = timings.find(t => t.id === id) + if (t) { t.status = "done"; t.durationMs = durationMs } + onProgress({ type: "phase_done", id, durationMs }) + } + + function errorCall(id: string, message: string) { + const t = timings.find(t => t.id === id) + if (t) t.status = "error" + onProgress({ type: "error", id, message }) + } + + // Phase 1 β€” edge map (instant) + const edgeMap = buildEdgeMap(enrichedBlocks) + if (edgeMap.nodes.length === 0) { + throw new Error("All notes are reference nodes. Add content notes to the canvas.") + } + + // Calls A + B β€” parallel + const startA = startCall("callA", "Decontextualising notes", true) + const startB = startCall("callB", "Clustering sections", true) + + let decontextualized: DecontextualizedNode[] + let clusters: ClusterAssignment[] + + try { + ;[decontextualized, clusters] = await Promise.all([ + callDecontextualize(edgeMap, config, fn) + .then(r => { doneCall("callA", startA); return r }) + .catch(e => { errorCall("callA", String(e)); throw e }), + callCluster(edgeMap, config, fn) + .then(r => { doneCall("callB", startB); return r }) + .catch(e => { errorCall("callB", String(e)); throw e }), + ]) + } catch (e) { throw e } + + // Calls CΓ—N β€” parallel, one per cluster + const clusterStarts = clusters.map((cluster, i) => + startCall(`callC-${i}`, cluster.sectionName, true) + ) + + let sectionResults: RawClusterResult[] + try { + sectionResults = await Promise.all( + clusters.map((cluster, i) => + callSynthesizeCluster(cluster, clusters, decontextualized, edgeMap, config, fn) + .then(r => { doneCall(`callC-${i}`, clusterStarts[i]); return r }) + .catch(e => { errorCall(`callC-${i}`, String(e)); throw e }) + ) + ) + } catch (e) { throw e } + + const sections: SynthesisSection[] = clusters.map((cluster, i) => ({ + ...sectionResults[i], + nodeIds: cluster.nodeIds, + })) + + return { + outline: { sections }, + decontextualized, + clusters, + timings, + } +} + +export function getSourceAnchors(blocks: TextBlock[]): TextBlock[] { + return blocks.filter(b => b.contentType === "reference") +} diff --git a/plugin/src/ai-adapter.ts b/plugin/src/ai-adapter.ts index fe125cc..67a24e4 100644 --- a/plugin/src/ai-adapter.ts +++ b/plugin/src/ai-adapter.ts @@ -9,6 +9,7 @@ import { } from "@/lib/ai-settings" import { CONTENT_TYPE_CONFIG, type ContentType } from "@/lib/content-types" import { detectContentType } from "@/lib/detect-content-type" +import { type SynthesisCallFn } from "@/lib/synthesis" // ── Types re-exported from shared lib (avoiding "use client" import issues) ─── @@ -715,3 +716,48 @@ Return ONLY valid JSON: throw new Error("Could not parse ghost response") } } + +// ── Synthesis transport (for plugin port of Synthesis Document generation) ──── + +export function makeSynthesisCallFn(plugin: NodepadPlugin): SynthesisCallFn { + return async (config: AIConfig, targetUrl: string, payload: object) => { + // Gemini CLI: combine messages into a single prompt and spawn CLI + if (config.provider === "geminicli") { + const messages = ((payload as Record).messages ?? []) as Array<{ role: string; content: string }> + const system = messages.find(m => m.role === "system")?.content ?? "" + const user = messages.find(m => m.role === "user")?.content ?? "" + const combined = [system, user].filter(Boolean).join("\n\n---\n\n") + + console.log("[Nodepad/Gemini CLI] Stage 2: Generating synthesis...") + const result = await spawnCLI("gemini", ["--policy", "simple"], combined, 480000) + if (!result.success) throw new Error(`Gemini CLI synthesis failed: ${result.error}`) + console.log(`[Nodepad/Gemini CLI] Synthesis done (${result.content.length} chars)`) + return result.content + } + + // All other providers: requestUrl directly to the provider + const isOllama = config.provider === "ollama" + const url = isOllama ? `${getBaseUrl(config)}/api/chat` : targetUrl + + const response = await requestUrl({ + url, + method: "POST", + headers: getProviderHeaders(config), + body: JSON.stringify(payload), + contentType: "application/json", + throw: false, + }) + + if (response.status >= 400) { + throw new Error(`Synthesis AI request failed (${response.status})`) + } + + const data = response.json as Record + const content = isOllama + ? (data.message as { content?: string } | undefined)?.content + : ((data.choices as Array<{ message?: { content?: string } }> | undefined)?.[0]?.message?.content) + + if (!content) throw new Error("Empty response from AI provider") + return content + } +} diff --git a/plugin/src/main.ts b/plugin/src/main.ts index 0b86700..4e64c4a 100644 --- a/plugin/src/main.ts +++ b/plugin/src/main.ts @@ -19,6 +19,17 @@ export default class NodepadPlugin extends Plugin { callback: () => this.createNewSpace(), }) + this.addCommand({ + id: "generate-synthesis-document", + name: "Generate Synthesis Document", + checkCallback: (checking) => { + const view = this.app.workspace.getActiveViewOfType(NodepadView) + if (!view) return false + if (!checking) view.triggerSynthesis() + return true + }, + }) + this.registerEvent( this.app.workspace.on("file-menu", (menu, item) => { if (!(item instanceof TFolder)) return diff --git a/plugin/src/view.tsx b/plugin/src/view.tsx index 251eae6..6d49790 100644 --- a/plugin/src/view.tsx +++ b/plugin/src/view.tsx @@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client" import React, { useState, useCallback, useEffect, useRef, useMemo } from "react" import { motion, AnimatePresence } from "framer-motion" import type NodepadPlugin from "./main" -import { enrichBlock, generateGhost } from "./ai-adapter" +import { enrichBlock, generateGhost, getPluginAIConfig, makeSynthesisCallFn } from "./ai-adapter" import { TilingArea } from "@/components/tiling-area" import { KanbanArea } from "@/components/kanban-area" @@ -16,6 +16,20 @@ import type { TextBlock } from "@/components/tile-card" import type { ContentType } from "@/lib/content-types" import { detectContentType } from "@/lib/detect-content-type" import { exportToMarkdown, copyToClipboard } from "@/lib/export" +import { + generateSynthesisDocument, + callPolish, + getSourceAnchors, + type CallTiming, + type ProgressEvent, +} from "@/lib/synthesis" +import { + renderSynthesisDocument, + renderPolishedDocument, + slugifySynthesis, +} from "@/lib/synthesis-export" +import { SynthesisConfirmDialog } from "@/components/synthesis-confirm-dialog" +import { SynthesisProgressPanel } from "@/components/synthesis-progress-panel" export const VIEW_TYPE = "nodepad-view" @@ -105,12 +119,21 @@ export class NodepadView extends TextFileView { private root: Root | null = null readonly plugin: NodepadPlugin private fileData = "" + readonly synthesisTriggerRef: { current: (() => void) | null } = { current: null } constructor(leaf: WorkspaceLeaf, plugin: NodepadPlugin) { super(leaf) this.plugin = plugin } + triggerSynthesis() { + if (this.synthesisTriggerRef.current) { + this.synthesisTriggerRef.current() + } else { + new Notice("Nodepad: open a canvas first to generate a synthesis document.") + } + } + getViewType() { return VIEW_TYPE } getDisplayText() { return this.file?.basename ?? "Nodepad" } getIcon() { return "layout-dashboard" } @@ -157,6 +180,7 @@ export class NodepadView extends TextFileView { initialData={this.fileData} fileName={this.file?.basename} folderPath={this.file?.parent?.path} + synthesisTriggerRef={this.synthesisTriggerRef} onSave={(data) => { this.fileData = data this.requestSave() @@ -183,9 +207,10 @@ interface NodepadAppProps { onSave: (data: string) => void onMenuClick: () => void portalContainer?: HTMLElement + synthesisTriggerRef?: { current: (() => void) | null } } -function NodepadApp({ plugin, initialData, fileName, folderPath, onSave, onMenuClick, portalContainer }: NodepadAppProps) { +function NodepadApp({ plugin, initialData, fileName, folderPath, onSave, onMenuClick, portalContainer, synthesisTriggerRef }: NodepadAppProps) { const parsed = useMemo(() => parseFileData(initialData), [initialData]) const [blocks, setBlocks] = useState(parsed.blocks) @@ -197,6 +222,119 @@ function NodepadApp({ plugin, initialData, fileName, folderPath, onSave, onMenuC const [viewMode, setViewMode] = useState<"tiling" | "kanban" | "graph">(parsed.viewMode ?? "tiling") const [isGhostPanelOpen, setIsGhostPanelOpen] = useState(false) const [isIndexOpen, setIsIndexOpen] = useState(false) + + // ── Synthesis state ─────────────────────────────────────────────────────── + const [synthConfirmOpen, setSynthConfirmOpen] = useState(false) + const [synthProgressOpen, setSynthProgressOpen] = useState(false) + const [synthCalls, setSynthCalls] = useState([]) + const [synthActive, setSynthActive] = useState(false) + const [synthTotalStart, setSynthTotalStart] = useState(undefined) + const [synthSourceAnchors, setSynthSourceAnchors] = useState([]) + const synthEnablePolish = useRef(false) + + // Expose trigger to NodepadView so the command palette can open the dialog + useEffect(() => { + if (synthesisTriggerRef) { + synthesisTriggerRef.current = () => { + setSynthSourceAnchors(getSourceAnchors(blocks)) + setSynthConfirmOpen(true) + } + return () => { synthesisTriggerRef.current = null } + } + }, [synthesisTriggerRef, blocks]) + + const startSynthesis = useCallback((enablePolish: boolean) => { + synthEnablePolish.current = enablePolish + setSynthConfirmOpen(false) + setSynthCalls([]) + setSynthActive(true) + setSynthProgressOpen(false) + + const generationStart = Date.now() + setSynthTotalStart(generationStart) + + const canvasName = fileName ?? "Synthesis" + const slug = slugifySynthesis(canvasName) + const dir = folderPath ?? "" + + const config = getPluginAIConfig(plugin) + const callFn = makeSynthesisCallFn(plugin) + + const onProgress = (event: ProgressEvent) => { + setSynthCalls(prev => { + const next = [...prev] + if (event.type === "phase_start") { + next.push({ + id: event.id, label: event.label, status: "running", + startTime: Date.now(), + isParallel: event.id.startsWith("callC-") || event.id === "callA" || event.id === "callB", + }) + } else if (event.type === "phase_done") { + const t = next.find(t => t.id === event.id) + if (t) { t.status = "done"; t.durationMs = event.durationMs } + } else if (event.type === "error") { + const t = next.find(t => t.id === event.id) + if (t) t.status = "error" + } + return next + }) + } + + const writeToVault = async (filename: string, content: string) => { + let path = dir ? `${dir}/${filename}` : filename + let idx = 2 + while (plugin.app.vault.getAbstractFileByPath(path)) { + const dot = filename.lastIndexOf(".") + const base = dot >= 0 ? filename.slice(0, dot) : filename + const ext = dot >= 0 ? filename.slice(dot) : "" + path = dir ? `${dir}/${base}-${idx}${ext}` : `${base}-${idx}${ext}` + idx++ + } + const file = await plugin.app.vault.create(path, content) + await plugin.app.workspace.getLeaf("tab").openFile(file) + } + + generateSynthesisDocument(blocks, onProgress, callFn, config ?? undefined) + .then(async ({ outline, decontextualized, clusters, timings }) => { + const rawMs = Date.now() - generationStart + const rawMd = renderSynthesisDocument(canvasName, outline, decontextualized, clusters, timings, false, rawMs) + await writeToVault(`${slug}-synthesis.md`, rawMd) + + if (enablePolish && config) { + const dStart = Date.now() + setSynthCalls(prev => [...prev, { + id: "callD", label: "Final editorial polish", + status: "running", startTime: dStart, isParallel: false, + }]) + try { + const draftForPolish = renderSynthesisDocument(canvasName, outline, decontextualized, clusters, [], false) + const polishedText = await callPolish(draftForPolish, config, callFn) + const polishDuration = Date.now() - dStart + setSynthCalls(prev => prev.map(c => + c.id === "callD" ? { ...c, status: "done", durationMs: polishDuration } : c + )) + const fullTimings: CallTiming[] = [ + ...timings, + { id: "callD", label: "Final editorial polish", status: "done", durationMs: polishDuration }, + ] + const polishedMs = Date.now() - generationStart + const polishedMd = renderPolishedDocument(polishedText, fullTimings, polishedMs) + await writeToVault(`${slug}-synthesis-polished.md`, polishedMd) + } catch (e) { + setSynthCalls(prev => prev.map(c => + c.id === "callD" ? { ...c, status: "error" } : c + )) + console.error("[synthesis polish]", e) + } + } + setSynthActive(false) + }) + .catch((err: Error) => { + console.error("[synthesis]", err) + new Notice(`Synthesis failed: ${err.message}`) + setSynthActive(false) + }) + }, [blocks, fileName, folderPath, plugin]) const [isCommandKOpen, setIsCommandKOpen] = useState(false) const [highlightedBlockId, setHighlightedBlockId] = useState(null) const [undoToast, setUndoToast] = useState(null) @@ -688,6 +826,25 @@ function NodepadApp({ plugin, initialData, fileName, folderPath, onSave, onMenuC isOpen={isIndexOpen} viewMode={viewMode} /> + + {/* Synthesis dialogs β€” portalled into the plugin container */} + setSynthConfirmOpen(false)} + container={portalContainer} + /> + setSynthProgressOpen(true)} + onDialogClose={() => setSynthProgressOpen(false)} + container={portalContainer} + />
) }