✨ story bible
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user