feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏

AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-27 15:53:51 +08:00
1 parent c11307aa67
commit 5de74e90dc
21 files changed
+1379 -386

No files matched your search

@@ -6,11 +6,13 @@ import {
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";
@@ -44,6 +46,7 @@ const ACTION_ROWS: {
{ action: "expand", label: "扩写", icon: Expand },
{ action: "shorten", label: "缩写", icon: Shrink },
{ action: "tone", label: "改语气", icon: Mic2 },
{ action: "imitate", label: "仿写", icon: BookCopy },
];
// ── Main component ─────────────────────────────────────────────────────────────
@@ -52,7 +55,7 @@ interface WritingAiMenuProps {
pos: MenuPos;
status: RewriteStatus;
error: string | null;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => void;
onAbort: () => void;
onRetry: () => void;
onClose: () => void;
@@ -68,8 +71,11 @@ export function WritingAiMenu({
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.
@@ -78,6 +84,13 @@ export function WritingAiMenu({
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";
@@ -110,6 +123,13 @@ export function WritingAiMenu({
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);
@@ -230,7 +250,9 @@ export function WritingAiMenu({
<div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone";
const isTonesOpen = isTone && toneOpen;
const isImitate = action === "imitate";
const hasSub = isTone;
const isExpanded = isTone && toneOpen;
return (
<div key={action}>
<button
@@ -238,6 +260,9 @@ export function WritingAiMenu({
onClick={() => {
if (isTone) {
setToneOpen((v) => !v);
} else if (isImitate) {
setToneOpen(false);
setImitateDialogOpen(true);
} else {
fireAction(action);
}
@@ -245,23 +270,23 @@ export function WritingAiMenu({
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",
isTonesOpen && "bg-accent 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>
{isTone && (
{hasSub && (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isTonesOpen && "rotate-90",
isExpanded && "rotate-90",
)}
/>
)}
</button>
{/* Tone sub-list */}
{isTonesOpen && (
{isTone && toneOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button
@@ -282,6 +307,85 @@ export function WritingAiMenu({
</>
)}
</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,
);