✨ upload attachments and custom feedback
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user