425 lines
14 KiB
TypeScript
425 lines
14 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";
|
||
|
||
/**
|
||
* 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为
|
||
* `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC /
|
||
* 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。
|
||
*/
|
||
export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog";
|
||
|
||
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 guideOwnsDialogRef = useRef(false);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const [canLeft, setCanLeft] = useState(false);
|
||
const [canRight, setCanRight] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const handler = (event: Event) => {
|
||
const detail = (
|
||
event as CustomEvent<{ open?: boolean; owner?: boolean }>
|
||
).detail;
|
||
guideOwnsDialogRef.current = Boolean(detail?.owner);
|
||
setManageOpen(Boolean(detail?.open));
|
||
};
|
||
window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||
return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||
}, []);
|
||
|
||
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)}
|
||
data-compact-guide="prompt-bar"
|
||
>
|
||
{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={(next) => {
|
||
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
|
||
if (!next && guideOwnsDialogRef.current) return;
|
||
setManageOpen(next);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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({ enabled: open });
|
||
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"
|
||
data-compact-guide="prompt-dialog"
|
||
>
|
||
<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>
|
||
);
|
||
}
|