✨ story bible
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
2
src/renderer/types/global.d.ts
vendored
2
src/renderer/types/global.d.ts
vendored
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user