43e1c2f61d
写作体验优化: - 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容) - 浏览器原生全屏专注写作模式 - Markdown 编辑模式(左右分栏,实时预览) - 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏 封面图功能: - PostForm 新增封面图 URL 输入 + 实时预览 - BlogList 文章卡片显示封面缩略图 - PostContent 文章详情页显示封面大图 AI 辅助写作: - OpenAI 兼容接口,SSE 流式返回 - 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要 - 自定义指令输入,结果可复制/替换/追加 其他: - 后台文章列表改为一页10篇 - 前台 /blog 页面添加分页功能(一页10篇)
243 lines
8.7 KiB
TypeScript
243 lines
8.7 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,
|
|
X,
|
|
Loader2,
|
|
Copy,
|
|
Check,
|
|
} from "lucide-react";
|
|
|
|
interface AiAssistantProps {
|
|
/** 当前编辑器的纯文本内容 */
|
|
content: string;
|
|
/** 将 AI 结果插入/替换到编辑器 */
|
|
onInsert: (text: string, mode: "replace" | "append") => void;
|
|
}
|
|
|
|
type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar";
|
|
|
|
const ACTIONS: { key: Action; label: string; icon: React.ReactNode; needSelection?: boolean }[] = [
|
|
{ key: "polish", label: "润色", icon: <Wand2 className="w-3.5 h-3.5" /> },
|
|
{ key: "expand", label: "扩写", icon: <Maximize2 className="w-3.5 h-3.5" /> },
|
|
{ key: "shorten", label: "精简", icon: <Minimize2 className="w-3.5 h-3.5" /> },
|
|
{ key: "continue", label: "续写", icon: <ArrowRight className="w-3.5 h-3.5" /> },
|
|
{ key: "fix_grammar", label: "纠错", icon: <Bug className="w-3.5 h-3.5" /> },
|
|
{ key: "translate_en", label: "中→英", icon: <Languages className="w-3.5 h-3.5" /> },
|
|
{ key: "translate_zh", label: "英→中", icon: <Languages className="w-3.5 h-3.5" /> },
|
|
{ key: "summarize", label: "摘要", icon: <FileText className="w-3.5 h-3.5" /> },
|
|
];
|
|
|
|
export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [result, setResult] = useState("");
|
|
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);
|
|
}, []);
|
|
|
|
async function runAction(action?: Action) {
|
|
const text = content.replace(/<[^>]+>/g, "").trim();
|
|
if (!text && action !== "summarize") {
|
|
setResult("请先输入文章内容");
|
|
setOpen(true);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setResult("");
|
|
setOpen(true);
|
|
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;
|
|
}
|
|
|
|
// 读取 SSE 流
|
|
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 });
|
|
// 解析 SSE 数据
|
|
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 {
|
|
// 忽略解析错误
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
function handleCopy() {
|
|
navigator.clipboard.writeText(result);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
|
|
function handleInsert(mode: "replace" | "append") {
|
|
onInsert(result, mode);
|
|
setResult("");
|
|
setOpen(false);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* 触发按钮 */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex items-center gap-1.5 font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
>
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
AI 辅助写作
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
|
{/* 关闭按钮 */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-sans text-xs text-muted-foreground">选择操作或输入自定义指令</span>
|
|
<button type="button" onClick={() => { setOpen(false); stopGeneration(); }} className="text-muted-foreground hover:text-foreground">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 操作按钮组 */}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{ACTIONS.map((a) => (
|
|
<button
|
|
key={a.key}
|
|
type="button"
|
|
disabled={loading}
|
|
onClick={() => runAction(a.key)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
|
>
|
|
{a.icon}
|
|
{a.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 自定义指令 */}
|
|
<div className="flex gap-2">
|
|
<input
|
|
value={customPrompt}
|
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(); } }}
|
|
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 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
disabled={loading}
|
|
/>
|
|
<Button size="sm" variant="outline" disabled={loading || !customPrompt.trim()} onClick={() => runAction()} className="h-8 px-3 text-xs">
|
|
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 结果 */}
|
|
{result && (
|
|
<div className="space-y-2">
|
|
<div className="relative rounded-lg border border-border bg-muted/30 p-3 max-h-60 overflow-auto">
|
|
<pre className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">{result}</pre>
|
|
{loading && (
|
|
<button
|
|
type="button"
|
|
onClick={stopGeneration}
|
|
className="absolute top-2 right-2 px-2 py-0.5 rounded text-xs bg-red-600 text-white hover:bg-red-700 transition-colors"
|
|
>
|
|
停止
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 操作按钮 */}
|
|
{!loading && result && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors"
|
|
>
|
|
{copied ? <Check className="w-3.5 h-3.5 text-green-600" /> : <Copy className="w-3.5 h-3.5" />}
|
|
{copied ? "已复制" : "复制"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleInsert("replace")}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-primary text-primary-foreground font-sans text-xs hover:bg-primary/90 transition-colors"
|
|
>
|
|
替换内容
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleInsert("append")}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors"
|
|
>
|
|
追加到末尾
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|