feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏
AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user