"use client"; import { useEffect, useRef, useState } from "react"; import type { Editor } from "@tiptap/react"; import { Sparkles, Replace, ArrowDownToLine, X, RefreshCw, Copy } from "lucide-react"; import { sse } from "@/lib/client/api"; import { Button } from "@/components/ui/button"; import { Markdown } from "@/components/markdown"; import { markdownToDoc } from "@/lib/editor/markdown-client"; import { toast } from "@/components/ui/toast"; export interface AIInlineState { mode: string; selection: string; replaceRange: { from: number; to: number } | null; insertAt: number; pageId?: string | null } const PRESETS: { id: string; label: string }[] = [ { id: "rewrite", label: "Improve" }, { id: "shorten", label: "Shorten" }, { id: "expand", label: "Expand" }, { id: "fix", label: "Fix grammar" }, { id: "formal", label: "Formal" }, { id: "simplify", label: "Simplify" }, { id: "summarize", label: "Summarize" }, { id: "translate_fr", label: "→ Français" }, { id: "translate_en", label: "→ English" }, { id: "explain_math", label: "Explain math" }, { id: "table", label: "To table" }, { id: "action_items", label: "Action items" }, { id: "continue", label: "Continue" }, ]; export function AIInline({ state, editor, onClose }: { state: AIInlineState; editor: Editor; onClose: () => void }) { const [instruction, setInstruction] = useState(""); const [action, setAction] = useState(state.mode === "custom" || state.mode === "write" ? "" : state.mode); const [out, setOut] = useState(""); const [busy, setBusy] = useState(false); const [meta, setMeta] = useState<{ model?: string; costUsd?: number | null; latencyMs?: number } | null>(null); const abort = useRef(null); const inputRef = useRef(null); useEffect(() => { if (action && action !== "write") run(action); else inputRef.current?.focus(); /* eslint-disable-next-line */ }, []); async function run(act: string, instr?: string) { abort.current?.abort(); const ac = new AbortController(); abort.current = ac; setBusy(true); setOut(""); setMeta(null); setAction(act); const text = act === "write" || (act === "custom" && !state.selection) ? "" : state.selection; await sse("/api/ai/action", { action: act === "write" ? "custom" : act, text: text || "(empty — generate new content from the instruction)", instruction: instr ?? instruction }, (ev) => { if (ev.type === "text") setOut((s) => s + ev.delta); else if (ev.type === "meta") setMeta({ model: ev.model }); else if (ev.type === "done") setMeta((m) => ({ ...m, costUsd: ev.costUsd, latencyMs: ev.latencyMs })); else if (ev.type === "error") toast("AI error", { description: ev.message, tone: "danger" }); }, ac.signal); setBusy(false); } function apply(mode: "replace" | "insert") { const nodes = markdownToDoc(out); const wrapped = [{ type: "aiBlock", attrs: { model: meta?.model ?? null, prompt: (action || "custom") + (instruction ? `: ${instruction}` : ""), at: new Date().toISOString() }, content: nodes }]; const isInlineOnly = nodes.length === 1 && nodes[0].type === "paragraph"; if (mode === "replace" && state.replaceRange) editor.chain().focus().insertContentAt(state.replaceRange, isInlineOnly ? nodes : wrapped).run(); else editor.chain().focus().insertContentAt(state.insertAt, wrapped).run(); onClose(); } return (
{ if (e.key === "Escape") { abort.current?.abort(); onClose(); } }}>
setInstruction(e.target.value)} placeholder={state.selection ? "Tell AI what to do with the selection…" : "What should AI write here?"} className="flex-1 bg-transparent text-sm outline-none placeholder:text-fg-faint" onKeyDown={(e) => { if (e.key === "Enter" && instruction.trim()) run(state.selection ? "custom" : "write", instruction); }} /> {busy ? : }
{state.selection && !out && !busy && (
{PRESETS.map((p) => )}
)} {(out || busy) && (
{busy && ▍}
)} {out && !busy && (
{state.replaceRange && } {meta?.model}{meta?.latencyMs ? ` · ${(meta.latencyMs / 1000).toFixed(1)} s` : ""}{meta?.costUsd != null ? ` · $${meta.costUsd.toFixed(4)}` : ""}
)}
); }