SPB Git
5.9 KB · 60 lines tsx
1"use client";2import { useEffect, useRef, useState } from "react";3import type { Editor } from "@tiptap/react";4import { Sparkles, Replace, ArrowDownToLine, X, RefreshCw, Copy } from "lucide-react";5import { sse } from "@/lib/client/api";6import { Button } from "@/components/ui/button";7import { Markdown } from "@/components/markdown";8import { markdownToDoc } from "@/lib/editor/markdown-client";9import { toast } from "@/components/ui/toast";10export interface AIInlineState { mode: string; selection: string; replaceRange: { from: number; to: number } | null; insertAt: number; pageId?: string | null }11const PRESETS: { id: string; label: string }[] = [12  { 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" },13  { 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" },14];15export function AIInline({ state, editor, onClose }: { state: AIInlineState; editor: Editor; onClose: () => void }) {16  const [instruction, setInstruction] = useState(""); const [action, setAction] = useState(state.mode === "custom" || state.mode === "write" ? "" : state.mode);17  const [out, setOut] = useState(""); const [busy, setBusy] = useState(false); const [meta, setMeta] = useState<{ model?: string; costUsd?: number | null; latencyMs?: number } | null>(null);18  const abort = useRef<AbortController | null>(null); const inputRef = useRef<HTMLInputElement>(null);19  useEffect(() => { if (action && action !== "write") run(action); else inputRef.current?.focus(); /* eslint-disable-next-line */ }, []);20  async function run(act: string, instr?: string) {21    abort.current?.abort(); const ac = new AbortController(); abort.current = ac; setBusy(true); setOut(""); setMeta(null); setAction(act);22    const text = act === "write" || (act === "custom" && !state.selection) ? "" : state.selection;23    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);24    setBusy(false);25  }26  function apply(mode: "replace" | "insert") {27    const nodes = markdownToDoc(out);28    const wrapped = [{ type: "aiBlock", attrs: { model: meta?.model ?? null, prompt: (action || "custom") + (instruction ? `: ${instruction}` : ""), at: new Date().toISOString() }, content: nodes }];29    const isInlineOnly = nodes.length === 1 && nodes[0].type === "paragraph";30    if (mode === "replace" && state.replaceRange) editor.chain().focus().insertContentAt(state.replaceRange, isInlineOnly ? nodes : wrapped).run();31    else editor.chain().focus().insertContentAt(state.insertAt, wrapped).run();32    onClose();33  }34  return (35    <div className="fixed inset-x-2 bottom-2 z-40 mx-auto max-w-2xl rounded-xl border border-ai/30 bg-bg-elev shadow-pop fade-up md:absolute md:inset-x-auto md:bottom-auto md:left-0 md:right-0 md:top-0 md:mx-auto safe-bottom" onKeyDown={(e) => { if (e.key === "Escape") { abort.current?.abort(); onClose(); } }}>36      <div className="flex items-center gap-2 border-b border-line px-3 py-2">37        <Sparkles className="size-4 text-ai" />38        <input ref={inputRef} value={instruction} onChange={(e) => 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); }} />39        {busy ? <Button size="xs" variant="ghost" onClick={() => abort.current?.abort()}>Stop</Button> : <Button size="xs" variant="ai" onClick={() => run(state.selection ? "custom" : "write", instruction)} disabled={!instruction.trim()}>Run</Button>}40        <button className="rounded p-1 text-fg-faint hover:bg-bg-sunk" onClick={() => { abort.current?.abort(); onClose(); }}><X className="size-4" /></button>41      </div>42      {state.selection && !out && !busy && (43        <div className="flex flex-wrap gap-1 px-3 py-2">{PRESETS.map((p) => <button key={p.id} onClick={() => run(p.id)} className="rounded-md border border-line bg-bg px-2 py-1 text-[12px] hover:bg-bg-sunk">{p.label}</button>)}</div>44      )}45      {(out || busy) && (46        <div className="max-h-[45vh] overflow-y-auto px-4 py-3"><Markdown text={out || "…"} className="prose-md" />{busy && <span className="pulse-soft text-fg-faint">▍</span>}</div>47      )}48      {out && !busy && (49        <div className="flex flex-wrap items-center gap-1.5 border-t border-line px-3 py-2">50          {state.replaceRange && <Button size="sm" onClick={() => apply("replace")}><Replace /> Replace</Button>}51          <Button size="sm" variant={state.replaceRange ? "secondary" : "default"} onClick={() => apply("insert")}><ArrowDownToLine /> Insert below</Button>52          <Button size="sm" variant="ghost" onClick={() => run(action || "custom")}><RefreshCw /> Retry</Button>53          <Button size="sm" variant="ghost" onClick={() => { navigator.clipboard.writeText(out); toast("Copied"); }}><Copy /> Copy</Button>54          <span className="ml-auto text-[11px] text-fg-faint">{meta?.model}{meta?.latencyMs ? ` · ${(meta.latencyMs / 1000).toFixed(1)} s` : ""}{meta?.costUsd != null ? ` · $${meta.costUsd.toFixed(4)}` : ""}</span>55        </div>56      )}57    </div>58  );59}60
Files · 2c37cb5a3e2af18e5ba361996d453cab97da82c2
Menu

Theme