AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
Sparkles,
|
|
Wand2,
|
|
Expand,
|
|
Shrink,
|
|
Mic2,
|
|
BookCopy,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
ChevronRight,
|
|
X,
|
|
Square,
|
|
Send,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
|
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface MenuPos {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
// ── Tone options ───────────────────────────────────────────────────────────────
|
|
|
|
const TONE_OPTIONS: { value: RewriteTone; label: string }[] = [
|
|
{ value: "professional", label: "更专业" },
|
|
{ value: "casual", label: "更口语化" },
|
|
{ value: "readable", label: "更易读" },
|
|
{ value: "academic", label: "更学术" },
|
|
{ value: "expressive", label: "更有文采" },
|
|
];
|
|
|
|
// ── Action rows ────────────────────────────────────────────────────────────────
|
|
|
|
const ACTION_ROWS: {
|
|
action: RewriteAction;
|
|
label: string;
|
|
icon: React.ElementType;
|
|
}[] = [
|
|
{ action: "polish", label: "润色", icon: Wand2 },
|
|
{ action: "expand", label: "扩写", icon: Expand },
|
|
{ action: "shorten", label: "缩写", icon: Shrink },
|
|
{ action: "tone", label: "改语气", icon: Mic2 },
|
|
{ action: "imitate", label: "仿写", icon: BookCopy },
|
|
];
|
|
|
|
// ── Main component ─────────────────────────────────────────────────────────────
|
|
|
|
interface WritingAiMenuProps {
|
|
pos: MenuPos;
|
|
status: RewriteStatus;
|
|
error: string | null;
|
|
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => void;
|
|
onAbort: () => void;
|
|
onRetry: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function WritingAiMenu({
|
|
pos,
|
|
status,
|
|
error,
|
|
onAction,
|
|
onAbort,
|
|
onRetry,
|
|
onClose,
|
|
}: WritingAiMenuProps) {
|
|
const [toneOpen, setToneOpen] = useState(false);
|
|
const [imitateDialogOpen, setImitateDialogOpen] = useState(false);
|
|
const [referenceText, setReferenceText] = useState("");
|
|
const [instruction, setInstruction] = useState("");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const imitateTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// autoFocus is unreliable when the menu opens during a mousedown event sequence;
|
|
// explicit focus via setTimeout(0) runs after the browser finishes that sequence.
|
|
useEffect(() => {
|
|
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
|
return () => clearTimeout(id);
|
|
}, []);
|
|
|
|
// Focus the dialog textarea when the imitate dialog opens.
|
|
useEffect(() => {
|
|
if (!imitateDialogOpen) return;
|
|
const id = setTimeout(() => imitateTextareaRef.current?.focus(), 0);
|
|
return () => clearTimeout(id);
|
|
}, [imitateDialogOpen]);
|
|
|
|
const isLoading = status === "loading";
|
|
const isError = status === "error";
|
|
|
|
// Measure actual height after render and compute safe position before browser paint
|
|
const menuWidth = 240;
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const h = el.getBoundingClientRect().height;
|
|
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - menuWidth - 8);
|
|
const spaceBelow = window.innerHeight - pos.y - 8;
|
|
const spaceAbove = pos.y - 8;
|
|
let safeY: number;
|
|
if (spaceBelow >= h) {
|
|
safeY = pos.y + 4;
|
|
} else if (spaceAbove >= h) {
|
|
safeY = pos.y - h - 4;
|
|
} else if (spaceBelow >= spaceAbove) {
|
|
safeY = Math.max(8, window.innerHeight - h - 8);
|
|
} else {
|
|
safeY = 8;
|
|
}
|
|
setComputedPos({ left: safeX, top: safeY });
|
|
}, [pos.x, pos.y]);
|
|
|
|
const fireAction = (action: RewriteAction, tone?: RewriteTone) => {
|
|
onAction(action, tone, instruction.trim() || undefined);
|
|
};
|
|
|
|
const fireImitate = () => {
|
|
const ref = referenceText.trim();
|
|
if (!ref) return;
|
|
setImitateDialogOpen(false);
|
|
onAction("imitate", undefined, instruction.trim() || undefined, ref);
|
|
};
|
|
|
|
const handleInputEnter = () => {
|
|
const trimmed = instruction.trim();
|
|
onAction("polish", undefined, trimmed || undefined);
|
|
};
|
|
|
|
return createPortal(
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
|
|
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
position: "fixed",
|
|
left: computedPos?.left ?? pos.x,
|
|
top: computedPos?.top ?? (pos.y + 4),
|
|
width: menuWidth,
|
|
zIndex: 9999,
|
|
visibility: computedPos ? "visible" : "hidden",
|
|
}}
|
|
className="rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
|
<span className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
|
|
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
|
|
AI 改写
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Loading state ──────────────────────────────────────── */}
|
|
{isLoading && (
|
|
<div className="flex flex-col items-center gap-4 px-4 py-6">
|
|
{/* Bouncing dots */}
|
|
<div className="flex items-center gap-2">
|
|
{[0, 160, 320].map((delay) => (
|
|
<span
|
|
key={delay}
|
|
className="h-1.5 w-1.5 rounded-full bg-primary animate-bouncing"
|
|
style={{ animationDelay: `${delay}ms` }}
|
|
/>
|
|
))}
|
|
<span className="ml-1 text-xs text-muted-foreground">AI 正在生成…</span>
|
|
</div>
|
|
|
|
{/* Stop button — breathing animation */}
|
|
<button
|
|
type="button"
|
|
onClick={onAbort}
|
|
className={cn(
|
|
"animate-breathe-stop",
|
|
"flex items-center gap-1.5 px-5 py-2 text-xs font-medium rounded-lg",
|
|
"border border-red-300/70 bg-red-50/60 text-red-600",
|
|
"dark:border-red-800/50 dark:bg-red-950/20 dark:text-red-400",
|
|
"hover:bg-red-100/90 dark:hover:bg-red-950/40 transition-colors",
|
|
)}
|
|
>
|
|
<Square className="h-3 w-3 fill-current" />
|
|
停止生成
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Error state ─────────────────────────────────────────── */}
|
|
{isError && (
|
|
<div className="px-3 py-3 space-y-2">
|
|
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
{error ?? "改写失败"}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
|
|
>
|
|
<RefreshCw className="h-3 w-3" />
|
|
重试
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Idle state: input + action list ─────────────────────── */}
|
|
{!isLoading && !isError && (
|
|
<>
|
|
{/* Custom instruction input */}
|
|
<div className="px-2.5 pt-2.5 pb-2">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={instruction}
|
|
onChange={(e) => setInstruction(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleInputEnter();
|
|
}
|
|
}}
|
|
placeholder="告诉 AI 你想要什么…"
|
|
className={cn(
|
|
"w-full text-xs rounded-md px-2.5 py-1.5 outline-none transition-colors",
|
|
"bg-muted/50 border border-input focus:border-primary/60",
|
|
"placeholder:text-muted-foreground/50",
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-border/50 mx-1" />
|
|
|
|
{/* Preset actions */}
|
|
<div className="p-1 space-y-0.5">
|
|
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
|
|
const isTone = action === "tone";
|
|
const isImitate = action === "imitate";
|
|
const hasSub = isTone;
|
|
const isExpanded = isTone && toneOpen;
|
|
return (
|
|
<div key={action}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (isTone) {
|
|
setToneOpen((v) => !v);
|
|
} else if (isImitate) {
|
|
setToneOpen(false);
|
|
setImitateDialogOpen(true);
|
|
} else {
|
|
fireAction(action);
|
|
}
|
|
}}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
|
|
"hover:bg-accent hover:text-accent-foreground",
|
|
isExpanded && "bg-accent text-accent-foreground",
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="flex-1">{label}</span>
|
|
{hasSub && (
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
|
|
isExpanded && "rotate-90",
|
|
)}
|
|
/>
|
|
)}
|
|
</button>
|
|
|
|
{/* Tone sub-list */}
|
|
{isTone && toneOpen && (
|
|
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
|
|
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => fireAction("tone", value)}
|
|
className="flex w-full items-center px-2 py-1.5 text-xs rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
>
|
|
{toneLabel}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Imitate dialog:弹窗内多行输入范文,回车 / 发送即仿写 ───────────── */}
|
|
{imitateDialogOpen && (
|
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
|
|
<div
|
|
className="absolute inset-0 bg-black/40 animate-in fade-in duration-100"
|
|
onMouseDown={() => setImitateDialogOpen(false)}
|
|
/>
|
|
<div
|
|
className="relative w-full max-w-md rounded-xl border bg-popover shadow-2xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
|
<span className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
|
<BookCopy className="h-4 w-4 text-amber-500" />
|
|
仿写
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setImitateDialogOpen(false)}
|
|
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-4 space-y-3">
|
|
<textarea
|
|
ref={imitateTextareaRef}
|
|
value={referenceText}
|
|
onChange={(e) => setReferenceText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
// Enter 提交,Shift+Enter 换行
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
fireImitate();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setImitateDialogOpen(false);
|
|
}
|
|
}}
|
|
rows={8}
|
|
placeholder="粘贴范文,AI 将模仿其文风、句式与语气改写所选文字…(回车提交,Shift+Enter 换行)"
|
|
className={cn(
|
|
"w-full resize-none text-sm rounded-md px-3 py-2 outline-none transition-colors leading-relaxed",
|
|
"bg-muted/50 border border-input focus:border-primary/60",
|
|
"placeholder:text-muted-foreground/50",
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/20">
|
|
<button
|
|
type="button"
|
|
onClick={() => setImitateDialogOpen(false)}
|
|
className="px-3 py-1.5 text-xs font-medium rounded-md border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={fireImitate}
|
|
disabled={!referenceText.trim()}
|
|
className={cn(
|
|
"flex items-center justify-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md transition-colors",
|
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
|
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
)}
|
|
>
|
|
<Send className="h-3.5 w-3.5" />
|
|
发送
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|