♿ manuscript reference as a tool
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
import type { DraftDocument } from './fileSystem'
|
||||
import { readAllDraftFiles } from './fileSystem'
|
||||
|
||||
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 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 {
|
||||
return [
|
||||
'=== STORY BIBLE ===',
|
||||
@@ -76,7 +86,7 @@ function buildManuscriptBlock(allDocs: DraftDocument[], currentPath: string): st
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyBibleContent?: string): string {
|
||||
function buildSystemPrompt(payload: AIPayload, storyBibleContent?: string): string {
|
||||
const chapterName =
|
||||
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
|
||||
|
||||
@@ -84,11 +94,7 @@ function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyB
|
||||
? 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}".
|
||||
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}".
|
||||
|
||||
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(
|
||||
payload: AIPayload,
|
||||
allDocs: DraftDocument[] | undefined,
|
||||
storyBibleContent: string | undefined,
|
||||
onChunk: (chunk: string) => 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({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 4096,
|
||||
system: buildSystemPrompt(payload, allDocs, storyBibleContent),
|
||||
system: systemPrompt,
|
||||
tools: [getManuscriptTool],
|
||||
messages
|
||||
})
|
||||
|
||||
@@ -262,4 +271,51 @@ export async function streamMessage(
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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 type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
|
||||
@@ -123,9 +123,8 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||
try {
|
||||
const allDocs = payload.projectMode ? await readAllDraftFiles() : undefined
|
||||
const storyBibleContent = payload.storyBibleMode ? (await readStoryBibleFile() ?? undefined) : undefined
|
||||
await streamMessage(payload, allDocs, storyBibleContent, (chunk: string) => {
|
||||
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
||||
await streamMessage(payload, storyBibleContent, (chunk: string) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('ai:chunk', chunk)
|
||||
}
|
||||
|
||||
@@ -42,10 +42,6 @@ export function ChatPanel(): JSX.Element {
|
||||
setActiveSession,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
wholeStoryMode,
|
||||
setWholeStoryMode,
|
||||
storyBibleMode,
|
||||
setStoryBibleMode,
|
||||
} = useEditorStore()
|
||||
|
||||
const tab = rightPanelTab
|
||||
@@ -89,9 +85,7 @@ export function ChatPanel(): JSX.Element {
|
||||
.slice(-10)
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
userMessage: text,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
projectMode: wholeStoryMode,
|
||||
storyBibleMode: storyBibleMode
|
||||
attachments: attachments.length > 0 ? attachments : undefined
|
||||
},
|
||||
(chunk: string) => {
|
||||
appendToLastAssistantMessage(chunk)
|
||||
@@ -164,25 +158,6 @@ 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 && (
|
||||
|
||||
@@ -4,13 +4,12 @@ import { FileTreeNode } from './FileTreeNode'
|
||||
import './FileTree.css'
|
||||
|
||||
export function FileTree(): JSX.Element {
|
||||
const { fileTree, activeFilePath, setActiveFile, markSaved, setStoryBibleMode } = useEditorStore()
|
||||
const { fileTree, activeFilePath, setActiveFile, markSaved } = 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().
|
||||
|
||||
@@ -71,14 +71,6 @@ 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
|
||||
@@ -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 () => {
|
||||
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
||||
if (!api) return
|
||||
|
||||
@@ -57,8 +57,6 @@ 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 {
|
||||
|
||||
Reference in New Issue
Block a user