🐛 don't overwrite story bible with new suggestions
This commit is contained in:
@@ -190,9 +190,75 @@ export async function openStoryBibleFile(): Promise<{ path: string; content: str
|
|||||||
return { path: STORY_BIBLE_PATH, content }
|
return { path: STORY_BIBLE_PATH, content }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeStoryBibleFile(content: string): Promise<void> {
|
// Parse a markdown document into a preamble (text before first ## heading) and
|
||||||
|
// an ordered list of { header, body } sections delimited by ## headings.
|
||||||
|
function parseSections(content: string): { preamble: string; sections: { header: string; body: string }[] } {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const preamble: string[] = []
|
||||||
|
const sections: { header: string; body: string[] }[] = []
|
||||||
|
let current: { header: string; body: string[] } | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
if (current) sections.push({ header: current.header, body: current.body })
|
||||||
|
current = { header: line, body: [] }
|
||||||
|
} else if (current === null) {
|
||||||
|
preamble.push(line)
|
||||||
|
} else {
|
||||||
|
current.body.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) sections.push({ header: current.header, body: current.body })
|
||||||
|
|
||||||
|
return {
|
||||||
|
preamble: preamble.join('\n').trimEnd(),
|
||||||
|
sections: sections.map(s => ({ header: s.header, body: s.body.join('\n').trimEnd() }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge incoming content into existing by ## section.
|
||||||
|
// Sections present in incoming replace the matching section in existing.
|
||||||
|
// New sections (in incoming but not existing) are appended.
|
||||||
|
// Sections only in existing are preserved unchanged.
|
||||||
|
export function mergeStoryBibleContent(existing: string, incoming: string): string {
|
||||||
|
const { preamble, sections: existingSections } = parseSections(existing)
|
||||||
|
const { sections: incomingSections } = parseSections(incoming)
|
||||||
|
|
||||||
|
const updatedBodies = new Map(existingSections.map(s => [s.header, s.body]))
|
||||||
|
const existingHeaders = new Set(existingSections.map(s => s.header))
|
||||||
|
|
||||||
|
for (const { header, body } of incomingSections) {
|
||||||
|
updatedBodies.set(header, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing sections in original order (with updated bodies), then new ones
|
||||||
|
const result: string[] = [preamble || '# Story Bible']
|
||||||
|
for (const { header } of existingSections) {
|
||||||
|
const body = updatedBodies.get(header) ?? ''
|
||||||
|
result.push('', header)
|
||||||
|
if (body) result.push('', body)
|
||||||
|
}
|
||||||
|
for (const { header, body } of incomingSections) {
|
||||||
|
if (!existingHeaders.has(header)) {
|
||||||
|
result.push('', header)
|
||||||
|
if (body) result.push('', body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeStoryBibleFile(content: string): Promise<string> {
|
||||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||||
await writeFile(STORY_BIBLE_PATH, content, 'utf-8')
|
let existing: string
|
||||||
|
try {
|
||||||
|
existing = await readFile(STORY_BIBLE_PATH, 'utf-8')
|
||||||
|
} catch {
|
||||||
|
existing = STORY_BIBLE_TEMPLATE
|
||||||
|
}
|
||||||
|
const merged = mergeStoryBibleContent(existing, content)
|
||||||
|
await writeFile(STORY_BIBLE_PATH, merged, 'utf-8')
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readStoryBibleFile(): Promise<string | null> {
|
export async function readStoryBibleFile(): Promise<string | null> {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function registerIpcHandlers(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:writeStoryBible', async (_event, content: string) => {
|
ipcMain.handle('fs:writeStoryBible', async (_event, content: string) => {
|
||||||
await writeStoryBibleFile(content)
|
return await writeStoryBibleFile(content)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
||||||
|
|||||||
@@ -89,6 +89,6 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
openStoryBible: (): Promise<{ path: string; content: string }> =>
|
openStoryBible: (): Promise<{ path: string; content: string }> =>
|
||||||
ipcRenderer.invoke('fs:openStoryBible'),
|
ipcRenderer.invoke('fs:openStoryBible'),
|
||||||
|
|
||||||
writeStoryBible: (content: string): Promise<void> =>
|
writeStoryBible: (content: string): Promise<string> =>
|
||||||
ipcRenderer.invoke('fs:writeStoryBible', content)
|
ipcRenderer.invoke('fs:writeStoryBible', content)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,18 +40,16 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
|
|||||||
}, [message.role, message.content])
|
}, [message.role, message.content])
|
||||||
|
|
||||||
const handleApplyToBible = async (): Promise<void> => {
|
const handleApplyToBible = async (): Promise<void> => {
|
||||||
await window.api.writeStoryBible(message.content)
|
// writeStoryBible merges the new content into the existing bible by ## section
|
||||||
// If the story bible is currently open, update the editor directly.
|
// and returns the full merged document.
|
||||||
// setActiveFile(samePath, …) doesn't trigger the MarkdownEditor's sync effect
|
const merged = await window.api.writeStoryBible(message.content)
|
||||||
// (which only watches activeFilePath changes), so we use currentEditorView
|
// If the story bible is currently open, update the editor to show the merged result.
|
||||||
// instead — the same pattern used by RevisionPanel.restore().
|
|
||||||
if (activeFilePath?.endsWith('Story Bible.md')) {
|
if (activeFilePath?.endsWith('Story Bible.md')) {
|
||||||
const view = currentEditorView
|
const view = currentEditorView
|
||||||
if (view) {
|
if (view) {
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: message.content }
|
changes: { from: 0, to: view.state.doc.length, insert: merged }
|
||||||
})
|
})
|
||||||
// File is already saved — clear the dirty flag the dispatch just set
|
|
||||||
markSaved()
|
markSaved()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +114,7 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
|
|||||||
<button
|
<button
|
||||||
className="chat-apply-btn"
|
className="chat-apply-btn"
|
||||||
onClick={handleApplyToBible}
|
onClick={handleApplyToBible}
|
||||||
title="Replace Story Bible.md with this content"
|
title="Merge into Story Bible"
|
||||||
>
|
>
|
||||||
↓ Apply to Story Bible
|
↓ Apply to Story Bible
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ const BIBLE_PROMPTS = {
|
|||||||
|
|
||||||
Base everything strictly on what is in the manuscript text.`,
|
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).`,
|
characters: `Read the full manuscript and write character profiles for the Story Bible. Begin your response with the heading \`## Characters\` followed by a blank line. For each named character include: role, physical description, personality traits, key relationships, and arc. Format each character as a ### subsection.`,
|
||||||
|
|
||||||
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.`,
|
timeline: `Read the full manuscript and extract all significant events in chronological order for the Story Bible. Begin your response with the heading \`## Timeline\` followed by a blank line. Reference chapters where helpful. Format as a 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.`
|
world: `Read the full manuscript and write a World & Setting section for the Story Bible. Begin your response with the heading \`## World & Setting\` followed by a blank line. Cover: the Basque Country geography and atmosphere, the historical period and cultural context, and key locations described in the text.`
|
||||||
}
|
}
|
||||||
|
|
||||||
function countWords(text: string): number {
|
function countWords(text: string): number {
|
||||||
@@ -98,9 +98,7 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
conversationHistory: chatHistory
|
conversationHistory: chatHistory
|
||||||
.slice(-10)
|
.slice(-10)
|
||||||
.map((m) => ({ role: m.role, content: m.content })),
|
.map((m) => ({ role: m.role, content: m.content })),
|
||||||
userMessage: prompt,
|
userMessage: prompt
|
||||||
projectMode: true,
|
|
||||||
storyBibleMode: false
|
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
appendToLastAssistantMessage(chunk)
|
appendToLastAssistantMessage(chunk)
|
||||||
|
|||||||
2
src/renderer/types/global.d.ts
vendored
2
src/renderer/types/global.d.ts
vendored
@@ -24,7 +24,7 @@ declare global {
|
|||||||
createDir: (parentPath: string, name: string) => Promise<string>
|
createDir: (parentPath: string, name: string) => Promise<string>
|
||||||
moveFile: (sourcePath: string, targetDirPath: string) => Promise<string>
|
moveFile: (sourcePath: string, targetDirPath: string) => Promise<string>
|
||||||
openStoryBible: () => Promise<{ path: string; content: string }>
|
openStoryBible: () => Promise<{ path: string; content: string }>
|
||||||
writeStoryBible: (content: string) => Promise<void>
|
writeStoryBible: (content: string) => Promise<string>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user