🐛 parse PDF
This commit is contained in:
@@ -97,11 +97,12 @@ export function registerIpcHandlers(): void {
|
|||||||
const data = readFileSync(filePath, 'utf-8')
|
const data = readFileSync(filePath, 'utf-8')
|
||||||
attachments.push({ name, mimeType: 'text/plain', data })
|
attachments.push({ name, mimeType: 'text/plain', data })
|
||||||
} else if (ext === '.pdf') {
|
} else if (ext === '.pdf') {
|
||||||
// Dynamically require pdf-parse to avoid CJS/ESM issues at module load time
|
// pdf-parse v2 API: new PDFParse({ data: buffer }) then .getText()
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>
|
const { PDFParse } = require('pdf-parse') as { PDFParse: new (opts: { data: Uint8Array }) => { getText: () => Promise<{ text: string }> } }
|
||||||
const buffer = readFileSync(filePath)
|
const buffer = readFileSync(filePath)
|
||||||
const parsed = await pdfParse(buffer)
|
const parser = new PDFParse({ data: new Uint8Array(buffer) })
|
||||||
|
const parsed = await parser.getText()
|
||||||
attachments.push({ name, mimeType: 'application/pdf', data: parsed.text })
|
attachments.push({ name, mimeType: 'application/pdf', data: parsed.text })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,27 @@
|
|||||||
border-color: var(--text-muted);
|
border-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Pending attachment indicator (between tab bar and messages) ─── */
|
||||||
|
.chat-attachment-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-indicator-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { MenuItem } from '../FileTree/ContextMenu'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSend: (text: string, attachments: Attachment[]) => void
|
onSend: (text: string, attachments: Attachment[]) => void
|
||||||
|
onAttachmentsChange?: (count: number) => void
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,19 +15,24 @@ function attachmentIcon(mimeType: string): string {
|
|||||||
return '📝'
|
return '📝'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
export function ChatInput({ onSend, onAttachmentsChange, disabled }: Props): JSX.Element {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [hasSelection, setHasSelection] = useState(false)
|
const [hasSelection, setHasSelection] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const updateAttachments = (next: Attachment[]): void => {
|
||||||
|
setAttachments(next)
|
||||||
|
onAttachmentsChange?.(next.length)
|
||||||
|
}
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if ((!trimmed && attachments.length === 0) || disabled) return
|
if ((!trimmed && attachments.length === 0) || disabled) return
|
||||||
onSend(trimmed, attachments)
|
onSend(trimmed, attachments)
|
||||||
setValue('')
|
setValue('')
|
||||||
setAttachments([])
|
updateAttachments([])
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
}
|
}
|
||||||
@@ -54,12 +60,18 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
|||||||
setAttachments((prev) => {
|
setAttachments((prev) => {
|
||||||
const existingNames = new Set(prev.map((a) => a.name))
|
const existingNames = new Set(prev.map((a) => a.name))
|
||||||
const fresh = picked.filter((a) => !existingNames.has(a.name))
|
const fresh = picked.filter((a) => !existingNames.has(a.name))
|
||||||
return [...prev, ...fresh]
|
const next = [...prev, ...fresh]
|
||||||
|
onAttachmentsChange?.(next.length)
|
||||||
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeAttachment = (name: string): void => {
|
const removeAttachment = (name: string): void => {
|
||||||
setAttachments((prev) => prev.filter((a) => a.name !== name))
|
setAttachments((prev) => {
|
||||||
|
const next = prev.filter((a) => a.name !== name)
|
||||||
|
onAttachmentsChange?.(next.length)
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0)
|
const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function ChatPanel(): JSX.Element {
|
|||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
const [tab, setTab] = useState<TabId>('chat')
|
const [tab, setTab] = useState<TabId>('chat')
|
||||||
|
const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const prevAnnotationCountRef = useRef(annotations.length)
|
const prevAnnotationCountRef = useRef(annotations.length)
|
||||||
|
|
||||||
@@ -122,6 +123,14 @@ export function ChatPanel(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Pending-attachment indicator ── */}
|
||||||
|
{pendingAttachmentCount > 0 && (
|
||||||
|
<div className="chat-attachment-indicator">
|
||||||
|
<span className="chat-attachment-indicator-icon">⌁</span>
|
||||||
|
{pendingAttachmentCount} file{pendingAttachmentCount !== 1 ? 's' : ''} attached to next message
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Panel content ── */}
|
{/* ── Panel content ── */}
|
||||||
{tab === 'feedback' ? (
|
{tab === 'feedback' ? (
|
||||||
<FeedbackPanel />
|
<FeedbackPanel />
|
||||||
@@ -151,7 +160,11 @@ export function ChatPanel(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatInput onSend={sendMessage} disabled={!hasFile || isAILoading} />
|
<ChatInput
|
||||||
|
onSend={sendMessage}
|
||||||
|
onAttachmentsChange={setPendingAttachmentCount}
|
||||||
|
disabled={!hasFile || isAILoading}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
|
import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
|
||||||
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
|
import { EditorState, StateField, StateEffect, Annotation, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll } from '@codemirror/commands'
|
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll } from '@codemirror/commands'
|
||||||
@@ -15,6 +15,10 @@ import './Editor.css'
|
|||||||
// StateEffect to push new annotations into the editor
|
// StateEffect to push new annotations into the editor
|
||||||
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
||||||
|
|
||||||
|
// Annotation tag that marks an auto-dismiss transaction so annotationHistory
|
||||||
|
// records its inverse, making the dismissal undoable with Cmd+Z.
|
||||||
|
const userDismissAnnotation = Annotation.define<boolean>()
|
||||||
|
|
||||||
// StateField stores the raw annotation array for hover lookup.
|
// StateField stores the raw annotation array for hover lookup.
|
||||||
// Positions are mapped through every document change so the field always
|
// Positions are mapped through every document change so the field always
|
||||||
// reflects where annotations actually are in the current document.
|
// reflects where annotations actually are in the current document.
|
||||||
@@ -56,9 +60,21 @@ function schedulePendingDismiss(id: string): void {
|
|||||||
if (existing !== undefined) clearTimeout(existing)
|
if (existing !== undefined) clearTimeout(existing)
|
||||||
dismissTimers.set(id, setTimeout(() => {
|
dismissTimers.set(id, setTimeout(() => {
|
||||||
dismissTimers.delete(id)
|
dismissTimers.delete(id)
|
||||||
const { removeAnnotation } = useEditorStore.getState()
|
|
||||||
removeAnnotation(id)
|
|
||||||
tooltipAnalysisCache.delete(id)
|
tooltipAnalysisCache.delete(id)
|
||||||
|
const view = currentEditorView
|
||||||
|
if (!view) {
|
||||||
|
// No editor mounted — fall back to direct store update (not undoable)
|
||||||
|
useEditorStore.getState().removeAnnotation(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Dispatch a tagged CM transaction so annotationHistory records the inverse
|
||||||
|
// and the dismissal can be undone with Cmd+Z.
|
||||||
|
const remaining = view.state.field(rawAnnotationsField).filter(a => a.id !== id)
|
||||||
|
view.dispatch({
|
||||||
|
effects: setAnnotationsEffect.of(remaining),
|
||||||
|
annotations: [userDismissAnnotation.of(true)]
|
||||||
|
})
|
||||||
|
// Store sync is handled by the updateListener detecting userDismissAnnotation.
|
||||||
}, EDIT_DISMISS_MS))
|
}, EDIT_DISMISS_MS))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,16 +275,16 @@ const annotationField = StateField.define<DecorationSet>({
|
|||||||
provide: (f) => EditorView.decorations.from(f)
|
provide: (f) => EditorView.decorations.from(f)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Teach CM's undo/redo history about annotation state so that Cmd+Z after
|
// Teach CM's undo/redo history about annotation state so Cmd+Z can restore
|
||||||
// an Apply also restores the highlight. For every transaction that carries a
|
// highlights. Records the previous annotation list as the inverse effect for:
|
||||||
// setAnnotationsEffect we record the *previous* annotation list as the
|
// • Apply suggestion (docChanged + setAnnotationsEffect)
|
||||||
// inverse effect; CM history replays it on undo.
|
// • Edit-triggered auto-dismiss (tagged with userDismissAnnotation)
|
||||||
|
// External updates (critique loads, store clears) have neither flag and must
|
||||||
|
// NOT enter the undo stack or Undo would remove highlights unexpectedly.
|
||||||
const annotationHistory = invertedEffects.of(tr => {
|
const annotationHistory = invertedEffects.of(tr => {
|
||||||
// Only track annotation changes that accompany a document edit (i.e. an
|
const isApply = tr.docChanged && tr.effects.some(e => e.is(setAnnotationsEffect))
|
||||||
// Apply). External updates — critique results loading, store clears — have
|
const isAutoDismiss = tr.annotation(userDismissAnnotation) === true
|
||||||
// no doc change and must NOT enter the undo stack, otherwise Undo removes
|
if (isApply || isAutoDismiss) {
|
||||||
// highlights before touching any text.
|
|
||||||
if (tr.docChanged && tr.effects.some(e => e.is(setAnnotationsEffect))) {
|
|
||||||
const before = tr.startState.field(rawAnnotationsField)
|
const before = tr.startState.field(rawAnnotationsField)
|
||||||
return [setAnnotationsEffect.of(before)]
|
return [setAnnotationsEffect.of(before)]
|
||||||
}
|
}
|
||||||
@@ -389,6 +405,14 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the debounce timer fires it dispatches a tagged CM transaction
|
||||||
|
// (no doc change). Sync the resulting annotation list to the store so
|
||||||
|
// the feedback panel reflects the dismissal immediately.
|
||||||
|
if (update.transactions.some(tr => tr.annotation(userDismissAnnotation) === true)) {
|
||||||
|
useEditorStore.getState().setAnnotations(update.state.field(rawAnnotationsField))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-dismiss annotations whose highlighted text the user edits.
|
// Auto-dismiss annotations whose highlighted text the user edits.
|
||||||
// We check the pre-edit annotation positions (startState) against
|
// We check the pre-edit annotation positions (startState) against
|
||||||
// each changed range reported by the transaction.
|
// each changed range reported by the transaction.
|
||||||
@@ -474,12 +498,17 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
// Merge the store's list (which annotations exist) with CM-tracked positions
|
// Merge the store's list (which annotations exist) with CM-tracked positions
|
||||||
// (where they actually are after any edits), so that dismissing or adding an
|
// (where they actually are after any edits), so that dismissing or adding an
|
||||||
// annotation doesn't reset surviving highlights to stale store positions.
|
// annotation doesn't reset surviving highlights to stale store positions.
|
||||||
|
// Marked addToHistory.of(false) so this sync dispatch never creates an undo
|
||||||
|
// step — only Apply and tagged auto-dismissals should be undoable.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const view = viewRef.current
|
const view = viewRef.current
|
||||||
if (!view) return
|
if (!view) return
|
||||||
const trackedById = new Map(view.state.field(rawAnnotationsField).map(a => [a.id, a]))
|
const trackedById = new Map(view.state.field(rawAnnotationsField).map(a => [a.id, a]))
|
||||||
const toDispatch = annotations.map(a => trackedById.get(a.id) ?? a)
|
const toDispatch = annotations.map(a => trackedById.get(a.id) ?? a)
|
||||||
view.dispatch({ effects: setAnnotationsEffect.of(toDispatch) })
|
view.dispatch({
|
||||||
|
effects: setAnnotationsEffect.of(toDispatch),
|
||||||
|
annotations: [Transaction.addToHistory.of(false)]
|
||||||
|
})
|
||||||
}, [annotations])
|
}, [annotations])
|
||||||
|
|
||||||
// Reconfigure theme when font size or colour theme changes
|
// Reconfigure theme when font size or colour theme changes
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user