From aafebbc9fbe36bee45901e27e51f7d3f81e4b4fc Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Mon, 2 Mar 2026 22:13:09 +1000 Subject: [PATCH] :sparkles: story bible --- src/main/aiService.ts | 76 +++++- src/main/fileSystem.ts | 79 ++++++ src/main/ipcHandlers.ts | 14 +- src/preload/index.ts | 8 +- src/renderer/components/AIChat/Chat.css | 56 +++++ .../components/AIChat/ChatMessageItem.tsx | 34 +++ src/renderer/components/AIChat/ChatPanel.tsx | 28 ++- src/renderer/components/FileTree/FileTree.css | 19 ++ src/renderer/components/FileTree/FileTree.tsx | 36 ++- .../components/Toolbar/AnalysisToolbar.tsx | 230 +++++++++++++----- src/renderer/components/Toolbar/Toolbar.css | 21 ++ src/renderer/store/editorStore.ts | 26 +- src/renderer/types/editor.ts | 3 + src/renderer/types/global.d.ts | 2 + tsconfig.node.tsbuildinfo | 2 +- 15 files changed, 557 insertions(+), 77 deletions(-) diff --git a/src/main/aiService.ts b/src/main/aiService.ts index 8c84b7c..62ff28a 100644 --- a/src/main/aiService.ts +++ b/src/main/aiService.ts @@ -1,5 +1,6 @@ import Anthropic from '@anthropic-ai/sdk' import type { AIPayload, Attachment } from '../renderer/types/editor' +import type { DraftDocument } from './fileSystem' let _client: Anthropic | null = null @@ -16,11 +17,78 @@ if (!apiKey || apiKey === 'your-api-key-here') { return _client } -function buildSystemPrompt(payload: AIPayload): string { +const MAX_MANUSCRIPT_CHARS = 560_000 // ~140k tokens at 4 chars/token + +function buildStoryBibleBlock(content: string): string { + return [ + '=== STORY BIBLE ===', + "The following is the author's curated reference document. Treat it as authoritative ground truth for characters, world, timeline, and consistency rules.", + '', + content, + '=== END STORY BIBLE ===' + ].join('\n') +} + +function buildManuscriptBlock(allDocs: DraftDocument[], currentPath: string): string { + const total = allDocs.length + const totalChars = allDocs.reduce((s, d) => s + d.content.length, 0) + let docs = allDocs + let truncatedCount = 0 + + if (totalChars > MAX_MANUSCRIPT_CHARS) { + const currentIdx = allDocs.findIndex(d => d.path === currentPath) + const pivot = currentIdx !== -1 ? currentIdx : 0 + const priority: number[] = [pivot] + let lo = pivot - 1 + let hi = pivot + 1 + while (lo >= 0 || hi < allDocs.length) { + if (hi < allDocs.length) priority.push(hi++) + if (lo >= 0) priority.push(lo--) + } + let budget = MAX_MANUSCRIPT_CHARS + const included = new Set() + for (const idx of priority) { + if (budget <= 0) break + if (budget - allDocs[idx].content.length >= 0) { + budget -= allDocs[idx].content.length + included.add(idx) + } + } + truncatedCount = allDocs.length - included.size + docs = allDocs.filter((_, i) => included.has(i)) + } + + const lines: string[] = [ + '=== MANUSCRIPT: Full Story Context ===', + `The following ${docs.length} chapter(s) contain the novel in narrative order.`, + 'Use them for cross-chapter analysis: consistency, character arcs, narrative structure.', + '' + ] + for (let i = 0; i < docs.length; i++) { + lines.push(`--- [${i + 1}/${total}] ${docs[i].relativePath} ---`) + lines.push(docs[i].content) + lines.push('') + } + if (truncatedCount > 0) { + lines.push(`[Note: ${truncatedCount} chapter(s) omitted to fit context. Chapters closest to the current chapter were prioritised.]`) + } + lines.push('=== END MANUSCRIPT ===') + return lines.join('\n') +} + +function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyBibleContent?: string): string { const chapterName = payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter' - const chapterContext = `You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}". + const storyBibleSection = storyBibleContent + ? buildStoryBibleBlock(storyBibleContent) + '\n\n' + : '' + + const manuscriptSection = allDocs + ? buildManuscriptBlock(allDocs, payload.documentPath) + '\n\n' + : '' + + const chapterContext = `${storyBibleSection}${manuscriptSection}You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}". Key characters: Esti, Marko, Garbi, Irati, Cardinal Nikolai, Amaya, Izotz, Sua, Señor Jiménez, the Genboa family. @@ -165,6 +233,8 @@ function buildUserContent( export async function streamMessage( payload: AIPayload, + allDocs: DraftDocument[] | undefined, + storyBibleContent: string | undefined, onChunk: (chunk: string) => void ): Promise { const client = getClient() @@ -180,7 +250,7 @@ export async function streamMessage( const stream = client.messages.stream({ model: 'claude-sonnet-4-5', max_tokens: 4096, - system: buildSystemPrompt(payload), + system: buildSystemPrompt(payload, allDocs, storyBibleContent), messages }) diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index 28c507e..ffd9185 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -8,6 +8,31 @@ const DRAFT_ROOT = const ORDER_FILE = join(DRAFT_ROOT, '.order.json') const SESSION_FILE = join(DRAFT_ROOT, '.session.json') const REVISIONS_DIR = join(DRAFT_ROOT, '.revisions') +const HOHOFF_DIR = join(DRAFT_ROOT, '.hohoff') +export const STORY_BIBLE_PATH = join(HOHOFF_DIR, 'Story Bible.md') + +const STORY_BIBLE_TEMPLATE = `# Story Bible + +## Characters + + + +## World & Setting + + + +## Timeline + + + +## Themes & Motifs + + + +## Continuity Rules + + +` const MAX_REVISIONS = 50 @@ -122,6 +147,60 @@ function countWords(text: string): number { return text.trim() === '' ? 0 : text.trim().split(/\s+/).length } +export interface DraftDocument { + path: string + relativePath: string // e.g. "Part I/Chapter 1" + content: string +} + +function flattenFileNodes(nodes: FileNode[]): string[] { + const paths: string[] = [] + for (const node of nodes) { + if (node.type === 'file') paths.push(node.path) + else if (node.children) paths.push(...flattenFileNodes(node.children)) + } + return paths +} + +export async function readAllDraftFiles(): Promise { + const tree = await listDraftFiles() + // Exclude Story Bible.md — it is injected separately via readStoryBibleFile() + const paths = flattenFileNodes(tree).filter(p => p !== STORY_BIBLE_PATH) + const prefix = DRAFT_ROOT + '/' + return Promise.all( + paths.map(async (p) => ({ + path: p, + relativePath: (p.startsWith(prefix) ? p.slice(prefix.length) : p).replace(/\.md$/, ''), + content: await readFile(p, 'utf-8') + })) + ) +} + +export async function openStoryBibleFile(): Promise<{ path: string; content: string }> { + await mkdir(HOHOFF_DIR, { recursive: true }) + let content: string + try { + content = await readFile(STORY_BIBLE_PATH, 'utf-8') + } catch { + content = STORY_BIBLE_TEMPLATE + await writeFile(STORY_BIBLE_PATH, content, 'utf-8') + } + return { path: STORY_BIBLE_PATH, content } +} + +export async function writeStoryBibleFile(content: string): Promise { + await mkdir(HOHOFF_DIR, { recursive: true }) + await writeFile(STORY_BIBLE_PATH, content, 'utf-8') +} + +export async function readStoryBibleFile(): Promise { + try { + return await readFile(STORY_BIBLE_PATH, 'utf-8') + } catch { + return null + } +} + async function collectMarkdownPaths(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }) const paths: string[] = [] diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index ffa98ee..db2df13 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -1,7 +1,7 @@ import { ipcMain, dialog, BrowserWindow } from 'electron' import { readFileSync } from 'fs' import { extname, basename } from 'path' -import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir } from './fileSystem' +import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readAllDraftFiles, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile } from './fileSystem' import { streamMessage } from './aiService' import type { AIPayload, Attachment } from '../renderer/types/editor' @@ -70,6 +70,14 @@ export function registerIpcHandlers(): void { return await moveFileOrDir(sourcePath, targetDirPath) }) + ipcMain.handle('fs:openStoryBible', async () => { + return await openStoryBibleFile() + }) + + ipcMain.handle('fs:writeStoryBible', async (_event, content: string) => { + await writeStoryBibleFile(content) + }) + ipcMain.handle('fs:pickAttachments', async (event): Promise => { const win = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(win!, { @@ -115,7 +123,9 @@ export function registerIpcHandlers(): void { ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { try { - await streamMessage(payload, (chunk: string) => { + const allDocs = payload.projectMode ? await readAllDraftFiles() : undefined + const storyBibleContent = payload.storyBibleMode ? (await readStoryBibleFile() ?? undefined) : undefined + await streamMessage(payload, allDocs, storyBibleContent, (chunk: string) => { if (!event.sender.isDestroyed()) { event.sender.send('ai:chunk', chunk) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 12d2978..5578297 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -84,5 +84,11 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.invoke('fs:createDir', parentPath, name), moveFile: (sourcePath: string, targetDirPath: string): Promise => - ipcRenderer.invoke('fs:move', sourcePath, targetDirPath) + ipcRenderer.invoke('fs:move', sourcePath, targetDirPath), + + openStoryBible: (): Promise<{ path: string; content: string }> => + ipcRenderer.invoke('fs:openStoryBible'), + + writeStoryBible: (content: string): Promise => + ipcRenderer.invoke('fs:writeStoryBible', content) }) diff --git a/src/renderer/components/AIChat/Chat.css b/src/renderer/components/AIChat/Chat.css index 53db585..8d65158 100644 --- a/src/renderer/components/AIChat/Chat.css +++ b/src/renderer/components/AIChat/Chat.css @@ -77,6 +77,40 @@ flex-shrink: 0; } +/* ── Whole story context bar ─────────────────────────────────────── */ +.chat-context-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.chat-project-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 3px 8px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.chat-project-toggle:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.chat-project-toggle--active { + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + /* ── Chat sub-header (History link + New Chat button) ────────────── */ .chat-subheader { display: flex; @@ -634,3 +668,25 @@ padding: 1px 5px; flex-shrink: 0; } + +/* ── Apply-to-bible action ───────────────────────────────────────── */ +.chat-message-apply { + margin-top: 10px; +} + +.chat-apply-btn { + background: none; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--accent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 4px 10px; + cursor: pointer; + transition: background 0.15s; +} + +.chat-apply-btn:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); +} diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index 8afcaf7..e974dec 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -2,6 +2,8 @@ import { useMemo } from 'react' import { marked } from 'marked' import DOMPurify from 'dompurify' import type { ChatMessage, TextAnnotation } from '../../types/editor' +import { useEditorStore } from '../../store/editorStore' +import { currentEditorView } from '../Editor/MarkdownEditor' marked.setOptions({ breaks: true }) @@ -27,12 +29,32 @@ interface Props { } export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Element { + const { activeFilePath, markSaved } = useEditorStore() + const html = useMemo(() => { if (message.role !== 'assistant' || !message.content) return null const raw = marked.parse(message.content) as string return DOMPurify.sanitize(raw) }, [message.role, message.content]) + const handleApplyToBible = async (): Promise => { + await window.api.writeStoryBible(message.content) + // If the story bible is currently open, update the editor directly. + // setActiveFile(samePath, …) doesn't trigger the MarkdownEditor's sync effect + // (which only watches activeFilePath changes), so we use currentEditorView + // instead — the same pattern used by RevisionPanel.restore(). + if (activeFilePath?.endsWith('Story Bible.md')) { + const view = currentEditorView + if (view) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: message.content } + }) + // File is already saved — clear the dirty flag the dispatch just set + markSaved() + } + } + } + return (
@@ -86,6 +108,18 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
)} + + {message.bibleGeneration && message.role === 'assistant' && message.content.length > 0 && ( +
+ +
+ )} ) } diff --git a/src/renderer/components/AIChat/ChatPanel.tsx b/src/renderer/components/AIChat/ChatPanel.tsx index c382dd8..7c6aa46 100644 --- a/src/renderer/components/AIChat/ChatPanel.tsx +++ b/src/renderer/components/AIChat/ChatPanel.tsx @@ -42,6 +42,10 @@ export function ChatPanel(): JSX.Element { setActiveSession, rightPanelTab, setRightPanelTab, + wholeStoryMode, + setWholeStoryMode, + storyBibleMode, + setStoryBibleMode, } = useEditorStore() const tab = rightPanelTab @@ -85,7 +89,9 @@ export function ChatPanel(): JSX.Element { .slice(-10) .map((m) => ({ role: m.role, content: m.content })), userMessage: text, - attachments: attachments.length > 0 ? attachments : undefined + attachments: attachments.length > 0 ? attachments : undefined, + projectMode: wholeStoryMode, + storyBibleMode: storyBibleMode }, (chunk: string) => { appendToLastAssistantMessage(chunk) @@ -158,6 +164,26 @@ export function ChatPanel(): JSX.Element { + {/* ── Context toggles: Story bible + Whole story ── */} + {tab === 'chat' && hasFile && ( +
+ + +
+ )} + {/* ── Chat sub-header: History link + New Chat button ── */} {showSubheader && (
diff --git a/src/renderer/components/FileTree/FileTree.css b/src/renderer/components/FileTree/FileTree.css index 94340f4..03504bf 100644 --- a/src/renderer/components/FileTree/FileTree.css +++ b/src/renderer/components/FileTree/FileTree.css @@ -6,6 +6,9 @@ } .file-tree-header { + display: flex; + align-items: center; + justify-content: space-between; padding: 14px 12px 10px; font-size: 11px; font-weight: 700; @@ -15,6 +18,22 @@ text-transform: uppercase; } +.file-tree-bible-btn { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 2px; + opacity: 0.55; + transition: opacity 0.15s; + flex-shrink: 0; +} + +.file-tree-bible-btn:hover { + opacity: 1; +} + .file-tree-list { overflow-y: auto; flex: 1; diff --git a/src/renderer/components/FileTree/FileTree.tsx b/src/renderer/components/FileTree/FileTree.tsx index b476fdf..ff3b36d 100644 --- a/src/renderer/components/FileTree/FileTree.tsx +++ b/src/renderer/components/FileTree/FileTree.tsx @@ -1,13 +1,45 @@ import { useEditorStore } from '../../store/editorStore' +import { currentEditorView } from '../Editor/MarkdownEditor' import { FileTreeNode } from './FileTreeNode' import './FileTree.css' export function FileTree(): JSX.Element { - const { fileTree } = useEditorStore() + const { fileTree, activeFilePath, setActiveFile, markSaved, setStoryBibleMode } = useEditorStore() + + const handleOpenStoryBible = async (): Promise => { + try { + const { path, content } = await window.api.openStoryBible() + setActiveFile(path, content) + setStoryBibleMode(true) + // If the path didn't change (already on Story Bible), the MarkdownEditor's + // activeFilePath-keyed effect won't fire. Push content directly, same + // pattern as RevisionPanel.restore(). + if (path === activeFilePath) { + const view = currentEditorView + if (view) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: content } + }) + markSaved() + } + } + } catch (err) { + console.error('[FileTree] openStoryBible failed:', err) + } + } return (