♿ manuscript reference as a tool
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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().
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user