Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions src/client/features/ai/AiPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, FilePlus2, Replace, Settings2, Square, Wand2 } from 'lucide-react'
import { Textarea } from '../../components/form'
import { Modal } from '../../components/overlay'
import { Button } from '../../components/primitives'
import type { LossReport } from '../../lib/ai/guard'
import { AI_SYSTEM_PROMPT_CONVERT, AI_SYSTEM_PROMPT_TIDY, classifyAiError, streamMarkdown } from '../../lib/ai/ollama'
import { t } from '../../lib/i18n'
import { useNotes } from '../../store/notes'
import { useUi } from '../../store/ui'
import { readTextFile } from './read-text-file'
import { applyToTarget, isTargetUnchanged, takeAiPanelRequest, type AiPanelRequest } from './request'

function lossMessage(loss: LossReport): string {
if (loss.kind === 'truncated') return t('ai.loss_truncated')
return t('ai.loss_short', { percent: Math.round((loss.ratio ?? 0) * 100) })
}

function failureMessage(error: unknown): string {
const classified = classifyAiError(error)
if (classified.kind === 'unsupported-browser') return t('settings.ai_error_browser')
if (classified.kind === 'http') {
if (classified.status === 429) return t('settings.ai_error_quota')
return t('settings.ai_error_http', { status: classified.status ?? 0, detail: classified.detail })
}
if (classified.kind === 'unreachable') return t('settings.ai_error_unreachable')
return t('settings.ai_error_unknown', { detail: classified.detail })
}

const FALLBACK_REQUEST: AiPanelRequest = { mode: 'convert', input: '', target: null }

