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

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