✨ story bible
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||||
|
import type { DraftDocument } from './fileSystem'
|
||||||
|
|
||||||
let _client: Anthropic | null = null
|
let _client: Anthropic | null = null
|
||||||
|
|
||||||
@@ -16,11 +17,78 @@ if (!apiKey || apiKey === 'your-api-key-here') {
|
|||||||
return _client
|
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 =
|
const chapterName =
|
||||||
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
|
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.
|
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(
|
export async function streamMessage(
|
||||||
payload: AIPayload,
|
payload: AIPayload,
|
||||||
|
allDocs: DraftDocument[] | undefined,
|
||||||
|
storyBibleContent: string | undefined,
|
||||||
onChunk: (chunk: string) => void
|
onChunk: (chunk: string) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const client = getClient()
|
const client = getClient()
|
||||||
@@ -180,7 +250,7 @@ export async function streamMessage(
|
|||||||
const stream = client.messages.stream({
|
const stream = client.messages.stream({
|
||||||
model: 'claude-sonnet-4-5',
|
model: 'claude-sonnet-4-5',
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
system: buildSystemPrompt(payload),
|
system: buildSystemPrompt(payload, allDocs, storyBibleContent),
|
||||||
messages
|
messages
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ const DRAFT_ROOT =
|
|||||||
const ORDER_FILE = join(DRAFT_ROOT, '.order.json')
|
const ORDER_FILE = join(DRAFT_ROOT, '.order.json')
|
||||||
const SESSION_FILE = join(DRAFT_ROOT, '.session.json')
|
const SESSION_FILE = join(DRAFT_ROOT, '.session.json')
|
||||||
const REVISIONS_DIR = join(DRAFT_ROOT, '.revisions')
|
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
|
const MAX_REVISIONS = 50
|
||||||
|
|
||||||
@@ -122,6 +147,60 @@ function countWords(text: string): number {
|
|||||||
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
|
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[]> {
|
async function collectMarkdownPaths(dir: string): Promise<string[]> {
|
||||||
const entries = await readdir(dir, { withFileTypes: true })
|
const entries = await readdir(dir, { withFileTypes: true })
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { extname, basename } from 'path'
|
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 { streamMessage } from './aiService'
|
||||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||||
|
|
||||||
@@ -70,6 +70,14 @@ export function registerIpcHandlers(): void {
|
|||||||
return await moveFileOrDir(sourcePath, targetDirPath)
|
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[]> => {
|
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(win!, {
|
const result = await dialog.showOpenDialog(win!, {
|
||||||
@@ -115,7 +123,9 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
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()) {
|
if (!event.sender.isDestroyed()) {
|
||||||
event.sender.send('ai:chunk', chunk)
|
event.sender.send('ai:chunk', chunk)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,5 +84,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.invoke('fs:createDir', parentPath, name),
|
ipcRenderer.invoke('fs:createDir', parentPath, name),
|
||||||
|
|
||||||
moveFile: (sourcePath: string, targetDirPath: string): Promise<string> =>
|
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;
|
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 sub-header (History link + New Chat button) ────────────── */
|
||||||
.chat-subheader {
|
.chat-subheader {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -634,3 +668,25 @@
|
|||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
flex-shrink: 0;
|
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 { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import type { ChatMessage, TextAnnotation } from '../../types/editor'
|
import type { ChatMessage, TextAnnotation } from '../../types/editor'
|
||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import { currentEditorView } from '../Editor/MarkdownEditor'
|
||||||
|
|
||||||
marked.setOptions({ breaks: true })
|
marked.setOptions({ breaks: true })
|
||||||
|
|
||||||
@@ -27,12 +29,32 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Element {
|
export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Element {
|
||||||
|
const { activeFilePath, markSaved } = useEditorStore()
|
||||||
|
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
if (message.role !== 'assistant' || !message.content) return null
|
if (message.role !== 'assistant' || !message.content) return null
|
||||||
const raw = marked.parse(message.content) as string
|
const raw = marked.parse(message.content) as string
|
||||||
return DOMPurify.sanitize(raw)
|
return DOMPurify.sanitize(raw)
|
||||||
}, [message.role, message.content])
|
}, [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 (
|
return (
|
||||||
<div className={`chat-message chat-message-${message.role}`}>
|
<div className={`chat-message chat-message-${message.role}`}>
|
||||||
<div className="chat-message-label">
|
<div className="chat-message-label">
|
||||||
@@ -86,6 +108,18 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export function ChatPanel(): JSX.Element {
|
|||||||
setActiveSession,
|
setActiveSession,
|
||||||
rightPanelTab,
|
rightPanelTab,
|
||||||
setRightPanelTab,
|
setRightPanelTab,
|
||||||
|
wholeStoryMode,
|
||||||
|
setWholeStoryMode,
|
||||||
|
storyBibleMode,
|
||||||
|
setStoryBibleMode,
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
const tab = rightPanelTab
|
const tab = rightPanelTab
|
||||||
@@ -85,7 +89,9 @@ export function ChatPanel(): JSX.Element {
|
|||||||
.slice(-10)
|
.slice(-10)
|
||||||
.map((m) => ({ role: m.role, content: m.content })),
|
.map((m) => ({ role: m.role, content: m.content })),
|
||||||
userMessage: text,
|
userMessage: text,
|
||||||
attachments: attachments.length > 0 ? attachments : undefined
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
|
projectMode: wholeStoryMode,
|
||||||
|
storyBibleMode: storyBibleMode
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
appendToLastAssistantMessage(chunk)
|
appendToLastAssistantMessage(chunk)
|
||||||
@@ -158,6 +164,26 @@ export function ChatPanel(): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ── */}
|
{/* ── Chat sub-header: History link + New Chat button ── */}
|
||||||
{showSubheader && (
|
{showSubheader && (
|
||||||
<div className="chat-subheader">
|
<div className="chat-subheader">
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-header {
|
.file-tree-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 14px 12px 10px;
|
padding: 14px 12px 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -15,6 +18,22 @@
|
|||||||
text-transform: uppercase;
|
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 {
|
.file-tree-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
import { useEditorStore } from '../../store/editorStore'
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import { currentEditorView } from '../Editor/MarkdownEditor'
|
||||||
import { FileTreeNode } from './FileTreeNode'
|
import { FileTreeNode } from './FileTreeNode'
|
||||||
import './FileTree.css'
|
import './FileTree.css'
|
||||||
|
|
||||||
export function FileTree(): JSX.Element {
|
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 (
|
return (
|
||||||
<nav className="file-tree">
|
<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">
|
<div className="file-tree-list">
|
||||||
{fileTree.map((node) => (
|
{fileTree.map((node) => (
|
||||||
<FileTreeNode
|
<FileTreeNode
|
||||||
|
|||||||
@@ -5,6 +5,33 @@ import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
|||||||
import { tooltipAnalysisCache } from '../Editor/MarkdownEditor'
|
import { tooltipAnalysisCache } from '../Editor/MarkdownEditor'
|
||||||
import './Toolbar.css'
|
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 {
|
function countWords(text: string): number {
|
||||||
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
|
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
|
||||||
}
|
}
|
||||||
@@ -35,7 +62,8 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
projectWordCount,
|
projectWordCount,
|
||||||
setProjectWordCount,
|
setProjectWordCount,
|
||||||
fontSize,
|
fontSize,
|
||||||
setFontSize
|
setFontSize,
|
||||||
|
setRightPanelTab
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +78,40 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
}, [isDirty])
|
}, [isDirty])
|
||||||
|
|
||||||
const hasFile = Boolean(activeFilePath)
|
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 => {
|
const runPassiveVoice = (): void => {
|
||||||
if (!hasFile) return
|
if (!hasFile) return
|
||||||
@@ -125,74 +187,114 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="toolbar-left">
|
<div className="toolbar-left">
|
||||||
<button
|
{isStoryBible ? (
|
||||||
className={`toolbar-btn${analysisMode === 'passive_voice' ? ' active' : ''}`}
|
<>
|
||||||
onClick={runPassiveVoice}
|
<span className="toolbar-bible-label">Generate from manuscript:</span>
|
||||||
disabled={!hasFile}
|
<button
|
||||||
title="Highlight passive voice sentences instantly (no AI required)"
|
className="toolbar-btn toolbar-btn-bible"
|
||||||
>
|
onClick={() => runBibleGeneration(BIBLE_PROMPTS.full)}
|
||||||
Passive Voice
|
disabled={isAILoading}
|
||||||
{passiveCount > 0 && (
|
title="Generate all story bible sections from the full manuscript"
|
||||||
<span className="toolbar-badge">{passiveCount}</span>
|
>
|
||||||
)}
|
{isAILoading ? 'Generating…' : 'Full bible'}
|
||||||
</button>
|
</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
|
<button
|
||||||
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
|
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
|
||||||
onClick={() => runAIAnalysis('consistency')}
|
onClick={() => runAIAnalysis('consistency')}
|
||||||
disabled={!hasFile || isAILoading}
|
disabled={!hasFile || isAILoading}
|
||||||
title="Check character names, timeline, and repeated phrases via AI"
|
title="Check character names, timeline, and repeated phrases via AI"
|
||||||
>
|
>
|
||||||
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
|
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
|
||||||
{consistencyCount > 0 && (
|
{consistencyCount > 0 && (
|
||||||
<span className="toolbar-badge">{consistencyCount}</span>
|
<span className="toolbar-badge">{consistencyCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
|
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
|
||||||
onClick={() => runAIAnalysis('style')}
|
onClick={() => runAIAnalysis('style')}
|
||||||
disabled={!hasFile || isAILoading}
|
disabled={!hasFile || isAILoading}
|
||||||
title="Pacing, sentence variety, show-don't-tell feedback via AI"
|
title="Pacing, sentence variety, show-don't-tell feedback via AI"
|
||||||
>
|
>
|
||||||
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
|
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
|
||||||
{styleCount > 0 && (
|
{styleCount > 0 && (
|
||||||
<span className="toolbar-badge">{styleCount}</span>
|
<span className="toolbar-badge">{styleCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn${analysisMode === 'show_tell' ? ' active' : ''}`}
|
className={`toolbar-btn${analysisMode === 'show_tell' ? ' active' : ''}`}
|
||||||
onClick={() => runAIAnalysis('show_tell')}
|
onClick={() => runAIAnalysis('show_tell')}
|
||||||
disabled={!hasFile || isAILoading}
|
disabled={!hasFile || isAILoading}
|
||||||
title="Find passages that tell rather than show via AI"
|
title="Find passages that tell rather than show via AI"
|
||||||
>
|
>
|
||||||
{isAILoading && analysisMode === 'show_tell' ? 'Reading…' : 'Show vs Tell'}
|
{isAILoading && analysisMode === 'show_tell' ? 'Reading…' : 'Show vs Tell'}
|
||||||
{showTellCount > 0 && (
|
{showTellCount > 0 && (
|
||||||
<span className="toolbar-badge">{showTellCount}</span>
|
<span className="toolbar-badge">{showTellCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn${analysisMode === 'critique' ? ' active' : ''}`}
|
className={`toolbar-btn${analysisMode === 'critique' ? ' active' : ''}`}
|
||||||
onClick={() => runAIAnalysis('critique')}
|
onClick={() => runAIAnalysis('critique')}
|
||||||
disabled={!hasFile || isAILoading}
|
disabled={!hasFile || isAILoading}
|
||||||
title="Honest overall critique of this chapter via AI"
|
title="Honest overall critique of this chapter via AI"
|
||||||
>
|
>
|
||||||
{isAILoading && analysisMode === 'critique' ? 'Reading…' : 'Critique'}
|
{isAILoading && analysisMode === 'critique' ? 'Reading…' : 'Critique'}
|
||||||
{critiqueCount > 0 && (
|
{critiqueCount > 0 && (
|
||||||
<span className="toolbar-badge">{critiqueCount}</span>
|
<span className="toolbar-badge">{critiqueCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{annotations.length > 0 && (
|
{annotations.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="toolbar-btn toolbar-btn-clear"
|
className="toolbar-btn toolbar-btn-clear"
|
||||||
onClick={() => { clearAnnotations(); tooltipAnalysisCache.clear() }}
|
onClick={() => { clearAnnotations(); tooltipAnalysisCache.clear() }}
|
||||||
title="Remove all highlights"
|
title="Remove all highlights"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -140,3 +140,24 @@
|
|||||||
margin: 0 3px;
|
margin: 0 3px;
|
||||||
opacity: 0.5;
|
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
|
isAILoading: boolean
|
||||||
aiError: string | null
|
aiError: string | null
|
||||||
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
|
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
|
||||||
startAssistantMessage: () => void
|
startAssistantMessage: (opts?: { bibleGeneration?: boolean }) => void
|
||||||
appendToLastAssistantMessage: (chunk: string) => void
|
appendToLastAssistantMessage: (chunk: string) => void
|
||||||
setAILoading: (loading: boolean) => void
|
setAILoading: (loading: boolean) => void
|
||||||
setAIError: (error: string | null) => void
|
setAIError: (error: string | null) => void
|
||||||
@@ -71,6 +71,14 @@ interface EditorState {
|
|||||||
theme: 'dark' | 'light'
|
theme: 'dark' | 'light'
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
|
|
||||||
|
// Whole story context mode
|
||||||
|
wholeStoryMode: boolean
|
||||||
|
setWholeStoryMode: (enabled: boolean) => void
|
||||||
|
|
||||||
|
// Story bible context mode
|
||||||
|
storyBibleMode: boolean
|
||||||
|
setStoryBibleMode: (enabled: boolean) => void
|
||||||
|
|
||||||
// Revision panel
|
// Revision panel
|
||||||
revisionPanelOpen: boolean
|
revisionPanelOpen: boolean
|
||||||
toggleRevisionPanel: () => void
|
toggleRevisionPanel: () => void
|
||||||
@@ -203,8 +211,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
startAssistantMessage: () => {
|
startAssistantMessage: (opts?) => {
|
||||||
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' }
|
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '', bibleGeneration: opts?.bibleGeneration }
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const history = [...s.chatHistory, msg]
|
const history = [...s.chatHistory, msg]
|
||||||
if (!s.activeFilePath) return { chatHistory: history }
|
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 () => {
|
loadSession: async () => {
|
||||||
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
||||||
if (!api) return
|
if (!api) return
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
attachments?: AttachmentMeta[] // metadata only — stored in history for display
|
attachments?: AttachmentMeta[] // metadata only — stored in history for display
|
||||||
annotationIds?: string[] // IDs of suggestions this message produced
|
annotationIds?: string[] // IDs of suggestions this message produced
|
||||||
|
bibleGeneration?: boolean // when true, show "Apply to Story Bible" button on assistant message
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
@@ -55,6 +56,8 @@ export interface AIPayload {
|
|||||||
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
userMessage: string
|
userMessage: string
|
||||||
attachments?: Attachment[] // full data for current API call only
|
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 {
|
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>
|
createFile: (parentPath: string, name: string) => Promise<string>
|
||||||
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 }>
|
||||||
|
writeStoryBible: (content: string) => Promise<void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user