export function AiPanel({ onClose }: { onClose: () => void }) {
const requestRef = useRef<AiPanelRequest>(takeAiPanelRequest() ?? FALLBACK_REQUEST)
const request = requestRef.current
const abortRef = useRef<AbortController | null>(null)
const toast = useUi((state) => state.toast)
const openPanel = useUi((state) => state.openPanel)
const createNote = useNotes((state) => state.createNote)
const editContent = useNotes((state) => state.editContent)

const [input, setInput] = useState(request.input)
const [output, setOutput] = useState('')
const [running, setRunning] = useState(false)
const [progress, setProgress] = useState<{ index: number; total: number } | null>(null)
const [loss, setLoss] = useState<LossReport | null>(null)
const [error, setError] = useState<string | null>(null)
const [dragging, setDragging] = useState(false)

useEffect(() => () => abortRef.current?.abort(), [])

const run = async () => {
if (!input.trim() || running) return
const controller = new AbortController()
abortRef.current = controller
setRunning(true)
setOutput('')
setLoss(null)
setError(null)
setProgress(null)
try {
const result = await streamMarkdown({
input,
systemPrompt: request.mode === 'tidy' ? AI_SYSTEM_PROMPT_TIDY : AI_SYSTEM_PROMPT_CONVERT,
signal: controller.signal,
onToken: (delta) => setOutput((current) => current + delta),
onChunk: (index, total) => setProgress(total > 1 ? { index, total } : null),
})
setOutput(result.markdown)
setLoss(result.loss)
} catch (failure) {
if (classifyAiError(failure).kind !== 'aborted') setError(failureMessage(failure))
} finally {
abortRef.current = null
setRunning(false)
setProgress(null)
}
}

const stop = () => {
abortRef.current?.abort()
}

const acceptAsNewNote = async () => {
const id = await createNote({ content: output, open: true })
if (id) onClose()
}

const acceptAsReplacement = () => {
const target = request.target
if (!target) return
const state = useNotes.getState()
const note = state.notes[target.noteId]
const current = state.contents[target.noteId]
if (!note || current === undefined) {
toast({ title: t('ai.target_note_gone'), tone: 'danger' })
return
}
if (!isTargetUnchanged(current, target)) {
toast({ title: t('ai.target_note_changed'), tone: 'danger' })
return
}
editContent(target.noteId, applyToTarget(current, target, output))
onClose()
}

const acceptFile = async (file: File) => {
const result = await readTextFile(file)
if (result.ok) {
setInput(result.text)
return
}
const reasons = {
'not-text': t('ai.file_not_text'),
'too-large': t('ai.file_too_large'),
'read-failed': t('ai.file_read_failed'),
}
toast({ title: reasons[result.reason], tone: 'danger' })
}

const title = request.mode === 'tidy' ? t('ai.tidy_title') : t('ai.convert_title')
const description = request.mode === 'tidy' ? t('ai.tidy_description') : t('ai.convert_description')

return (
<Modal
open
onClose={onClose}
title={title}
description={description}
width={720}
footer={
<div className="flex flex-wrap items-center justify-end gap-2">
{running ? (
<Button type="button" variant="secondary" icon={<Square size={13} />} onClick={stop}>
{t('ai.stop')}
</Button>
) : (
<Button
type="button"
variant="secondary"
icon={<Wand2 size={13} />}
onClick={() => void run()}
disabled={!input.trim()}
>
{output ? t('ai.run_again') : t('ai.run')}
</Button>
)}
{request.mode === 'tidy' ? (
<Button
type="button"
variant="primary"
icon={<Replace size={13} />}
onClick={acceptAsReplacement}
disabled={running || !output}
>
{t('ai.replace_note')}
</Button>
) : (
<Button
type="button"
variant="primary"
icon={<FilePlus2 size={13} />}
onClick={() => void acceptAsNewNote()}
disabled={running || !output}
>
{t('ai.create_note')}
</Button>
)}
</div>
}
>
<div className="space-y-3">
{loss && (
<div className="flex items-start gap-2 rounded-[var(--r-lg)] border border-[var(--warning)] bg-[var(--bg-sunken)] p-3 text-[12px] leading-relaxed text-[var(--text-secondary)]">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-[var(--warning)]" />
<div>
<div className="font-medium text-[var(--text-primary)]">{lossMessage(loss)}</div>
<div className="mt-0.5">{t('ai.loss_advice')}</div>
</div>
</div>
)}

{error && (
<div className="flex flex-col gap-2 rounded-[var(--r-lg)] border border-[var(--border-subtle)] bg-[var(--bg-sunken)] p-3 text-[12px] leading-relaxed text-[var(--text-secondary)]">
<span>{error}</span>
<div>
<Button
type="button"
variant="secondary"
size="sm"
icon={<Settings2 size={13} />}
onClick={() => {
openPanel('settings')
}}
>
{t('ai.open_settings')}
</Button>
</div>
</div>
)}

<div
onDragOver={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
const file = event.dataTransfer.files[0]
if (file) void acceptFile(file)
}}
>
<div className="mb-1 text-[12px] font-medium text-[var(--text-secondary)]">{t('ai.input_label')}</div>
<Textarea
rows={7}
value={input}
placeholder={t('ai.input_placeholder')}
className={dragging ? 'border-[var(--accent)]' : undefined}
onChange={(event) => setInput(event.target.value)}
/>
</div>

{(output || running) && (
<div>
<div className="mb-1 flex items-center justify-between text-[12px] font-medium text-[var(--text-secondary)]">
<span>{t('ai.output_label')}</span>
{progress && <span>{t('ai.chunk_progress', { index: progress.index, total: progress.total })}</span>}
</div>
<pre className="max-h-[280px] overflow-auto whitespace-pre-wrap rounded-[var(--r-lg)] border border-[var(--border-subtle)] bg-[var(--bg-sunken)] p-3 text-[12px] leading-relaxed text-[var(--text-primary)]">
{output || t('ai.waiting')}
</pre>
</div>
)}
</div>
</Modal>
)
}
39 changes: 39 additions & 0 deletions src/client/features/ai/active-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { afterEach, describe, expect, it } from 'vitest'
import { getEditorSelection, setActiveEditorView } from './active-editor'

function viewWith(doc: string, from: number, to: number): EditorView {
return new EditorView({ state: EditorState.create({ doc, selection: { anchor: from, head: to } }) })
}

afterEach(() => {
setActiveEditorView(null)
})

describe('getEditorSelection', () => {
it('returns null when no editor has been registered', () => {
expect(getEditorSelection()).toBeNull()
})

it('returns null for an empty caret selection', () => {
setActiveEditorView(viewWith('hello world', 3, 3))
expect(getEditorSelection()).toBeNull()
})

it('returns null for a whitespace-only selection', () => {
setActiveEditorView(viewWith('a b', 1, 4))
expect(getEditorSelection()).toBeNull()
})

it('returns the selected text with its range', () => {
setActiveEditorView(viewWith('hello world', 6, 11))
expect(getEditorSelection()).toEqual({ text: 'world', from: 6, to: 11 })
})

it('forgets the editor once it is unregistered', () => {
setActiveEditorView(viewWith('hello world', 6, 11))
setActiveEditorView(null)
expect(getEditorSelection()).toBeNull()
})
})
21 changes: 21 additions & 0 deletions src/client/features/ai/active-editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { EditorView } from '@codemirror/view'

