✨ upload attachments and custom feedback
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { AIPayload } from '../renderer/types/editor'
|
||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
|
||||
let _client: Anthropic | null = null
|
||||
|
||||
@@ -89,7 +89,54 @@ SUGGESTION: [concrete direction]
|
||||
Name the single most important thing to fix in a revision of this chapter.`
|
||||
}
|
||||
|
||||
return `${chapterContext}\n\n${modeInstructions[payload.mode]}`
|
||||
let prompt = `${chapterContext}\n\n${modeInstructions[payload.mode]}`
|
||||
|
||||
if (payload.attachments && payload.attachments.length > 0) {
|
||||
const names = payload.attachments.map((a) => a.name).join(', ')
|
||||
prompt += `\n\nThe user has attached the following reference file(s): ${names}.
|
||||
When suggesting edits based on the attached material, identify exact passages in the chapter and use this format for each suggestion:
|
||||
ISSUE: [category of improvement]
|
||||
PASSAGE: "[exact quoted text from the chapter]"
|
||||
PROBLEM: [brief explanation of why this needs changing]
|
||||
SUGGESTION: "[revised text]"`
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
type UserContentBlock = Anthropic.ImageBlockParam | Anthropic.TextBlockParam
|
||||
|
||||
function buildUserContent(
|
||||
userMessage: string,
|
||||
attachments: Attachment[] | undefined
|
||||
): string | UserContentBlock[] {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return userMessage
|
||||
}
|
||||
|
||||
const content: UserContentBlock[] = []
|
||||
|
||||
for (const att of attachments) {
|
||||
if (att.mimeType.startsWith('image/')) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: att.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
||||
data: att.data
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Text or PDF — include as a fenced text block
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: `--- Attached: ${att.name} ---\n${att.data}\n--- End of ${att.name} ---`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
content.push({ type: 'text', text: userMessage })
|
||||
return content
|
||||
}
|
||||
|
||||
export async function streamMessage(
|
||||
@@ -98,9 +145,12 @@ export async function streamMessage(
|
||||
): Promise<void> {
|
||||
const client = getClient()
|
||||
|
||||
const messages = [
|
||||
const messages: Anthropic.MessageParam[] = [
|
||||
...payload.conversationHistory.slice(-10),
|
||||
{ role: 'user' as const, content: payload.userMessage }
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: buildUserContent(payload.userMessage, payload.attachments)
|
||||
}
|
||||
]
|
||||
|
||||
const stream = client.messages.stream({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ipcMain } from 'electron'
|
||||
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 } from './fileSystem'
|
||||
import { streamMessage } from './aiService'
|
||||
import type { AIPayload } from '../renderer/types/editor'
|
||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('fs:listFiles', async () => {
|
||||
@@ -48,6 +50,48 @@ export function registerIpcHandlers(): void {
|
||||
await deleteRevision(filePath, revisionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{ name: 'Supported Files', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'txt', 'md', 'pdf'] },
|
||||
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },
|
||||
{ name: 'Text Files', extensions: ['txt', 'md'] },
|
||||
{ name: 'PDF', extensions: ['pdf'] }
|
||||
]
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) return []
|
||||
|
||||
const attachments: Attachment[] = []
|
||||
for (const filePath of result.filePaths) {
|
||||
const name = basename(filePath)
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
|
||||
const data = readFileSync(filePath).toString('base64')
|
||||
const mimeType =
|
||||
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
||||
: ext === '.png' ? 'image/png'
|
||||
: ext === '.gif' ? 'image/gif'
|
||||
: 'image/webp'
|
||||
attachments.push({ name, mimeType, data })
|
||||
} else if (['.txt', '.md'].includes(ext)) {
|
||||
const data = readFileSync(filePath, 'utf-8')
|
||||
attachments.push({ name, mimeType: 'text/plain', data })
|
||||
} else if (ext === '.pdf') {
|
||||
// Dynamically require pdf-parse to avoid CJS/ESM issues at module load time
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>
|
||||
const buffer = readFileSync(filePath)
|
||||
const parsed = await pdfParse(buffer)
|
||||
attachments.push({ name, mimeType: 'application/pdf', data: parsed.text })
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
})
|
||||
|
||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||
try {
|
||||
await streamMessage(payload, (chunk: string) => {
|
||||
|
||||
Reference in New Issue
Block a user