story bible

This commit is contained in:
2026-03-02 22:13:09 +10:00
parent 84377c82af
commit aafebbc9fb
15 changed files with 557 additions and 77 deletions

View File

@@ -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<number>()
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<void> {
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
})

View File

@@ -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
<!-- Profile each character: role, appearance, personality, arc, key relationships -->
## World & Setting
<!-- The Basque Country: geography, historical period, cultural details, atmosphere -->
## Timeline
<!-- Key events in chronological order -->
## Themes & Motifs
<!-- Recurring symbols, imagery, thematic concerns -->
## Continuity Rules
<!-- Facts Claude must always respect: established plot points, internal logic, naming conventions -->
`
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<DraftDocument[]> {
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<void> {
await mkdir(HOHOFF_DIR, { recursive: true })
await writeFile(STORY_BIBLE_PATH, content, 'utf-8')
}
export async function readStoryBibleFile(): Promise<string | null> {
try {
return await readFile(STORY_BIBLE_PATH, 'utf-8')
} catch {
return null
}
}
async function collectMarkdownPaths(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths: string[] = []

View File

@@ -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<Attachment[]> => {
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)
}

View File

@@ -84,5 +84,11 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.invoke('fs:createDir', parentPath, name),
moveFile: (sourcePath: string, targetDirPath: string): Promise<string> =>
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<void> =>
ipcRenderer.invoke('fs:writeStoryBible', content)
})

View File

@@ -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);
}

View File

@@ -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<void> => {
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 (
<div className={`chat-message chat-message-${message.role}`}>
<div className="chat-message-label">
@@ -86,6 +108,18 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
</div>
</div>
)}
{message.bibleGeneration && message.role === 'assistant' && message.content.length > 0 && (
<div className="chat-message-apply">
<button
className="chat-apply-btn"
onClick={handleApplyToBible}
title="Replace Story Bible.md with this content"
>
Apply to Story Bible
</button>
</div>
)}
</div>
)
}

View File

@@ -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 {
</button>
</div>
{/* ── Context toggles: Story bible + Whole story ── */}
{tab === 'chat' && hasFile && (
<div className="chat-context-bar">
<button
className={`chat-project-toggle${storyBibleMode ? ' chat-project-toggle--active' : ''}`}
onClick={() => setStoryBibleMode(!storyBibleMode)}
title={storyBibleMode ? 'Story bible on — Claude sees your reference notes' : 'Enable story bible context'}
>
{storyBibleMode ? '◉' : '○'} Story bible
</button>
<button
className={`chat-project-toggle${wholeStoryMode ? ' chat-project-toggle--active' : ''}`}
onClick={() => setWholeStoryMode(!wholeStoryMode)}
title={wholeStoryMode ? 'Whole story on — Claude sees all chapters' : 'Enable whole story context'}
>
{wholeStoryMode ? '◉' : '○'} Whole story
</button>
</div>
)}
{/* ── Chat sub-header: History link + New Chat button ── */}
{showSubheader && (
<div className="chat-subheader">

View File

@@ -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;

View File

@@ -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<void> => {
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 (
<nav className="file-tree">
<div className="file-tree-header">HOHOFF</div>
<div className="file-tree-header">
<span>HOHOFF</span>
<button
className="file-tree-bible-btn"
onClick={handleOpenStoryBible}
title="Open Story Bible"
>
📖
</button>
</div>
<div className="file-tree-list">
{fileTree.map((node) => (
<FileTreeNode

View File

@@ -5,6 +5,33 @@ import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
import { tooltipAnalysisCache } from '../Editor/MarkdownEditor'
import './Toolbar.css'
const BIBLE_PROMPTS = {
full: `Read the full manuscript and generate comprehensive story bible content as markdown. Include:
## Characters
[Detailed profile for every named character: role, physical description, personality, key relationships, arc]
## World & Setting
[Geography, historical period, Basque Country cultural details, atmosphere, key locations]
## Timeline
[Key events in chronological order with chapter references]
## Themes & Motifs
[Recurring symbols, imagery, thematic concerns]
## Continuity Rules
[Facts that must stay consistent: character details, established plot points, internal logic]
Base everything strictly on what is in the manuscript text.`,
characters: `Read the full manuscript and write character profiles for the Story Bible's Characters section. For each named character include: role, physical description, personality traits, key relationships, and arc. Format as markdown subsections (### Name).`,
timeline: `Read the full manuscript and extract all significant events in chronological order for the Story Bible Timeline section. Reference chapters where helpful. Format as a markdown numbered list.`,
world: `Read the full manuscript and write a World & Setting entry for the Story Bible. Cover: the Basque Country geography and atmosphere, the historical period and cultural context, and key locations described in the text. Format as markdown.`
}
function countWords(text: string): number {
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
}
@@ -35,7 +62,8 @@ export function AnalysisToolbar(): JSX.Element {
projectWordCount,
setProjectWordCount,
fontSize,
setFontSize
setFontSize,
setRightPanelTab
} = useEditorStore()
useEffect(() => {
@@ -50,6 +78,40 @@ export function AnalysisToolbar(): JSX.Element {
}, [isDirty])
const hasFile = Boolean(activeFilePath)
const isStoryBible = activeFilePath?.endsWith('Story Bible.md') ?? false
const runBibleGeneration = async (prompt: string): Promise<void> => {
if (!activeFilePath || isAILoading) return
setAIError(null)
setRightPanelTab('chat')
addUserMessage(prompt)
startAssistantMessage({ bibleGeneration: true })
setAILoading(true)
try {
await window.api.streamAIMessage(
{
mode: 'chat',
documentContent: activeFileContent,
documentPath: activeFilePath,
conversationHistory: chatHistory
.slice(-10)
.map((m) => ({ role: m.role, content: m.content })),
userMessage: prompt,
projectMode: true,
storyBibleMode: false
},
(chunk: string) => {
appendToLastAssistantMessage(chunk)
}
)
} catch (err) {
setAIError(err instanceof Error ? err.message : 'Generation failed')
} finally {
setAILoading(false)
}
}
const runPassiveVoice = (): void => {
if (!hasFile) return
@@ -125,74 +187,114 @@ export function AnalysisToolbar(): JSX.Element {
return (
<div className="toolbar">
<div className="toolbar-left">
<button
className={`toolbar-btn${analysisMode === 'passive_voice' ? ' active' : ''}`}
onClick={runPassiveVoice}
disabled={!hasFile}
title="Highlight passive voice sentences instantly (no AI required)"
>
Passive Voice
{passiveCount > 0 && (
<span className="toolbar-badge">{passiveCount}</span>
)}
</button>
{isStoryBible ? (
<>
<span className="toolbar-bible-label">Generate from manuscript:</span>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.full)}
disabled={isAILoading}
title="Generate all story bible sections from the full manuscript"
>
{isAILoading ? 'Generating…' : 'Full bible'}
</button>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.characters)}
disabled={isAILoading}
title="Extract character profiles from the manuscript"
>
Characters
</button>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.timeline)}
disabled={isAILoading}
title="Extract timeline of events from the manuscript"
>
Timeline
</button>
<button
className="toolbar-btn toolbar-btn-bible"
onClick={() => runBibleGeneration(BIBLE_PROMPTS.world)}
disabled={isAILoading}
title="Extract world & setting details from the manuscript"
>
World
</button>
</>
) : (
<>
<button
className={`toolbar-btn${analysisMode === 'passive_voice' ? ' active' : ''}`}
onClick={runPassiveVoice}
disabled={!hasFile}
title="Highlight passive voice sentences instantly (no AI required)"
>
Passive Voice
{passiveCount > 0 && (
<span className="toolbar-badge">{passiveCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
onClick={() => runAIAnalysis('consistency')}
disabled={!hasFile || isAILoading}
title="Check character names, timeline, and repeated phrases via AI"
>
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
{consistencyCount > 0 && (
<span className="toolbar-badge">{consistencyCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
onClick={() => runAIAnalysis('consistency')}
disabled={!hasFile || isAILoading}
title="Check character names, timeline, and repeated phrases via AI"
>
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
{consistencyCount > 0 && (
<span className="toolbar-badge">{consistencyCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
onClick={() => runAIAnalysis('style')}
disabled={!hasFile || isAILoading}
title="Pacing, sentence variety, show-don't-tell feedback via AI"
>
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
{styleCount > 0 && (
<span className="toolbar-badge">{styleCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
onClick={() => runAIAnalysis('style')}
disabled={!hasFile || isAILoading}
title="Pacing, sentence variety, show-don't-tell feedback via AI"
>
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
{styleCount > 0 && (
<span className="toolbar-badge">{styleCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'show_tell' ? ' active' : ''}`}
onClick={() => runAIAnalysis('show_tell')}
disabled={!hasFile || isAILoading}
title="Find passages that tell rather than show via AI"
>
{isAILoading && analysisMode === 'show_tell' ? 'Reading…' : 'Show vs Tell'}
{showTellCount > 0 && (
<span className="toolbar-badge">{showTellCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'show_tell' ? ' active' : ''}`}
onClick={() => runAIAnalysis('show_tell')}
disabled={!hasFile || isAILoading}
title="Find passages that tell rather than show via AI"
>
{isAILoading && analysisMode === 'show_tell' ? 'Reading…' : 'Show vs Tell'}
{showTellCount > 0 && (
<span className="toolbar-badge">{showTellCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'critique' ? ' active' : ''}`}
onClick={() => runAIAnalysis('critique')}
disabled={!hasFile || isAILoading}
title="Honest overall critique of this chapter via AI"
>
{isAILoading && analysisMode === 'critique' ? 'Reading…' : 'Critique'}
{critiqueCount > 0 && (
<span className="toolbar-badge">{critiqueCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'critique' ? ' active' : ''}`}
onClick={() => runAIAnalysis('critique')}
disabled={!hasFile || isAILoading}
title="Honest overall critique of this chapter via AI"
>
{isAILoading && analysisMode === 'critique' ? 'Reading…' : 'Critique'}
{critiqueCount > 0 && (
<span className="toolbar-badge">{critiqueCount}</span>
)}
</button>
{annotations.length > 0 && (
<button
className="toolbar-btn toolbar-btn-clear"
onClick={() => { clearAnnotations(); tooltipAnalysisCache.clear() }}
title="Remove all highlights"
>
Clear
</button>
{annotations.length > 0 && (
<button
className="toolbar-btn toolbar-btn-clear"
onClick={() => { clearAnnotations(); tooltipAnalysisCache.clear() }}
title="Remove all highlights"
>
Clear
</button>
)}
</>
)}
</div>

View File

@@ -140,3 +140,24 @@
margin: 0 3px;
opacity: 0.5;
}
/* ── Story Bible toolbar ─────────────────────────────────────────── */
.toolbar-bible-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
margin-right: 2px;
align-self: center;
}
.toolbar-btn-bible {
color: var(--accent);
}
.toolbar-btn-bible:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}

View File

@@ -26,7 +26,7 @@ interface EditorState {
isAILoading: boolean
aiError: string | null
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
startAssistantMessage: () => void
startAssistantMessage: (opts?: { bibleGeneration?: boolean }) => void
appendToLastAssistantMessage: (chunk: string) => void
setAILoading: (loading: boolean) => void
setAIError: (error: string | null) => void
@@ -71,6 +71,14 @@ interface EditorState {
theme: 'dark' | 'light'
toggleTheme: () => void
// Whole story context mode
wholeStoryMode: boolean
setWholeStoryMode: (enabled: boolean) => void
// Story bible context mode
storyBibleMode: boolean
setStoryBibleMode: (enabled: boolean) => void
// Revision panel
revisionPanelOpen: boolean
toggleRevisionPanel: () => void
@@ -203,8 +211,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
})
},
startAssistantMessage: () => {
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' }
startAssistantMessage: (opts?) => {
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '', bibleGeneration: opts?.bibleGeneration }
set((s) => {
const history = [...s.chatHistory, msg]
if (!s.activeFilePath) return { chatHistory: history }
@@ -542,6 +550,18 @@ export const useEditorStore = create<EditorState>((set, get) => ({
})
},
wholeStoryMode: localStorage.getItem('wholeStoryMode') === 'true',
setWholeStoryMode: (enabled: boolean) => {
localStorage.setItem('wholeStoryMode', String(enabled))
set({ wholeStoryMode: enabled })
},
storyBibleMode: localStorage.getItem('storyBibleMode') === 'true',
setStoryBibleMode: (enabled: boolean) => {
localStorage.setItem('storyBibleMode', String(enabled))
set({ storyBibleMode: enabled })
},
loadSession: async () => {
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
if (!api) return

View File

@@ -20,6 +20,7 @@ export interface ChatMessage {
content: string
attachments?: AttachmentMeta[] // metadata only — stored in history for display
annotationIds?: string[] // IDs of suggestions this message produced
bibleGeneration?: boolean // when true, show "Apply to Story Bible" button on assistant message
}
export interface ChatSession {
@@ -55,6 +56,8 @@ export interface AIPayload {
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
userMessage: string
attachments?: Attachment[] // full data for current API call only
projectMode?: boolean // when true, main process injects all draft files as context
storyBibleMode?: boolean // when true, main process injects Story Bible.md as context
}
export interface RevisionMeta {

View File

@@ -23,6 +23,8 @@ declare global {
createFile: (parentPath: string, name: string) => Promise<string>
createDir: (parentPath: string, name: string) => Promise<string>
moveFile: (sourcePath: string, targetDirPath: string) => Promise<string>
openStoryBible: () => Promise<{ path: string; content: string }>
writeStoryBible: (content: string) => Promise<void>
}
}
}

File diff suppressed because one or more lines are too long