AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
RefreshCw, X, ArrowRightLeft, ArrowDownToLine,
|
|
Loader2, ChevronDown, Brain, SendHorizontal, Square,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
|
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
|
|
|
|
const ACTION_LABEL: Record<RewriteAction, string> = {
|
|
polish: "润色",
|
|
expand: "扩写",
|
|
shorten: "缩写",
|
|
tone: "改语气",
|
|
imitate: "仿写",
|
|
};
|
|
|
|
const TONE_LABEL: Record<RewriteTone, string> = {
|
|
professional: "更专业",
|
|
casual: "更口语化",
|
|
readable: "更易读",
|
|
academic: "更学术",
|
|
expressive: "更有文采",
|
|
};
|
|
|
|
interface MenuPos { x: number; y: number }
|
|
|
|
interface WritingRewritePreviewProps {
|
|
pos: MenuPos;
|
|
status: RewriteStatus;
|
|
originalText: string;
|
|
result: string | null;
|
|
thinking: string | null;
|
|
error: string | null;
|
|
action: RewriteAction;
|
|
tone?: RewriteTone;
|
|
onReplace: () => void;
|
|
onInsert: () => void;
|
|
onRetry: () => void;
|
|
onAbort: () => void;
|
|
onClose: () => void;
|
|
onFollowUp: (instruction: string) => void;
|
|
}
|
|
|
|
export function WritingRewritePreview({
|
|
pos,
|
|
status,
|
|
result,
|
|
thinking,
|
|
error,
|
|
action,
|
|
tone,
|
|
onReplace,
|
|
onInsert,
|
|
onRetry,
|
|
onAbort,
|
|
onClose,
|
|
onFollowUp,
|
|
}: WritingRewritePreviewProps) {
|
|
const isLoading = status === "loading";
|
|
const isError = status === "error";
|
|
const isSuccess = status === "success" && result !== null;
|
|
const hasResult = result !== null && result.length > 0;
|
|
|
|
// ── Follow-up input ─────────────────────────────────────────────────────
|
|
const [followUpInput, setFollowUpInput] = useState("");
|
|
const followUpInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Auto-focus the follow-up input when generation finishes
|
|
useEffect(() => {
|
|
if (status === "success") {
|
|
setTimeout(() => followUpInputRef.current?.focus(), 50);
|
|
}
|
|
}, [status]);
|
|
|
|
const handleFollowUpSubmit = () => {
|
|
const instruction = followUpInput.trim();
|
|
if (!instruction || isLoading) return;
|
|
setFollowUpInput("");
|
|
onFollowUp(instruction);
|
|
};
|
|
|
|
// ── Thinking expand / collapse ──────────────────────────────────────────
|
|
const [thinkingOpen, setThinkingOpen] = useState(false);
|
|
const prevStatusRef = useRef<RewriteStatus>(status);
|
|
|
|
// Auto-expand when thinking content first streams in during loading
|
|
useEffect(() => {
|
|
if (isLoading && thinking) setThinkingOpen(true);
|
|
}, [isLoading, thinking]);
|
|
|
|
// Auto-collapse when generation finishes (loading → success/error)
|
|
useEffect(() => {
|
|
const prev = prevStatusRef.current;
|
|
prevStatusRef.current = status;
|
|
if (prev === "loading" && status !== "loading") {
|
|
setThinkingOpen(false);
|
|
}
|
|
}, [status]);
|
|
|
|
// ── Position ────────────────────────────────────────────────────────────
|
|
const panelWidth = 480;
|
|
const [computedPos, setComputedPos] = useState<{
|
|
left: number;
|
|
top?: number;
|
|
bottom?: number;
|
|
maxHeight: number;
|
|
} | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const MARGIN = 8;
|
|
const GAP = 6;
|
|
const MIN_HEIGHT = 160;
|
|
const safeX = Math.min(Math.max(pos.x, MARGIN), window.innerWidth - panelWidth - MARGIN);
|
|
const spaceBelow = window.innerHeight - pos.y - MARGIN;
|
|
const spaceAbove = pos.y - MARGIN;
|
|
if (spaceBelow >= spaceAbove) {
|
|
// More space below — anchor top at selection bottom, grow downward
|
|
setComputedPos({
|
|
left: safeX,
|
|
top: pos.y + GAP,
|
|
maxHeight: Math.max(MIN_HEIGHT, spaceBelow - GAP),
|
|
});
|
|
} else {
|
|
// More space above — anchor bottom at selection top, grow upward
|
|
setComputedPos({
|
|
left: safeX,
|
|
bottom: window.innerHeight - pos.y + GAP,
|
|
maxHeight: Math.max(MIN_HEIGHT, spaceAbove - GAP),
|
|
});
|
|
}
|
|
}, [pos.x, pos.y]);
|
|
|
|
const actionLabel = tone
|
|
? `${ACTION_LABEL[action]} · ${TONE_LABEL[tone]}`
|
|
: ACTION_LABEL[action];
|
|
|
|
return createPortal(
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
|
|
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
left: computedPos?.left ?? pos.x,
|
|
...(computedPos?.bottom !== undefined
|
|
? { bottom: computedPos.bottom }
|
|
: { top: computedPos?.top ?? (pos.y + 6) }),
|
|
maxHeight: computedPos?.maxHeight,
|
|
width: panelWidth,
|
|
zIndex: 9999,
|
|
visibility: computedPos ? "visible" : "hidden",
|
|
}}
|
|
className="flex flex-col rounded-xl border bg-popover shadow-xl 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/40">
|
|
<span className="text-xs font-semibold text-foreground">{actionLabel}</span>
|
|
<div className="flex items-center gap-1">
|
|
{isLoading && (
|
|
<button
|
|
type="button"
|
|
onClick={onAbort}
|
|
className="rounded p-1 hover:bg-red-100 dark:hover:bg-red-950/40 text-red-500 hover:text-red-600 transition-colors"
|
|
title="停止生成"
|
|
>
|
|
<Square className="h-3.5 w-3.5 fill-current" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
disabled={isLoading}
|
|
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
|
title="重试"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-3 space-y-2 overflow-y-auto flex-1 min-h-0">
|
|
|
|
{/* Thinking — collapsible with animation */}
|
|
{thinking && (
|
|
<div className="rounded-md border border-amber-200/60 bg-amber-50/40 dark:border-amber-800/40 dark:bg-amber-950/20 overflow-hidden">
|
|
{/* Toggle button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setThinkingOpen((v) => !v)}
|
|
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-[10px] font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100/40 dark:hover:bg-amber-900/20 transition-colors"
|
|
>
|
|
<Brain className="h-3 w-3 shrink-0" />
|
|
<span className="flex-1 text-left">思考过程</span>
|
|
{isLoading && (
|
|
<span className="text-[9px] text-amber-600/70 dark:text-amber-500/60 mr-1">
|
|
思考中…
|
|
</span>
|
|
)}
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-3 w-3 transition-transform duration-300",
|
|
thinkingOpen && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{/* Animated content — grid-rows trick for smooth height transition */}
|
|
<div
|
|
style={{ gridTemplateRows: thinkingOpen ? "1fr" : "0fr" }}
|
|
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div className="px-3 pb-2.5 pt-1.5 text-[11px] text-amber-800/80 dark:text-amber-300/70 leading-relaxed whitespace-pre-wrap border-t border-amber-200/40 dark:border-amber-800/30 max-h-44 overflow-y-auto">
|
|
{thinking}
|
|
{isLoading && (
|
|
<span className="inline-block w-0.5 h-3 bg-amber-500 animate-pulse align-middle ml-0.5" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result area */}
|
|
{/* Loading — no text yet */}
|
|
{isLoading && !hasResult && (
|
|
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3.5 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
|
AI 正在生成…
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{isError && (
|
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive leading-relaxed">
|
|
{error ?? "改写失败,请重试"}
|
|
</div>
|
|
)}
|
|
|
|
{/* Result text — show while streaming and after success */}
|
|
{hasResult && (
|
|
<div className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground leading-relaxed whitespace-pre-wrap">
|
|
{result}
|
|
{isLoading && (
|
|
<span className="inline-block w-0.5 h-3.5 bg-primary animate-pulse align-middle ml-0.5" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer actions — only after success */}
|
|
{isSuccess && (
|
|
<div className="flex items-center gap-2 px-3 py-2 border-t bg-muted/20">
|
|
<Button size="sm" className="flex-1 h-7 text-xs gap-1.5" onClick={onReplace}>
|
|
<ArrowRightLeft className="h-3 w-3" />
|
|
替换原文
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs gap-1.5" onClick={onInsert}>
|
|
<ArrowDownToLine className="h-3 w-3" />
|
|
插入后面
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Follow-up chat input — shown after success */}
|
|
{isSuccess && (
|
|
<div className="flex items-center gap-2 px-3 py-2 border-t bg-background">
|
|
<input
|
|
ref={followUpInputRef}
|
|
type="text"
|
|
value={followUpInput}
|
|
onChange={(e) => setFollowUpInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleFollowUpSubmit();
|
|
}
|
|
}}
|
|
placeholder="继续优化,如:更简洁一些、语气更正式…"
|
|
className="flex-1 text-xs bg-muted/50 rounded-md px-2.5 py-1.5 outline-none border border-input focus:border-primary/50 transition-colors placeholder:text-muted-foreground/50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleFollowUpSubmit}
|
|
disabled={!followUpInput.trim()}
|
|
className="shrink-0 rounded-md p-1.5 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
|
>
|
|
<SendHorizontal className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|