manuscript reference as a tool

This commit is contained in:
2026-03-03 10:55:41 +10:00
parent d15b134d43
commit cc00c40214
7 changed files with 70 additions and 63 deletions

View File

@@ -1,6 +1,7 @@
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' import type { DraftDocument } from './fileSystem'
import { readAllDraftFiles } from './fileSystem'
let _client: Anthropic | null = null let _client: Anthropic | null = null
@@ -19,6 +20,15 @@ if (!apiKey || apiKey === 'your-api-key-here') {
const MAX_MANUSCRIPT_CHARS = 560_000 // ~140k tokens at 4 chars/token const MAX_MANUSCRIPT_CHARS = 560_000 // ~140k tokens at 4 chars/token
const getManuscriptTool: Anthropic.Tool = {
name: 'get_manuscript',
description:
'Fetches all draft chapters in narrative order. ' +
'Call this when the user asks about events, characters, or passages beyond the current chapter, ' +
'or when a cross-chapter analysis (consistency, arcs, narrative structure) would benefit from the full novel.',
input_schema: { type: 'object', properties: {}, required: [] }
}
function buildStoryBibleBlock(content: string): string { function buildStoryBibleBlock(content: string): string {
return [ return [
'=== STORY BIBLE ===', '=== STORY BIBLE ===',
@@ -76,7 +86,7 @@ function buildManuscriptBlock(allDocs: DraftDocument[], currentPath: string): st
return lines.join('\n') return lines.join('\n')
} }
function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyBibleContent?: string): string { function buildSystemPrompt(payload: AIPayload, storyBibleContent?: string): string {
const chapterName = const chapterName =
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter' payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
@@ -84,11 +94,7 @@ function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyB
? buildStoryBibleBlock(storyBibleContent) + '\n\n' ? buildStoryBibleBlock(storyBibleContent) + '\n\n'
: '' : ''
const manuscriptSection = allDocs const chapterContext = `${storyBibleSection}You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}".
? 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.
@@ -233,7 +239,6 @@ function buildUserContent(
export async function streamMessage( export async function streamMessage(
payload: AIPayload, payload: AIPayload,
allDocs: DraftDocument[] | undefined,
storyBibleContent: string | undefined, storyBibleContent: string | undefined,
onChunk: (chunk: string) => void onChunk: (chunk: string) => void
): Promise<void> { ): Promise<void> {
@@ -247,10 +252,14 @@ export async function streamMessage(
} }
] ]
const systemPrompt = buildSystemPrompt(payload, storyBibleContent)
// Phase 1 — initial response, with get_manuscript tool available
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, allDocs, storyBibleContent), system: systemPrompt,
tools: [getManuscriptTool],
messages messages
}) })
@@ -262,4 +271,51 @@ export async function streamMessage(
onChunk(chunk.delta.text) onChunk(chunk.delta.text)
} }
} }
const finalMsg = await stream.finalMessage()
// Phase 2 — only if Claude requested the manuscript
if (finalMsg.stop_reason === 'tool_use') {
const toolUse = finalMsg.content.find(
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use' && b.name === 'get_manuscript'
)
if (toolUse) {
onChunk('\n\n*Reading manuscript…*\n\n')
const allDocs = await readAllDraftFiles()
const manuscriptText = buildManuscriptBlock(allDocs, payload.documentPath)
const stream2 = client.messages.stream({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: systemPrompt,
tools: [getManuscriptTool],
messages: [
...messages,
{ role: 'assistant', content: finalMsg.content },
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: toolUse.id,
content: manuscriptText
}
]
}
]
})
for await (const chunk of stream2) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
onChunk(chunk.delta.text)
}
}
await stream2.finalMessage()
}
}
} }

View File

@@ -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, readAllDraftFiles, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile } from './fileSystem' import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, 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'
@@ -123,9 +123,8 @@ export function registerIpcHandlers(): void {
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try { try {
const allDocs = payload.projectMode ? await readAllDraftFiles() : undefined const storyBibleContent = (await readStoryBibleFile()) ?? undefined
const storyBibleContent = payload.storyBibleMode ? (await readStoryBibleFile() ?? undefined) : undefined await streamMessage(payload, storyBibleContent, (chunk: string) => {
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)
} }

View File

@@ -42,10 +42,6 @@ export function ChatPanel(): JSX.Element {
setActiveSession, setActiveSession,
rightPanelTab, rightPanelTab,
setRightPanelTab, setRightPanelTab,
wholeStoryMode,
setWholeStoryMode,
storyBibleMode,
setStoryBibleMode,
} = useEditorStore() } = useEditorStore()
const tab = rightPanelTab const tab = rightPanelTab
@@ -89,9 +85,7 @@ 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)
@@ -164,25 +158,6 @@ 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 && (

View File

@@ -4,13 +4,12 @@ import { FileTreeNode } from './FileTreeNode'
import './FileTree.css' import './FileTree.css'
export function FileTree(): JSX.Element { export function FileTree(): JSX.Element {
const { fileTree, activeFilePath, setActiveFile, markSaved, setStoryBibleMode } = useEditorStore() const { fileTree, activeFilePath, setActiveFile, markSaved } = useEditorStore()
const handleOpenStoryBible = async (): Promise<void> => { const handleOpenStoryBible = async (): Promise<void> => {
try { try {
const { path, content } = await window.api.openStoryBible() const { path, content } = await window.api.openStoryBible()
setActiveFile(path, content) setActiveFile(path, content)
setStoryBibleMode(true)
// If the path didn't change (already on Story Bible), the MarkdownEditor's // If the path didn't change (already on Story Bible), the MarkdownEditor's
// activeFilePath-keyed effect won't fire. Push content directly, same // activeFilePath-keyed effect won't fire. Push content directly, same
// pattern as RevisionPanel.restore(). // pattern as RevisionPanel.restore().

View File

@@ -71,14 +71,6 @@ 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
@@ -550,18 +542,6 @@ 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

View File

@@ -57,8 +57,6 @@ 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 {

File diff suppressed because one or more lines are too long