Files
deerflow-code/frontend-web/src/components/workspace/custom-prompt-menu.tsx
T
2026-06-05 10:28:45 +08:00

392 lines
13 KiB
TypeScript

"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
PencilIcon,
Settings2Icon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAgentSelector } from "@/core/agents/hooks";
import {
useCreateCustomPrompt,
useCustomPrompts,
useDeleteCustomPrompt,
useUpdateCustomPrompt,
} from "@/core/custom-prompts";
import {
useUpdateUserPreferences,
useUserPreferences,
} from "@/core/user-preferences";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export function CustomPromptBar({
onInsert,
className,
currentAgentId,
}: {
onInsert: (content: string) => void;
className?: string;
currentAgentId?: string | null;
}) {
const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter(
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
);
const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
const updateArrows = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanLeft(el.scrollLeft > 4);
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
}, []);
useEffect(() => {
updateArrows();
window.addEventListener("resize", updateArrows);
return () => window.removeEventListener("resize", updateArrows);
}, [prompts, updateArrows]);
const scrollByDir = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
};
const overflowing = canLeft || canRight;
return (
<>
<div className={cn("flex min-w-0 items-center gap-1", className)}>
{prompts.length > 0 && (
<>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canLeft}
onClick={() => scrollByDir(-1)}
aria-label="上一组"
>
<ChevronLeftIcon className="size-4" />
</Button>
)}
<div
ref={scrollRef}
onScroll={updateArrows}
className="min-w-0 flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
{prompts.map((prompt) => (
<button
key={prompt.id}
type="button"
onClick={() => onInsert(prompt.content)}
title={prompt.content}
className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)}
>
{prompt.title || prompt.content || "未命名提示词"}
</button>
))}
</div>
</div>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canRight}
onClick={() => scrollByDir(1)}
aria-label="下一组"
>
<ChevronRightIcon className="size-4" />
</Button>
)}
</>
)}
{prompts.length === 0 && <div className="min-w-0 flex-1" />}
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
title="管理常用提示词"
aria-label="管理常用提示词"
onClick={() => setManageOpen(true)}
>
<Settings2Icon className="size-4" />
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
</>
);
}
function ManagePromptsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const prompts = useCustomPrompts();
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const { agents } = useAgentSelector();
const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [agentId, setAgentId] = useState<string | undefined>(undefined);
const saving = createPrompt.isPending || updatePrompt.isPending;
const selectableAgents = agents;
const agentNameById = (id?: string | null) =>
id ? agents.find((agent) => agent.id === id)?.name ?? null : null;
const resetForm = () => {
setEditingId(null);
setTitle("");
setContent("");
setAgentId(undefined);
};
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!content.trim()) {
toast.error("提示词内容不能为空");
return;
}
if (!agentId) {
toast.error("请选择关联智能体");
return;
}
try {
if (editingId) {
await updatePrompt.mutateAsync({
id: editingId,
input: { title: trimmedTitle, content, agent_id: agentId },
});
toast.success("提示词已更新");
} else {
await createPrompt.mutateAsync({
title: trimmedTitle,
content,
agent_id: agentId,
});
toast.success("提示词已添加");
}
resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleEdit = (id: string) => {
const target = prompts.find((prompt) => prompt.id === id);
if (!target) return;
setEditingId(id);
setTitle(target.title);
setContent(target.content);
setAgentId(
target.agent_id && selectableAgents.some((agent) => agent.id === target.agent_id)
? target.agent_id
: undefined,
);
};
const handleToggleHideAdmin = async (next: boolean) => {
try {
await updatePreferences.mutateAsync({ hide_admin_prompt_prefix: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async (id: string) => {
try {
await deletePrompt.mutateAsync(id);
if (editingId === id) resetForm();
toast.success("提示词已删除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) resetForm();
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
保存常用提示词后,会显示在输入框上方。提示词与账号绑定,可跨设备使用。
</DialogDescription>
</DialogHeader>
<div className="flex items-start justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<p className="text-sm font-medium">只用我自己的提示词</p>
<p className="text-muted-foreground mt-0.5 text-xs">
开启后,输入框不再显示管理员配置的提示词前缀开关,本账号仅使用自己保存的提示词。
</p>
</div>
<Switch
checked={preferences.hide_admin_prompt_prefix}
disabled={updatePreferences.isPending}
onCheckedChange={(value) => void handleToggleHideAdmin(value)}
/>
</div>
<div className="space-y-3 rounded-md border p-3">
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="名称(可选,例如:周报总结)"
/>
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="提示词内容"
className="min-h-28"
/>
<div className="flex min-w-0 flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体</p>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left">
<SelectValue placeholder="请选择已配置的智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)] overflow-hidden"
>
{selectableAgents.map((agent) => (
<SelectItem
key={agent.id}
value={agent.id}
className="max-w-full pr-8"
>
<span className="block max-w-full truncate">
{desensitize(agent.name)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{selectableAgents.length === 0
? "当前账号还没有已配置的智能体。"
: "关联后,仅在选中该智能体的对话里显示此提示词。"}
</p>
</div>
<div className="flex justify-end gap-2">
{editingId && (
<Button
variant="outline"
size="sm"
onClick={resetForm}
disabled={saving}
>
取消编辑
</Button>
)}
<Button size="sm" onClick={handleSubmit} disabled={saving}>
{editingId ? "保存修改" : "添加提示词"}
</Button>
</div>
</div>
<div className="space-y-2">
{prompts.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
暂无常用提示词
</p>
) : (
prompts.map((prompt) => (
<div
key={prompt.id}
className="flex items-start justify-between gap-2 rounded-md border p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{prompt.title || "未命名提示词"}
</p>
{prompt.agent_id && (
<p className="mt-0.5 truncate text-[11px] text-sky-600 dark:text-sky-400">
仅 {desensitize(agentNameById(prompt.agent_id) || "未知智能体")} 可见
</p>
)}
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
{prompt.content}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => handleEdit(prompt.id)}
title="编辑"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => void handleDelete(prompt.id)}
title="删除"
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}