✨ upload attachments and custom feedback
This commit is contained in:
@@ -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(''),
|
||||
|
||||
228
package-lock.json
generated
228
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
||||
@@ -39,6 +39,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
})
|
||||
},
|
||||
|
||||
pickAttachments: (): Promise<Attachment[]> =>
|
||||
ipcRenderer.invoke('fs:pickAttachments'),
|
||||
|
||||
removeAIListener: (): void => {
|
||||
ipcRenderer.removeAllListeners('ai:chunk')
|
||||
ipcRenderer.removeAllListeners('ai:done')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Attachment[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<void> => {
|
||||
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 (
|
||||
<div className="chat-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-textarea"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="chat-send-btn"
|
||||
onClick={submit}
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
{attachments.length > 0 && (
|
||||
<div className="chat-attachments-bar">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.name} className="chat-attachment-chip">
|
||||
{att.mimeType.startsWith('image/') ? (
|
||||
<img
|
||||
className="chat-attachment-thumb"
|
||||
src={`data:${att.mimeType};base64,${att.data}`}
|
||||
alt={att.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="chat-attachment-icon">{attachmentIcon(att.mimeType)}</span>
|
||||
)}
|
||||
<span className="chat-attachment-name" title={att.name}>
|
||||
{att.name.length > 22 ? att.name.slice(0, 20) + '…' : att.name}
|
||||
</span>
|
||||
<button
|
||||
className="chat-attachment-remove"
|
||||
onClick={() => removeAttachment(att.name)}
|
||||
aria-label={`Remove ${att.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chat-input-row">
|
||||
<button
|
||||
className="chat-paperclip-btn"
|
||||
onClick={handleAttach}
|
||||
disabled={disabled}
|
||||
title="Attach file (image, text, PDF)"
|
||||
aria-label="Attach file"
|
||||
>
|
||||
⌁
|
||||
</button>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-textarea"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="chat-send-btn"
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
aria-label="Send"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import type { ChatMessage } from '../../types/editor'
|
||||
|
||||
marked.setOptions({ breaks: true })
|
||||
|
||||
function attachmentIcon(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return '🖼'
|
||||
if (mimeType === 'application/pdf') return '📄'
|
||||
return '📝'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage
|
||||
}
|
||||
@@ -21,6 +27,16 @@ export function ChatMessageItem({ message }: Props): JSX.Element {
|
||||
<div className="chat-message-label">
|
||||
{message.role === 'user' ? 'You' : 'Editor AI'}
|
||||
</div>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="chat-message-attachments">
|
||||
{message.attachments.map((att) => (
|
||||
<span key={att.name} className="chat-message-attachment-chip" title={att.name}>
|
||||
{attachmentIcon(att.mimeType)}{' '}
|
||||
{att.name.length > 24 ? att.name.slice(0, 22) + '…' : att.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-content">
|
||||
{html ? (
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChatMessageItem } from './ChatMessageItem'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { FeedbackPanel } from '../Feedback/FeedbackPanel'
|
||||
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||
import type { Attachment } from '../../types/editor'
|
||||
import './Chat.css'
|
||||
|
||||
type TabId = 'chat' | 'feedback'
|
||||
@@ -40,11 +41,11 @@ export function ChatPanel(): JSX.Element {
|
||||
prevAnnotationCountRef.current = curr
|
||||
}, [annotations])
|
||||
|
||||
const sendMessage = async (text: string): Promise<void> => {
|
||||
const sendMessage = async (text: string, attachments: Attachment[]): Promise<void> => {
|
||||
if (!activeFilePath || isAILoading) return
|
||||
|
||||
setAIError(null)
|
||||
addUserMessage(text)
|
||||
addUserMessage(text, attachments.map(({ name, mimeType }) => ({ name, mimeType })))
|
||||
startAssistantMessage()
|
||||
setAILoading(true)
|
||||
|
||||
@@ -58,7 +59,8 @@ export function ChatPanel(): JSX.Element {
|
||||
conversationHistory: chatHistory
|
||||
.slice(-10)
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
userMessage: text
|
||||
userMessage: text,
|
||||
attachments: attachments.length > 0 ? attachments : undefined
|
||||
},
|
||||
(chunk: string) => {
|
||||
appendToLastAssistantMessage(chunk)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode, RevisionMeta } from '../types/editor'
|
||||
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode, RevisionMeta, AttachmentMeta } from '../types/editor'
|
||||
|
||||
interface AnnotationFileState {
|
||||
mode: AnalysisMode
|
||||
@@ -24,7 +24,7 @@ interface EditorState {
|
||||
chatHistory: ChatMessage[]
|
||||
isAILoading: boolean
|
||||
aiError: string | null
|
||||
addUserMessage: (text: string) => void
|
||||
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
|
||||
startAssistantMessage: () => void
|
||||
appendToLastAssistantMessage: (chunk: string) => void
|
||||
setAILoading: (loading: boolean) => void
|
||||
@@ -148,8 +148,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
isAILoading: false,
|
||||
aiError: null,
|
||||
|
||||
addUserMessage: (text) => {
|
||||
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text }
|
||||
addUserMessage: (text, attachments?) => {
|
||||
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text, attachments }
|
||||
set((s) => {
|
||||
const history = [...s.chatHistory, msg]
|
||||
const byFile = s.activeFilePath
|
||||
|
||||
@@ -5,10 +5,20 @@ export interface FileNode {
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
export interface AttachmentMeta {
|
||||
name: string
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface Attachment extends AttachmentMeta {
|
||||
data: string // base64 for images, extracted text for text/PDF
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
attachments?: AttachmentMeta[] // metadata only — stored in history for display
|
||||
}
|
||||
|
||||
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique'
|
||||
@@ -33,6 +43,7 @@ export interface AIPayload {
|
||||
documentPath: string
|
||||
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
userMessage: string
|
||||
attachments?: Attachment[] // full data for current API call only
|
||||
}
|
||||
|
||||
export interface RevisionMeta {
|
||||
|
||||
3
src/renderer/types/global.d.ts
vendored
3
src/renderer/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { FileNode, AIPayload, RevisionMeta } from './editor'
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment } from './editor'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -13,6 +13,7 @@ declare global {
|
||||
removeAIListener: () => void
|
||||
getProjectWordCount: () => Promise<number>
|
||||
saveOrder: (order: Record<string, string[]>) => Promise<void>
|
||||
pickAttachments: () => Promise<Attachment[]>
|
||||
saveRevision: (filePath: string, content: string) => Promise<void>
|
||||
listRevisions: (filePath: string) => Promise<RevisionMeta[]>
|
||||
loadRevision: (filePath: string, revisionId: string) => Promise<string>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user