Files
sui_blog/src/components/admin/AiAssistant.tsx
T
huxu 707d065edb feat: AI 写作助手重构 — 右侧面板、选中文本、撤销、生成摘要/标题
- AiAssistant 改为右侧粘性面板,按文本处理/翻译/智能生成分组
- 支持选中文本局部处理(未选中时处理全文)
- 替换/追加内容后支持撤销恢复
- 新增「生成标题」「生成摘要」按钮,结果可一键填入表单
- 编辑器最小高度从 300px 提高到 500px
- admin layout 去除 max-w-5xl 限制,充分利用宽屏空间
- 添加 .env.example 模板,.gitignore 放行
- package.json 添加 @tailwindcss/oxide-win32-x64-msvc optionalDependencies(兼容 Windows)
2026-06-24 20:06:49 +08:00

445 lines
16 KiB
TypeScript

"use client";
import { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Sparkles,
Wand2,
Maximize2,
Minimize2,
ArrowRight,
Languages,
FileText,
Bug,
Loader2,
Copy,
Check,
Heading,
Send,
RefreshCw,
} from "lucide-react";
interface AiAssistantProps {
content: string;
selectedText?: string;
onInsert: (text: string, mode: "replace" | "append") => void;
onGenerateExcerpt?: (text: string) => void;
onGenerateTitle?: (text: string) => void;
}
type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar";
export default function AiAssistant({
content,
selectedText,
onInsert,
onGenerateExcerpt,
onGenerateTitle,
}: AiAssistantProps) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState("");
const [resultLabel, setResultLabel] = useState("");
const [generateTarget, setGenerateTarget] = useState<"title" | "excerpt" | null>(null);
const [customPrompt, setCustomPrompt] = useState("");
const [copied, setCopied] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
setGenerateTarget(null);
}, []);
function getEffectiveText(): string {
if (selectedText?.trim()) return selectedText.trim();
return content.replace(/<[^>]+>/g, "").trim();
}
const hasSelection = !!(selectedText?.trim());
async function runAction(action?: Action, label?: string) {
const text = getEffectiveText();
if (!text && action !== "summarize") {
setResult("请先输入文章内容或选中文字");
setResultLabel("");
return;
}
setLoading(true);
setResult("");
setResultLabel(label || "");
setGenerateTarget(null);
abortRef.current = new AbortController();
try {
const res = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: customPrompt || undefined,
selectedText: text || undefined,
action,
}),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
setResult(`错误:${err.error || "请求失败"}`);
setLoading(false);
return;
}
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
accumulated += delta;
setResult(accumulated);
}
} catch { /* ignore */ }
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setResult((prev) => prev + "\n\n[已停止]");
} else {
setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`);
}
} finally {
setLoading(false);
abortRef.current = null;
setGenerateTarget(null);
}
}
async function runGenerate(target: "title" | "excerpt") {
const text = content.replace(/<[^>]+>/g, "").trim();
if (!text) {
setResult("请先输入文章内容");
setResultLabel("");
return;
}
setLoading(true);
setResult("");
setResultLabel(target === "title" ? "生成标题" : "生成摘要");
setGenerateTarget(target);
abortRef.current = new AbortController();
const promptText = target === "title"
? `请为以下文章生成一个简洁的中文标题(不超过30字),直接输出标题,不要引号:\n\n${text}`
: `请为以下文章写一段摘要(2-3句话,不超过200字),直接输出摘要:\n\n${text}`;
try {
const res = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: promptText, selectedText: text }),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
setResult(`错误:${err.error || "请求失败"}`);
setLoading(false);
setGenerateTarget(null);
return;
}
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
accumulated += delta;
setResult(accumulated);
}
} catch { /* ignore */ }
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setResult((prev) => prev + "\n\n[已停止]");
} else {
setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`);
}
} finally {
setLoading(false);
abortRef.current = null;
setGenerateTarget(null);
}
}
function applyGenerated() {
if (generateTarget === "title" && onGenerateTitle && result) {
onGenerateTitle(result);
setResult("");
setResultLabel("");
setGenerateTarget(null);
} else if (generateTarget === "excerpt" && onGenerateExcerpt && result) {
onGenerateExcerpt(result);
setResult("");
setResultLabel("");
setGenerateTarget(null);
}
}
function handleCopy() {
navigator.clipboard.writeText(result);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleInsert(mode: "replace" | "append") {
onInsert(result, mode);
setResult("");
setResultLabel("");
}
return (
<div className="sticky top-6 space-y-5">
{/* ── 标题栏 ── */}
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Sparkles className="w-3.5 h-3.5 text-primary" />
</div>
<span className="font-sans text-sm font-semibold text-foreground">AI 写作助手</span>
</div>
{/* ── 选中提示 ── */}
{hasSelection && (
<div className="rounded-lg bg-primary/5 border border-primary/20 px-3 py-2">
<p className="font-sans text-xs text-primary/70">
📌 已选中 <span className="font-medium">{selectedText.length}</span> 字,AI 将只处理选中内容
</p>
</div>
)}
{/* ── 文本处理 ── */}
<div className="space-y-2">
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">文本处理</p>
<div className="grid grid-cols-2 gap-1.5">
{[
{ key: "polish" as Action, label: "润色", icon: <Wand2 className="w-3 h-3" /> },
{ key: "expand" as Action, label: "扩写", icon: <Maximize2 className="w-3 h-3" /> },
{ key: "shorten" as Action, label: "精简", icon: <Minimize2 className="w-3 h-3" /> },
{ key: "continue" as Action, label: "续写", icon: <ArrowRight className="w-3 h-3" /> },
{ key: "fix_grammar" as Action, label: "纠错", icon: <Bug className="w-3 h-3" /> },
{ key: "summarize" as Action, label: "摘要", icon: <FileText className="w-3 h-3" /> },
].map((a) => (
<button
key={a.key}
type="button"
disabled={loading}
onClick={() => runAction(a.key, a.label)}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
{a.icon}
{a.label}
</button>
))}
</div>
</div>
{/* ── 翻译 ── */}
<div className="space-y-2">
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">翻译</p>
<div className="grid grid-cols-2 gap-1.5">
{[
{ key: "translate_en" as Action, label: "中 → 英", icon: <Languages className="w-3 h-3" /> },
{ key: "translate_zh" as Action, label: "英 → 中", icon: <Languages className="w-3 h-3" /> },
].map((a) => (
<button
key={a.key}
type="button"
disabled={loading}
onClick={() => runAction(a.key, a.label)}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
{a.icon}
{a.label}
</button>
))}
</div>
</div>
{/* ── 智能生成 ── */}
<div className="space-y-2">
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">智能生成</p>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
disabled={loading}
onClick={() => runGenerate("title")}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
<Heading className="w-3 h-3" />
生成标题
</button>
<button
type="button"
disabled={loading}
onClick={() => runGenerate("excerpt")}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
<FileText className="w-3 h-3" />
生成摘要
</button>
</div>
</div>
{/* ── 自定义指令 ── */}
<div className="space-y-2">
<div className="flex gap-1.5">
<input
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(undefined, "自定义指令"); }
}}
placeholder="输入自定义指令..."
className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
disabled={loading}
/>
<Button
size="sm"
variant="outline"
disabled={loading || !customPrompt.trim()}
onClick={() => runAction(undefined, "自定义指令")}
className="h-8 w-8 p-0 shrink-0"
>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
{/* ── 结果区 ── */}
{(result || loading) && (
<div className="space-y-3">
{/* 分割线 */}
<div className="flex items-center gap-2">
<div className="flex-1 h-px bg-border" />
<span className="font-sans text-[10px] text-muted-foreground/50 shrink-0">
{loading ? "生成中..." : "结果"}
</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* 结果标签 */}
{resultLabel && !loading && result && (
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">{resultLabel}</p>
)}
{/* 结果内容 */}
<div className="relative rounded-lg border border-border bg-muted/20 p-3 max-h-80 overflow-auto">
{loading && !result && (
<div className="flex items-center gap-2 py-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="font-sans text-sm text-muted-foreground">AI 正在思考...</span>
</div>
)}
{result && (
<div className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{result}
{loading && <span className="inline-block w-1.5 h-4 bg-primary ml-0.5 animate-pulse align-middle" />}
</div>
)}
{loading && result && (
<button
type="button"
onClick={stopGeneration}
className="absolute top-2 right-2 px-2 py-0.5 rounded text-[10px] font-medium bg-red-600 text-white hover:bg-red-700 transition-colors"
>
停止
</button>
)}
</div>
{/* 操作按钮 */}
{!loading && result && (
<div className="space-y-2">
{generateTarget ? (
<button
type="button"
onClick={applyGenerated}
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
>
<Send className="w-3 h-3" />
应用{generateTarget === "title" ? "标题" : "摘要"}
</button>
) : (
<div className="space-y-1.5">
<button
type="button"
onClick={() => handleInsert("replace")}
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
>
<RefreshCw className="w-3 h-3" />
替换内容
</button>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => handleInsert("append")}
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
>
追加到末尾
</button>
<button
type="button"
onClick={handleCopy}
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
>
{copied ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
{copied ? "已复制" : "复制"}
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* ── 空状态 ── */}
{!result && !loading && (
<p className="font-sans text-xs text-muted-foreground/40 leading-relaxed text-center py-4">
选中文字可以局部处理<br />
或直接点击上方按钮处理全文
</p>
)}
</div>
);
}