export interface EditorSelection {
text: string
from: number
to: number
}

let activeView: EditorView | null = null

export function setActiveEditorView(view: EditorView | null): void {
activeView = view
}

export function getEditorSelection(): EditorSelection | null {
if (!activeView) return null
const range = activeView.state.selection.main
const text = activeView.state.sliceDoc(range.from, range.to)
if (!text.trim()) return null
return { text, from: range.from, to: range.to }
}
28 changes: 28 additions & 0 deletions src/client/features/ai/open-tidy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useNotes } from '../../store/notes'
import { useUi } from '../../store/ui'
import { getEditorSelection } from './active-editor'
import { WHOLE_NOTE_RANGE, setAiPanelRequest } from './request'

export function openAiTidyForNote(noteId: string): void {
const selection = getEditorSelection()
const body = useNotes.getState().contents[noteId] ?? ''
setAiPanelRequest(
selection
? {
mode: 'tidy',
input: selection.text,
target: { noteId, from: selection.from, to: selection.to, originalText: selection.text },
}
: {
mode: 'tidy',
input: body,
target: { noteId, from: WHOLE_NOTE_RANGE, to: WHOLE_NOTE_RANGE, originalText: body },
},
)
useUi.getState().openPanel('ai')
}

export function openAiTidyForActiveNote(): void {
const noteId = useUi.getState().activeNoteId
if (noteId) openAiTidyForNote(noteId)
}
62 changes: 62 additions & 0 deletions src/client/features/ai/read-text-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { MAX_TEXT_FILE_BYTES, looksLikeText, readTextFile } from './read-text-file'

function fileOf(name: string, type: string, contents = 'hello'): File {
return new File([contents], name, { type })
}

function oversizedFile(name: string, type: string): File {
const file = fileOf(name, type)
Object.defineProperty(file, 'size', { value: MAX_TEXT_FILE_BYTES + 1 })
return file
}

describe('looksLikeText', () => {
it('accepts anything the platform already labels as text', () => {
expect(looksLikeText('notes.weird', 'text/plain')).toBe(true)
})

it('accepts known text extensions even when the platform reports no MIME type', () => {
expect(looksLikeText('README.md', '')).toBe(true)
expect(looksLikeText('rows.CSV', '')).toBe(true)
expect(looksLikeText('config.yaml', '')).toBe(true)
})

it('rejects binary types', () => {
expect(looksLikeText('photo.png', 'image/png')).toBe(false)
expect(looksLikeText('report.pdf', 'application/pdf')).toBe(false)
expect(looksLikeText('archive.zip', '')).toBe(false)
})
})

describe('readTextFile', () => {
it('reads a plain text file', async () => {
const result = await readTextFile(fileOf('note.txt', 'text/plain', 'body text'))
expect(result).toEqual({ ok: true, text: 'body text', name: 'note.txt' })
})

it('reads a Markdown file the platform gave no MIME type for', async () => {
const result = await readTextFile(fileOf('README.md', '', '# Title'))
expect(result.ok).toBe(true)
})

it('rejects a binary file instead of silently ignoring it', async () => {
expect(await readTextFile(fileOf('photo.png', 'image/png'))).toEqual({ ok: false, reason: 'not-text' })
})

it('rejects an oversized file rather than truncating it', async () => {
expect(await readTextFile(oversizedFile('huge.txt', 'text/plain'))).toEqual({ ok: false, reason: 'too-large' })
})

it('checks the type before the size, so a huge image is reported as not-text', async () => {
expect(await readTextFile(oversizedFile('huge.png', 'image/png'))).toEqual({ ok: false, reason: 'not-text' })
})

it('reports a read failure instead of throwing', async () => {
const broken = fileOf('note.txt', 'text/plain')
Object.defineProperty(broken, 'text', {
value: () => Promise.reject(new Error('disk gone')),
})
expect(await readTextFile(broken)).toEqual({ ok: false, reason: 'read-failed' })
})
})
Loading