✨ 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user