diff --git a/demo/main.tsx b/demo/main.tsx index ac72129..cf75194 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -15,6 +15,7 @@ import '@renderer/styles/app.css' removeAIListener: () => {}, getProjectWordCount: () => Promise.resolve(0), saveOrder: () => Promise.resolve(), + pickAttachments: () => Promise.resolve([]), saveRevision: () => Promise.resolve(), listRevisions: () => Promise.resolve([]), loadRevision: () => Promise.resolve(''), diff --git a/package-lock.json b/package-lock.json index 9d3821a..ea599d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "codemirror": "^6.0.1", "dompurify": "^3.3.1", "marked": "^17.0.3", + "pdf-parse": "^2.4.5", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" @@ -28,6 +29,7 @@ "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", "@types/node": "^20.0.0", + "@types/pdf-parse": "^1.1.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", @@ -1520,6 +1522,190 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2056,6 +2242,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/pdf-parse": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", + "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -5107,6 +5303,38 @@ "dev": true, "license": "ISC" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", diff --git a/package.json b/package.json index 1a4898f..07ecef5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", "@types/node": "^20.0.0", + "@types/pdf-parse": "^1.1.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", @@ -35,6 +36,7 @@ "codemirror": "^6.0.1", "dompurify": "^3.3.1", "marked": "^17.0.3", + "pdf-parse": "^2.4.5", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" diff --git a/src/main/aiService.ts b/src/main/aiService.ts index e4815e3..2ca22f0 100644 --- a/src/main/aiService.ts +++ b/src/main/aiService.ts @@ -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 { 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({ diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index 4f458b6..ccdeab9 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -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 => { + 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) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8de049d..624670b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { FileNode, AIPayload, RevisionMeta } from '../renderer/types/editor' +import type { FileNode, AIPayload, RevisionMeta, Attachment } from '../renderer/types/editor' contextBridge.exposeInMainWorld('api', { listFiles: (): Promise => ipcRenderer.invoke('fs:listFiles'), @@ -39,6 +39,9 @@ contextBridge.exposeInMainWorld('api', { }) }, + pickAttachments: (): Promise => + ipcRenderer.invoke('fs:pickAttachments'), + removeAIListener: (): void => { ipcRenderer.removeAllListeners('ai:chunk') ipcRenderer.removeAllListeners('ai:done') diff --git a/src/renderer/components/AIChat/Chat.css b/src/renderer/components/AIChat/Chat.css index 5907db0..e3a12a4 100644 --- a/src/renderer/components/AIChat/Chat.css +++ b/src/renderer/components/AIChat/Chat.css @@ -278,13 +278,129 @@ .chat-input-area { display: flex; - align-items: flex-end; - gap: 8px; + flex-direction: column; + gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); flex-shrink: 0; } +/* ── Attachment chips bar (above the input row) ─── */ +.chat-attachments-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chat-attachment-chip { + display: flex; + align-items: center; + gap: 5px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 20px; + padding: 3px 8px 3px 4px; + font-size: 11px; + color: var(--text-secondary); + max-width: 180px; +} + +.chat-attachment-thumb { + width: 22px; + height: 22px; + object-fit: cover; + border-radius: 12px; + flex-shrink: 0; +} + +.chat-attachment-icon { + font-size: 14px; + flex-shrink: 0; + line-height: 1; +} + +.chat-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.chat-attachment-remove { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 1px; + flex-shrink: 0; + transition: color 0.12s; +} + +.chat-attachment-remove:hover { + color: var(--text-primary); +} + +/* ── Input row: paperclip + textarea + send ─── */ +.chat-input-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.chat-paperclip-btn { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 18px; + width: 34px; + height: 34px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: color 0.15s, border-color 0.15s; + padding: 0; +} + +.chat-paperclip-btn:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.chat-paperclip-btn:disabled { + opacity: 0.35; + cursor: default; +} + +/* ── Attachment chips shown inside past chat messages ─── */ +.chat-message-attachments { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 2px; +} + +.chat-message-attachment-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 20px; + padding: 2px 8px; + font-size: 11px; + color: var(--text-muted); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .chat-textarea { flex: 1; background: var(--input-bg); diff --git a/src/renderer/components/AIChat/ChatInput.tsx b/src/renderer/components/AIChat/ChatInput.tsx index 1bdf16b..e71ec3d 100644 --- a/src/renderer/components/AIChat/ChatInput.tsx +++ b/src/renderer/components/AIChat/ChatInput.tsx @@ -1,19 +1,28 @@ import { useState, useRef, type KeyboardEvent } from 'react' +import type { Attachment } from '../../types/editor' interface Props { - onSend: (text: string) => void + onSend: (text: string, attachments: Attachment[]) => void disabled: boolean } +function attachmentIcon(mimeType: string): string { + if (mimeType.startsWith('image/')) return '🖼' + if (mimeType === 'application/pdf') return '📄' + return '📝' +} + export function ChatInput({ onSend, disabled }: Props): JSX.Element { const [value, setValue] = useState('') + const [attachments, setAttachments] = useState([]) const textareaRef = useRef(null) const submit = (): void => { const trimmed = value.trim() - if (!trimmed || disabled) return - onSend(trimmed) + if ((!trimmed && attachments.length === 0) || disabled) return + onSend(trimmed, attachments) setValue('') + setAttachments([]) if (textareaRef.current) { textareaRef.current.style.height = 'auto' } @@ -34,27 +43,83 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element { } } + const handleAttach = async (): Promise => { + const picked = await window.api.pickAttachments() + if (picked.length === 0) return + // Deduplicate by name + setAttachments((prev) => { + const existingNames = new Set(prev.map((a) => a.name)) + const fresh = picked.filter((a) => !existingNames.has(a.name)) + return [...prev, ...fresh] + }) + } + + const removeAttachment = (name: string): void => { + setAttachments((prev) => prev.filter((a) => a.name !== name)) + } + + const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0) + return (
-