upload attachments and custom feedback

This commit is contained in:
2026-02-26 04:50:54 +10:00
parent f9333626aa
commit ee09163a7d
15 changed files with 580 additions and 41 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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({

View File

@@ -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) => {

View File

@@ -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')

View File

@@ -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);

View File

@@ -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,8 +43,63 @@ 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">
{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"
@@ -50,11 +114,12 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element {
<button
className="chat-send-btn"
onClick={submit}
disabled={disabled || !value.trim()}
disabled={!canSend}
aria-label="Send"
>
</button>
</div>
</div>
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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