diff --git a/apps/desktop/src/renderer/components/CommandPalette.tsx b/apps/desktop/src/renderer/components/CommandPalette.tsx index a8ceb85e..59f36bfc 100644 --- a/apps/desktop/src/renderer/components/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/CommandPalette.tsx @@ -33,6 +33,7 @@ import { Maximize2, SquareArrowOutUpRight, Sparkles, + PenLine, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { CommandCategory } from '@dripnex/command-registry'; @@ -107,6 +108,7 @@ const ICON_MAP: Record = { Maximize2, SquareArrowOutUpRight, Sparkles, + PenLine, }; const CATEGORY_ORDER: { category: CommandCategory; label: string }[] = [ diff --git a/apps/desktop/src/renderer/components/editor/SelectionToolbar.module.css b/apps/desktop/src/renderer/components/editor/SelectionToolbar.module.css index eae94341..cce5e57e 100644 --- a/apps/desktop/src/renderer/components/editor/SelectionToolbar.module.css +++ b/apps/desktop/src/renderer/components/editor/SelectionToolbar.module.css @@ -99,6 +99,20 @@ color: var(--text-primary); } +.aiItem:disabled, +.aiSend:disabled, +.aiInput:disabled { + opacity: 0.6; + cursor: default; +} + +.aiError { + margin: 0 0 6px; + padding: 0 8px; + color: var(--danger, #c44); + font-size: 12px; +} + .alertMark { font-size: 13px; font-weight: 700; diff --git a/apps/desktop/src/renderer/components/editor/SelectionToolbar.tsx b/apps/desktop/src/renderer/components/editor/SelectionToolbar.tsx index 1d840d4e..a0d4871f 100644 --- a/apps/desktop/src/renderer/components/editor/SelectionToolbar.tsx +++ b/apps/desktop/src/renderer/components/editor/SelectionToolbar.tsx @@ -18,38 +18,55 @@ import { } from 'lucide-react'; import { insertGithubAlert, type GithubAlertKind } from '@dripnex/commands'; import { dispatchCommand, getEditorView } from '../../hooks/useCommandRegistry'; +import { requestInlineEdit } from '../../editor/inlineAi/request'; import styles from './SelectionToolbar.module.css'; const AI_ACTIONS = [ { id: 'mermaid', label: 'Create a Mermaid diagram', - system: - 'Convert the selection into a mermaid diagram. Reply with a mermaid fenced code block only.', + instruction: 'Convert the selection into a mermaid diagram.', + keepFence: true, }, { id: 'table', label: 'Convert to Markdown table', - system: - 'Convert the selection into a GitHub-flavored markdown table. Reply with the table only.', + instruction: 'Convert the selection into a GitHub-flavored markdown table.', }, { id: 'proofread', label: 'Proofread', - system: - 'Fix grammar and spelling. Keep the meaning and markdown. Reply with the revised text only.', + instruction: 'Fix grammar and spelling. Keep the meaning and markdown.', }, { id: 'reformat', label: 'Reformat', - system: - 'Clean the markdown structure. Do not change meaning. Reply with the revised markdown only.', + instruction: 'Clean the markdown structure. Do not change meaning.', }, { id: 'improve', label: 'Improve writing', - system: - 'Improve clarity and rhythm. Keep the author voice and markdown. Reply with the revised text only.', + instruction: 'Improve clarity and rhythm. Keep the author voice and markdown.', + }, + { + id: 'summarize', + label: 'Summarize', + instruction: 'Condense the selection into a concise summary.', + }, + { + id: 'bullets', + label: 'Convert to bullet list', + instruction: 'Rewrite the selection as a Markdown bulleted list.', + }, + { + id: 'tasks', + label: 'Convert to task list', + instruction: 'Rewrite the selection as a Markdown task list (- [ ] items).', + }, + { + id: 'headings', + label: 'Add headings', + instruction: 'Reorganize the selection with appropriate Markdown headings.', }, ] as const; @@ -72,6 +89,8 @@ export function SelectionToolbar() { const [aiOpen, setAiOpen] = useState(false); const [alertOpen, setAlertOpen] = useState(false); const [aiPrompt, setAiPrompt] = useState(''); + const [aiBusy, setAiBusy] = useState(false); + const [aiError, setAiError] = useState(null); const barRef = useRef(null); const aiOpenRef = useRef(false); aiOpenRef.current = aiOpen; @@ -86,9 +105,8 @@ export function SelectionToolbar() { const interacting = aiOpenRef.current || (barRef.current !== null && barRef.current.contains(document.activeElement)); - if (from === to) { + if (from === to && !aiOpenRef.current) { setVisible(false); - setAiOpen(false); setAlertOpen(false); return; } @@ -137,12 +155,52 @@ export function SelectionToolbar() { return () => document.removeEventListener('mousedown', onDown); }, [visible]); - const runAi = (system: string, instruction?: string) => { - window.dispatchEvent( - new CustomEvent('dripnex:ai:edit', { - detail: { system, instruction }, - }) - ); + useEffect(() => { + const onOpen = () => { + aiOpenRef.current = true; + setAlertOpen(false); + setAiError(null); + setAiOpen(true); + requestAnimationFrame(update); + }; + window.addEventListener('dripnex:ai:open-inline', onOpen); + return () => window.removeEventListener('dripnex:ai:open-inline', onOpen); + }, [update]); + + const runAi = async (instruction: string, keepFence = false) => { + const view = getEditorView(); + if (!view || aiBusy) return; + const { from, to } = view.state.selection.main; + const initialContent = view.state.doc.toString(); + setAiBusy(true); + setAiError(null); + const result = await requestInlineEdit({ + content: initialContent, + from, + to, + title: '', + instruction, + keepFence, + }); + setAiBusy(false); + if (!result.ok) { + setAiError( + result.reason === 'missing-key' + ? 'Set up AI in Settings → AI' + : 'Could not edit the selection' + ); + return; + } + if (view.state.doc.toString() !== initialContent) { + setAiError('Note changed — try again'); + return; + } + if (from > view.state.doc.length || to > view.state.doc.length) return; + view.dispatch({ + changes: { from, to, insert: result.text }, + selection: { anchor: from, head: from + result.text.length }, + }); + view.focus(); setAiOpen(false); setAiPrompt(''); }; @@ -304,30 +362,32 @@ export function SelectionToolbar() { className={styles.aiForm} onSubmit={event => { event.preventDefault(); - if (!aiPrompt.trim()) return; - runAi( - 'Follow the user instruction on the selected markdown. Reply with the result only.', - aiPrompt.trim() - ); + if (!aiPrompt.trim() || aiBusy) return; + void runAi(aiPrompt.trim()); }} > setAiPrompt(event.target.value)} - placeholder="Edit with AI…" + placeholder={aiBusy ? 'Editing…' : 'Edit with AI…'} + disabled={aiBusy} autoFocus /> - + {aiError ?

{aiError}

: null} {AI_ACTIONS.map(action => ( diff --git a/apps/desktop/src/renderer/editor/ai/collectChat.ts b/apps/desktop/src/renderer/editor/ai/collectChat.ts new file mode 100644 index 00000000..aca3a956 --- /dev/null +++ b/apps/desktop/src/renderer/editor/ai/collectChat.ts @@ -0,0 +1,67 @@ +import type { LLMEvent } from '@dripnex/ai-core'; +import type { AiAPI } from '../../../preload/api/ai'; + +export const AI_CHAT_TIMEOUT_MS = 8_000; + +export type ChatApi = Pick; + +/** Collect a streamed chat into one string. Does not log text (PHI). */ +export function collectChat( + ai: ChatApi, + request: Parameters[0], + timeoutMs = AI_CHAT_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + let requestId: string | null = null; + let text = ''; + const buffered: Array<{ id: string; event: LLMEvent }> = []; + let settled = false; + + function finish(value: string | null, error?: unknown): void { + if (settled) return; + settled = true; + clearTimeout(timeout); + off(); + if (error) reject(error); + else resolve(value); + } + + function apply(id: string, event: LLMEvent): void { + if (settled) return; + if (!requestId) { + buffered.push({ id, event }); + return; + } + if (id !== requestId) return; + switch (event.type) { + case 'text': + text += event.delta; + break; + case 'error': + finish(null, new Error(event.error)); + break; + case 'done': + finish(text); + break; + } + } + + const off = ai.onEvent((id, raw) => apply(id, raw as LLMEvent)); + const timeout = setTimeout(() => { + if (requestId) void ai.cancel(requestId); + finish(null); + }, timeoutMs); + + void ai + .chat(request) + .then(result => { + if (settled) { + void ai.cancel(result.requestId); + return; + } + requestId = result.requestId; + for (const item of buffered) apply(item.id, item.event); + }) + .catch(error => finish(null, error)); + }); +} diff --git a/apps/desktop/src/renderer/editor/inlineAi/__tests__/parse.test.ts b/apps/desktop/src/renderer/editor/inlineAi/__tests__/parse.test.ts new file mode 100644 index 00000000..41e84cb1 --- /dev/null +++ b/apps/desktop/src/renderer/editor/inlineAi/__tests__/parse.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { buildInlineEditPrompt, extractInlineReplacement, inlineEditContext } from '../parse'; + +describe('inlineEditContext', () => { + it('windows text around the selection', () => { + const content = 'aaa\nHello world\nzzz'; + const from = content.indexOf('Hello'); + const to = from + 'Hello'.length; + const ctx = inlineEditContext(content, from, to, 'Note', 'Proofread'); + expect(ctx.selection).toBe('Hello'); + expect(ctx.before).toBe('aaa\n'); + expect(ctx.after).toBe(' world\nzzz'); + expect(ctx.title).toBe('Note'); + }); +}); + +describe('buildInlineEditPrompt', () => { + it('includes the instruction and selection', () => { + const prompt = buildInlineEditPrompt({ + title: 'Ship', + selection: 'teh list', + before: '', + after: '', + instruction: 'Proofread', + }); + expect(prompt).toContain('TITLE: Ship'); + expect(prompt).toContain('teh list'); + expect(prompt).toContain('INSTRUCTION: Proofread'); + }); +}); + +describe('extractInlineReplacement', () => { + it('returns plain text', () => { + expect(extractInlineReplacement('Hello world', false)).toBe('Hello world'); + }); + + it('unwraps a single markdown fence', () => { + expect(extractInlineReplacement('```markdown\nHello\n```', false)).toBe('Hello\n'); + }); + + it('keeps a fence when asked', () => { + expect(extractInlineReplacement('```mermaid\ngraph TD\n```', true)).toBe( + '```mermaid\ngraph TD\n```' + ); + }); + + it('preserves indented list items', () => { + expect(extractInlineReplacement(' - child', false)).toBe(' - child'); + }); + + it('preserves indented code inside a wrapping fence', () => { + expect(extractInlineReplacement('```\n code\n```', false)).toBe(' code\n'); + }); + + it('rejects empty output', () => { + expect(extractInlineReplacement(' ', false)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/editor/inlineAi/parse.ts b/apps/desktop/src/renderer/editor/inlineAi/parse.ts new file mode 100644 index 00000000..cd201490 --- /dev/null +++ b/apps/desktop/src/renderer/editor/inlineAi/parse.ts @@ -0,0 +1,64 @@ +const CONTEXT_CHARS = 2000; + +export interface InlineEditContext { + title: string; + selection: string; + before: string; + after: string; + instruction: string; +} + +export function inlineEditContext( + content: string, + from: number, + to: number, + title: string, + instruction: string +): InlineEditContext { + const start = Math.max(0, Math.min(from, content.length)); + const end = Math.max(start, Math.min(to, content.length)); + const beforeFrom = Math.max(0, start - CONTEXT_CHARS); + const afterTo = Math.min(content.length, end + CONTEXT_CHARS); + return { + title: title.trim() || 'Untitled', + selection: content.slice(start, end), + before: content.slice(beforeFrom, start), + after: content.slice(end, afterTo), + instruction, + }; +} + +export function buildInlineEditPrompt(ctx: InlineEditContext): string { + const body = ctx.selection || '(empty — insert new markdown at the cursor)'; + return [ + 'Rewrite ONLY the selection. Reply with the replacement markdown only.', + 'No explanation. Do not wrap the result in a fence unless the instruction asks for a fenced block.', + 'Preserve surrounding Markdown style and indentation.', + '', + `TITLE: ${ctx.title}`, + '', + 'BEFORE:', + ctx.before || '(start of note)', + '', + 'SELECTION:', + body, + '', + 'AFTER:', + ctx.after || '(end of note)', + '', + `INSTRUCTION: ${ctx.instruction}`, + ].join('\n'); +} + +/** Strip a single wrapping fence. Linear scan — no regex. */ +export function extractInlineReplacement(raw: string, keepFence: boolean): string | null { + if (!raw.trim()) return null; + if (keepFence) return raw; + const text = raw.trim(); + if (!text.startsWith('```')) return raw; + const firstNl = text.indexOf('\n'); + if (firstNl === -1) return text; + if (!text.endsWith('```')) return text; + const inner = text.slice(firstNl + 1, text.length - 3); + return inner || text; +} diff --git a/apps/desktop/src/renderer/editor/inlineAi/request.ts b/apps/desktop/src/renderer/editor/inlineAi/request.ts new file mode 100644 index 00000000..6722de80 --- /dev/null +++ b/apps/desktop/src/renderer/editor/inlineAi/request.ts @@ -0,0 +1,53 @@ +import { selectAi, useSettingsStore } from '../../stores/settings'; +import { resolveAiAuth } from '../../components/ai/resolveAiAuth'; +import { collectChat } from '../ai/collectChat'; +import { buildInlineEditPrompt, extractInlineReplacement, inlineEditContext } from './parse'; + +const INLINE_MAX_TOKENS = 2048; + +export type InlineEditResult = + | { ok: true; text: string } + | { ok: false; reason: 'missing-key' | 'empty' | 'failed' }; + +export async function requestInlineEdit(input: { + content: string; + from: number; + to: number; + title: string; + instruction: string; + keepFence?: boolean; +}): Promise { + const ai = window.dripnex?.ai; + if (!ai?.chat) return { ok: false, reason: 'failed' }; + + const auth = resolveAiAuth(selectAi(useSettingsStore.getState()), () => undefined); + if (auth.missingKey) return { ok: false, reason: 'missing-key' }; + + const ctx = inlineEditContext( + input.content, + input.from, + input.to, + input.title, + input.instruction + ); + const prompt = buildInlineEditPrompt(ctx); + + try { + const raw = await collectChat(ai, { + query: prompt, + currentNote: null, + relevantNotes: [], + history: [], + mode: 'chat', + provider: auth.provider, + model: auth.model, + providerConfig: { apiKey: auth.apiKey, baseUrl: auth.baseUrl }, + maxResponseTokens: INLINE_MAX_TOKENS, + }); + const text = raw ? extractInlineReplacement(raw, Boolean(input.keepFence)) : null; + if (!text) return { ok: false, reason: 'empty' }; + return { ok: true, text }; + } catch { + return { ok: false, reason: 'failed' }; + } +} diff --git a/apps/desktop/src/renderer/editor/nes/request.ts b/apps/desktop/src/renderer/editor/nes/request.ts index 54688cf1..4fe2c2e2 100644 --- a/apps/desktop/src/renderer/editor/nes/request.ts +++ b/apps/desktop/src/renderer/editor/nes/request.ts @@ -1,12 +1,10 @@ -import type { LLMEvent } from '@dripnex/ai-core'; -import type { AiAPI } from '../../../preload/api/ai'; import { selectAi, useSettingsStore } from '../../stores/settings'; import { resolveAiAuth } from '../../components/ai/resolveAiAuth'; +import { collectChat } from '../ai/collectChat'; import { buildNesPrompt, extractNesInsertion, nesLineContext } from './parse'; import type { NesCompleteInput } from './extension'; const NES_MAX_TOKENS = 96; -const NES_CHAT_TIMEOUT_MS = 8_000; /** * Ask the configured provider for a one-line continuation. @@ -40,61 +38,3 @@ export async function requestNesCompletion(input: NesCompleteInput): Promise; - -function collectChat(ai: ChatApi, request: Parameters[0]): Promise { - return new Promise((resolve, reject) => { - let requestId: string | null = null; - let text = ''; - const buffered: Array<{ id: string; event: LLMEvent }> = []; - let settled = false; - - function finish(value: string | null, error?: unknown): void { - if (settled) return; - settled = true; - clearTimeout(timeout); - off(); - if (error) reject(error); - else resolve(value); - } - - function apply(id: string, event: LLMEvent): void { - if (settled) return; - if (!requestId) { - buffered.push({ id, event }); - return; - } - if (id !== requestId) return; - switch (event.type) { - case 'text': - text += event.delta; - break; - case 'error': - finish(null, new Error(event.error)); - break; - case 'done': - finish(text); - break; - } - } - - const off = ai.onEvent((id, raw) => apply(id, raw as LLMEvent)); - const timeout = setTimeout(() => { - if (requestId) void ai.cancel(requestId); - finish(null); - }, NES_CHAT_TIMEOUT_MS); - - void ai - .chat(request) - .then(result => { - if (settled) { - void ai.cancel(result.requestId); - return; - } - requestId = result.requestId; - for (const item of buffered) apply(item.id, item.event); - }) - .catch(error => finish(null, error)); - }); -} diff --git a/apps/desktop/src/renderer/hooks/useCommandRegistry.ts b/apps/desktop/src/renderer/hooks/useCommandRegistry.ts index b7cb2280..a9d6f9ab 100644 --- a/apps/desktop/src/renderer/hooks/useCommandRegistry.ts +++ b/apps/desktop/src/renderer/hooks/useCommandRegistry.ts @@ -28,6 +28,11 @@ import { import { followWikilinkAtCursor } from '../utils/followWikilinkAtCursor'; import { acceptNes, dismissNes, triggerNes } from '../editor/nes/extension'; +function openInlineAi(): boolean { + window.dispatchEvent(new Event('dripnex:ai:open-inline')); + return true; +} + // --- Singleton registry --- export const registry = new CommandRegistry(); @@ -77,6 +82,7 @@ const editorExecutors: Record boolean | void> = { }, 'editor:undo': undoChange, 'editor:redo': redoChange, + 'editor:edit-with-ai': () => openInlineAi(), 'editor:trigger-nes': triggerNes, 'editor:accept-nes': acceptNes, 'editor:dismiss-nes': dismissNes, diff --git a/packages/command-registry/src/definitions/editor.ts b/packages/command-registry/src/definitions/editor.ts index 00ac92d0..6bc6bdf6 100644 --- a/packages/command-registry/src/definitions/editor.ts +++ b/packages/command-registry/src/definitions/editor.ts @@ -177,6 +177,15 @@ export const editorCommands: CommandDefinition[] = [ icon: 'Redo2', showInPalette: true, }, + { + id: 'editor:edit-with-ai', + name: 'Edit with AI', + category: 'editor', + context: 'editor', + defaultKeybinding: { key: 'Enter', modifiers: ['Mod'] }, + icon: 'PenLine', + showInPalette: true, + }, { id: 'editor:trigger-nes', name: 'Suggest Next Edit',