463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { Node, mergeAttributes, nodeInputRule } from "@tiptap/react";
|
||
|
|
import {
|
||
|
|
NodeViewWrapper,
|
||
|
|
ReactNodeViewRenderer,
|
||
|
|
type NodeViewProps,
|
||
|
|
} from "@tiptap/react";
|
||
|
|
import { streamText } from "ai";
|
||
|
|
import {
|
||
|
|
Check,
|
||
|
|
Languages,
|
||
|
|
Loader2,
|
||
|
|
Minimize2,
|
||
|
|
RefreshCw,
|
||
|
|
Sparkles,
|
||
|
|
TextQuote,
|
||
|
|
Trash2,
|
||
|
|
Wand2,
|
||
|
|
X,
|
||
|
|
} from "lucide-react";
|
||
|
|
import * as React from "react";
|
||
|
|
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
import {
|
||
|
|
createOpenAIClient,
|
||
|
|
EDITOR_SYSTEM_PROMPT,
|
||
|
|
expand,
|
||
|
|
rewrite,
|
||
|
|
selectOpenAIModel,
|
||
|
|
summarize,
|
||
|
|
translate,
|
||
|
|
} from "../../../../../packages/ai/src";
|
||
|
|
|
||
|
|
type AiStatus = "idle" | "loading" | "done" | "error";
|
||
|
|
|
||
|
|
function escapeHtml(s: string) {
|
||
|
|
return s
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">")
|
||
|
|
.replace(/"/g, """);
|
||
|
|
}
|
||
|
|
|
||
|
|
function plainTextToHtml(text: string) {
|
||
|
|
const trimmed = text.trim();
|
||
|
|
if (!trimmed) return "<p></p>";
|
||
|
|
return trimmed
|
||
|
|
.split(/\n\n+/)
|
||
|
|
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br/>")}</p>`)
|
||
|
|
.join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runEditorGeneration(prompt: string, signal: AbortSignal) {
|
||
|
|
const client = createOpenAIClient();
|
||
|
|
if (!client.ok) {
|
||
|
|
throw new Error(
|
||
|
|
"OpenAI API key is not configured. Set OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const model = selectOpenAIModel(client.provider, "gpt-4o-mini");
|
||
|
|
const result = streamText({
|
||
|
|
model,
|
||
|
|
abortSignal: signal,
|
||
|
|
system: EDITOR_SYSTEM_PROMPT,
|
||
|
|
prompt: prompt.trim(),
|
||
|
|
});
|
||
|
|
let out = "";
|
||
|
|
for await (const chunk of result.textStream) {
|
||
|
|
out += chunk;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runActionPair(
|
||
|
|
systemPrompt: string,
|
||
|
|
userPrompt: string,
|
||
|
|
signal: AbortSignal,
|
||
|
|
) {
|
||
|
|
const client = createOpenAIClient();
|
||
|
|
if (!client.ok) {
|
||
|
|
throw new Error(
|
||
|
|
"OpenAI API key is not configured. Set OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const model = selectOpenAIModel(client.provider, "gpt-4o-mini");
|
||
|
|
const result = streamText({
|
||
|
|
model,
|
||
|
|
abortSignal: signal,
|
||
|
|
system: systemPrompt,
|
||
|
|
prompt: userPrompt,
|
||
|
|
});
|
||
|
|
let out = "";
|
||
|
|
for await (const chunk of result.textStream) {
|
||
|
|
out += chunk;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function LoadingDots() {
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center gap-0.5 px-1" aria-hidden>
|
||
|
|
{[0, 1, 2].map((i) => (
|
||
|
|
<span
|
||
|
|
key={i}
|
||
|
|
className="inline-block size-1.5 animate-pulse rounded-full bg-primary/70"
|
||
|
|
style={{ animationDelay: `${i * 120}ms` }}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function AiBlockView(props: NodeViewProps) {
|
||
|
|
const { node, editor, getPos, updateAttributes, deleteNode } = props;
|
||
|
|
const status = (node.attrs.status as AiStatus) ?? "idle";
|
||
|
|
const prompt = (node.attrs.prompt as string) ?? "";
|
||
|
|
const resultHtml = (node.attrs.resultHtml as string) ?? "";
|
||
|
|
const errorMessage = (node.attrs.errorMessage as string) ?? "";
|
||
|
|
|
||
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||
|
|
const abortRef = React.useRef<AbortController | null>(null);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (status === "idle" && !resultHtml) {
|
||
|
|
inputRef.current?.focus();
|
||
|
|
}
|
||
|
|
}, [status, resultHtml]);
|
||
|
|
|
||
|
|
const [selTick, setSelTick] = React.useState(0);
|
||
|
|
React.useEffect(() => {
|
||
|
|
const bump = () => setSelTick((n) => n + 1);
|
||
|
|
editor.on("selectionUpdate", bump);
|
||
|
|
editor.on("transaction", bump);
|
||
|
|
return () => {
|
||
|
|
editor.off("selectionUpdate", bump);
|
||
|
|
editor.off("transaction", bump);
|
||
|
|
};
|
||
|
|
}, [editor]);
|
||
|
|
|
||
|
|
const selectionText = React.useMemo(() => {
|
||
|
|
const { from, to } = editor.state.selection;
|
||
|
|
if (from === to) return "";
|
||
|
|
return editor.state.doc.textBetween(from, to, "\n");
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync selection text when editor updates
|
||
|
|
}, [editor, selTick]);
|
||
|
|
|
||
|
|
const stopGeneration = React.useCallback(() => {
|
||
|
|
abortRef.current?.abort();
|
||
|
|
abortRef.current = null;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const runPrompt = React.useCallback(
|
||
|
|
async (text: string) => {
|
||
|
|
const trimmed = text.trim();
|
||
|
|
if (!trimmed) return;
|
||
|
|
stopGeneration();
|
||
|
|
const ac = new AbortController();
|
||
|
|
abortRef.current = ac;
|
||
|
|
updateAttributes({
|
||
|
|
prompt: trimmed,
|
||
|
|
status: "loading",
|
||
|
|
resultHtml: "",
|
||
|
|
errorMessage: "",
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
const out = await runEditorGeneration(trimmed, ac.signal);
|
||
|
|
updateAttributes({
|
||
|
|
status: "done",
|
||
|
|
resultHtml: plainTextToHtml(out),
|
||
|
|
errorMessage: "",
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
if ((e as Error).name === "AbortError") return;
|
||
|
|
updateAttributes({
|
||
|
|
status: "error",
|
||
|
|
errorMessage: (e as Error).message ?? "Something went wrong.",
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
abortRef.current = null;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[stopGeneration, updateAttributes],
|
||
|
|
);
|
||
|
|
|
||
|
|
const runQuick = React.useCallback(
|
||
|
|
async (kind: "summarize" | "expand" | "simplify" | "translate") => {
|
||
|
|
const base = selectionText.trim() || prompt.trim();
|
||
|
|
if (!base) {
|
||
|
|
updateAttributes({
|
||
|
|
status: "error",
|
||
|
|
errorMessage: "Select text in the editor or enter a prompt first.",
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
stopGeneration();
|
||
|
|
const ac = new AbortController();
|
||
|
|
abortRef.current = ac;
|
||
|
|
updateAttributes({
|
||
|
|
status: "loading",
|
||
|
|
resultHtml: "",
|
||
|
|
errorMessage: "",
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
let pair;
|
||
|
|
if (kind === "summarize") pair = summarize(base);
|
||
|
|
else if (kind === "expand") pair = expand(base);
|
||
|
|
else if (kind === "simplify") pair = rewrite(base, "concise");
|
||
|
|
else pair = translate(base, "Spanish");
|
||
|
|
|
||
|
|
const out = await runActionPair(
|
||
|
|
pair.systemPrompt,
|
||
|
|
pair.userPrompt,
|
||
|
|
ac.signal,
|
||
|
|
);
|
||
|
|
updateAttributes({
|
||
|
|
status: "done",
|
||
|
|
prompt: prompt || `[${kind}]`,
|
||
|
|
resultHtml: plainTextToHtml(out),
|
||
|
|
errorMessage: "",
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
if ((e as Error).name === "AbortError") return;
|
||
|
|
updateAttributes({
|
||
|
|
status: "error",
|
||
|
|
errorMessage: (e as Error).message ?? "Something went wrong.",
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
abortRef.current = null;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[prompt, selectionText, stopGeneration, updateAttributes],
|
||
|
|
);
|
||
|
|
|
||
|
|
const accept = React.useCallback(() => {
|
||
|
|
const pos = getPos();
|
||
|
|
if (typeof pos !== "number") return;
|
||
|
|
const html = resultHtml || "<p></p>";
|
||
|
|
editor
|
||
|
|
.chain()
|
||
|
|
.focus()
|
||
|
|
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
||
|
|
.insertContentAt(pos, html)
|
||
|
|
.run();
|
||
|
|
}, [editor, getPos, node.nodeSize, resultHtml]);
|
||
|
|
|
||
|
|
const discard = React.useCallback(() => {
|
||
|
|
deleteNode();
|
||
|
|
}, [deleteNode]);
|
||
|
|
|
||
|
|
const regenerate = React.useCallback(() => {
|
||
|
|
if (prompt.trim()) void runPrompt(prompt);
|
||
|
|
}, [prompt, runPrompt]);
|
||
|
|
|
||
|
|
const onKeyDownInput = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
|
|
if (e.key === "Enter") {
|
||
|
|
e.preventDefault();
|
||
|
|
void runPrompt(inputRef.current?.value ?? "");
|
||
|
|
}
|
||
|
|
if (e.key === "Escape") {
|
||
|
|
e.preventDefault();
|
||
|
|
discard();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<NodeViewWrapper
|
||
|
|
className={cn(
|
||
|
|
"ai-block my-4 rounded-xl border border-border/80 bg-gradient-to-br from-muted/40 via-background to-muted/30 p-4 shadow-sm ring-1 ring-border/50",
|
||
|
|
props.selected && "ring-2 ring-primary/35",
|
||
|
|
)}
|
||
|
|
data-type="ai-block"
|
||
|
|
>
|
||
|
|
<div className="mb-3 flex items-center gap-2">
|
||
|
|
<span className="flex size-8 items-center justify-center rounded-lg border border-border bg-background shadow-sm">
|
||
|
|
<Sparkles className="size-4 text-primary" />
|
||
|
|
</span>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm font-semibold tracking-tight">
|
||
|
|
AI assistant
|
||
|
|
</span>
|
||
|
|
<Badge variant="muted" className="text-[10px] uppercase">
|
||
|
|
/ai
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
Ask in natural language, then accept or regenerate.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{status !== "loading" && !(status === "done" && resultHtml) ? (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Input
|
||
|
|
ref={inputRef}
|
||
|
|
className="h-11 border-dashed bg-background/80 text-[15px]"
|
||
|
|
placeholder='e.g. "Write a short PRD for mobile offline mode"'
|
||
|
|
defaultValue={prompt}
|
||
|
|
key={prompt}
|
||
|
|
onKeyDown={onKeyDownInput}
|
||
|
|
/>
|
||
|
|
<div className="flex flex-wrap gap-1.5">
|
||
|
|
<QuickBtn
|
||
|
|
icon={TextQuote}
|
||
|
|
label="Summarize"
|
||
|
|
onClick={() => void runQuick("summarize")}
|
||
|
|
/>
|
||
|
|
<QuickBtn
|
||
|
|
icon={Wand2}
|
||
|
|
label="Expand"
|
||
|
|
onClick={() => void runQuick("expand")}
|
||
|
|
/>
|
||
|
|
<QuickBtn
|
||
|
|
icon={Minimize2}
|
||
|
|
label="Simplify"
|
||
|
|
onClick={() => void runQuick("simplify")}
|
||
|
|
/>
|
||
|
|
<QuickBtn
|
||
|
|
icon={Languages}
|
||
|
|
label="Translate"
|
||
|
|
onClick={() => void runQuick("translate")}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<p className="text-[11px] text-muted-foreground">
|
||
|
|
Enter to run · Esc to remove block · Quick actions use selected text
|
||
|
|
or your prompt
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{status === "loading" ? (
|
||
|
|
<div className="flex min-h-[52px] items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
|
||
|
|
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
|
||
|
|
<span>Generating</span>
|
||
|
|
<LoadingDots />
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{status === "error" && errorMessage ? (
|
||
|
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||
|
|
{errorMessage}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{status === "done" && resultHtml ? (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div
|
||
|
|
className="editor-prose max-h-[min(360px,50vh)] overflow-y-auto rounded-lg border border-border bg-card px-3 py-2.5 text-[15px] leading-relaxed [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0"
|
||
|
|
// eslint-disable-next-line react/no-danger
|
||
|
|
dangerouslySetInnerHTML={{ __html: resultHtml }}
|
||
|
|
/>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
<Button type="button" size="sm" onClick={accept}>
|
||
|
|
<Check className="mr-1.5 size-3.5" />
|
||
|
|
Accept
|
||
|
|
</Button>
|
||
|
|
<Button type="button" size="sm" variant="secondary" onClick={regenerate}>
|
||
|
|
<RefreshCw className="mr-1.5 size-3.5" />
|
||
|
|
Regenerate
|
||
|
|
</Button>
|
||
|
|
<Button type="button" size="sm" variant="ghost" onClick={discard}>
|
||
|
|
<Trash2 className="mr-1.5 size-3.5" />
|
||
|
|
Discard
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{status === "idle" ||
|
||
|
|
status === "loading" ||
|
||
|
|
status === "error" ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="mt-2 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||
|
|
contentEditable={false}
|
||
|
|
onClick={discard}
|
||
|
|
>
|
||
|
|
<X className="size-3" />
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
</NodeViewWrapper>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function QuickBtn({
|
||
|
|
icon: Icon,
|
||
|
|
label,
|
||
|
|
onClick,
|
||
|
|
}: {
|
||
|
|
icon: React.ComponentType<{ className?: string }>;
|
||
|
|
label: string;
|
||
|
|
onClick: () => void;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 gap-1.5 text-xs"
|
||
|
|
onClick={onClick}
|
||
|
|
>
|
||
|
|
<Icon className="size-3.5 opacity-80" />
|
||
|
|
{label}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const AiBlock = Node.create({
|
||
|
|
name: "aiBlock",
|
||
|
|
group: "block",
|
||
|
|
atom: true,
|
||
|
|
draggable: true,
|
||
|
|
|
||
|
|
addAttributes() {
|
||
|
|
return {
|
||
|
|
prompt: { default: "" },
|
||
|
|
status: { default: "idle" },
|
||
|
|
resultHtml: { default: "" },
|
||
|
|
errorMessage: { default: "" },
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
parseHTML() {
|
||
|
|
return [{ tag: 'div[data-type="ai-block"]' }];
|
||
|
|
},
|
||
|
|
|
||
|
|
renderHTML({
|
||
|
|
HTMLAttributes,
|
||
|
|
}: {
|
||
|
|
HTMLAttributes: Record<string, unknown>;
|
||
|
|
}) {
|
||
|
|
return [
|
||
|
|
"div",
|
||
|
|
mergeAttributes(HTMLAttributes, { "data-type": "ai-block" }),
|
||
|
|
];
|
||
|
|
},
|
||
|
|
|
||
|
|
addNodeView() {
|
||
|
|
return ReactNodeViewRenderer(AiBlockView);
|
||
|
|
},
|
||
|
|
|
||
|
|
addInputRules() {
|
||
|
|
return [
|
||
|
|
nodeInputRule({
|
||
|
|
find: /(^|\s)\/ai$/,
|
||
|
|
type: this.type,
|
||
|
|
getAttributes: () => ({
|
||
|
|
prompt: "",
|
||
|
|
status: "idle",
|
||
|
|
resultHtml: "",
|
||
|
|
errorMessage: "",
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
},
|
||
|
|
});
|