392 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|