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

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