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

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

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>