refactor: 移除提示词模板页面及相关接口,精简岗位/标签/用户提示词模型
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
838e986670
commit
e6662cf673
19 files changed
+55
-729
No files matched your search
@@ -1,258 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CheckIcon, Loader2, PencilIcon, PlusIcon, TagIcon, Trash2Icon } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
|
|
||||||
import {
|
|
||||||
usePromptTemplates,
|
|
||||||
useCreatePromptTemplate,
|
|
||||||
useDeletePromptTemplate,
|
|
||||||
useUpdatePromptTemplate,
|
|
||||||
type PromptTemplate,
|
|
||||||
} from "@/core/custom-prompts";
|
|
||||||
import { useResourceTags, useSetResourceTags, useTags } from "@/core/tags";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function errMsg(e: unknown): string {
|
|
||||||
return String(e instanceof Error ? e.message : e);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateFormDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
editing,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
editing: PromptTemplate | null;
|
|
||||||
}) {
|
|
||||||
const createT = useCreatePromptTemplate();
|
|
||||||
const updateT = useUpdatePromptTemplate();
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
|
|
||||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
|
||||||
const syncKey = `${open}:${editing?.id ?? "new"}`;
|
|
||||||
if (open && syncedFor !== syncKey) {
|
|
||||||
setTitle(editing?.title ?? "");
|
|
||||||
setContent(editing?.content ?? "");
|
|
||||||
setSyncedFor(syncKey);
|
|
||||||
}
|
|
||||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
|
||||||
|
|
||||||
const pending = createT.isPending || updateT.isPending;
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!content.trim()) return;
|
|
||||||
try {
|
|
||||||
if (editing) {
|
|
||||||
await updateT.mutateAsync({ id: editing.id, input: { title: title.trim(), content } });
|
|
||||||
} else {
|
|
||||||
await createT.mutateAsync({ title: title.trim(), content });
|
|
||||||
}
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(errMsg(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editing ? "编辑提示词模板" : "新增提示词模板"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">标题</label>
|
|
||||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:理赔标准话术" maxLength={255} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">内容</label>
|
|
||||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={5} placeholder="提示词正文…" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
|
||||||
<Button onClick={handleSave} disabled={pending || !content.trim()}>
|
|
||||||
{pending && <Loader2 className="size-4 animate-spin" />}保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateTagsDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
template,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
template: PromptTemplate;
|
|
||||||
}) {
|
|
||||||
const { data: tags } = useTags("user_prompt");
|
|
||||||
const { data: current } = useResourceTags("user_prompt", template.id, open);
|
|
||||||
const setTags = useSetResourceTags();
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
|
||||||
const syncKey = `${open}:${current ? "ready" : "loading"}`;
|
|
||||||
if (open && current && syncedFor !== syncKey) {
|
|
||||||
setSelected(current.map((t) => t.id));
|
|
||||||
setSyncedFor(syncKey);
|
|
||||||
}
|
|
||||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
|
||||||
|
|
||||||
const toggle = (id: string) =>
|
|
||||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await setTags.mutateAsync({ targetType: "user_prompt", targetId: template.id, tagIds: selected });
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(errMsg(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>为模板打标签</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{!tags || tags.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
暂无「常用提示词」标签,请先到「标签管理」创建。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{tags.map((tag) => {
|
|
||||||
const checked = selected.includes(tag.id);
|
|
||||||
const color = resolveTagColor(tag.color);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(tag.id)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
|
||||||
color.chip,
|
|
||||||
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{checked && <CheckIcon className="size-3" />}
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
|
||||||
<Button onClick={handleSave} disabled={setTags.isPending}>
|
|
||||||
{setTags.isPending && <Loader2 className="size-4 animate-spin" />}保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PromptTemplatesPage() {
|
|
||||||
const { data: templates, isLoading, error } = usePromptTemplates();
|
|
||||||
const deleteT = useDeletePromptTemplate();
|
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
|
||||||
const [tagsOpen, setTagsOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<PromptTemplate | null>(null);
|
|
||||||
const [tagging, setTagging] = useState<PromptTemplate | null>(null);
|
|
||||||
|
|
||||||
const handleDelete = async (tpl: PromptTemplate) => {
|
|
||||||
if (!window.confirm(`确认删除模板「${tpl.title || tpl.content.slice(0, 20)}」?已分发给用户的克隆将在下次同步时移除。`)) return;
|
|
||||||
try {
|
|
||||||
await deleteT.mutateAsync(tpl.id);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(errMsg(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold">常用提示词模板</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
管理员维护提示词模板并打标签;岗位配置了对应标签的用户,会自动加载这些模板到「常用提示词」。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" onClick={() => { setEditing(null); setFormOpen(true); }}>
|
|
||||||
<PlusIcon className="size-4" />新增模板
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12">
|
|
||||||
<Loader2 className="size-4 animate-spin" />加载中…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="text-destructive text-sm">{errMsg(error)}</div>}
|
|
||||||
|
|
||||||
{!isLoading && !error && templates && templates.length === 0 && (
|
|
||||||
<div className="text-muted-foreground rounded-lg border border-dashed py-12 text-center text-sm">
|
|
||||||
还没有模板,点击「新增模板」创建。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !error && templates && templates.length > 0 && (
|
|
||||||
<div className="overflow-hidden rounded-md border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">标题</th>
|
|
||||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">内容</th>
|
|
||||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{templates.map((tpl, i) => (
|
|
||||||
<tr key={tpl.id} className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}>
|
|
||||||
<td className="px-4 py-3 font-medium">{tpl.title || "—"}</td>
|
|
||||||
<td className="text-muted-foreground max-w-md truncate px-4 py-3">{tpl.content}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => { setTagging(tpl); setTagsOpen(true); }} aria-label="打标签">
|
|
||||||
<TagIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => { setEditing(tpl); setFormOpen(true); }} aria-label="编辑">
|
|
||||||
<PencilIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(tpl)} aria-label="删除">
|
|
||||||
<Trash2Icon className="text-destructive size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TemplateFormDialog open={formOpen} onOpenChange={setFormOpen} editing={editing} />
|
|
||||||
{tagging && <TemplateTagsDialog open={tagsOpen} onOpenChange={setTagsOpen} template={tagging} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -525,6 +525,25 @@ export function InputBox({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||||
|
{recommendedQuestions.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q.id}
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
onClick={() => {
|
||||||
|
textInput.setInput(q.content);
|
||||||
|
setFollowupsHidden(true);
|
||||||
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desensitize(q.title)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showFollowups && (
|
{showFollowups && (
|
||||||
<div className="flex items-center justify-center pb-2">
|
<div className="flex items-center justify-center pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1103,35 +1122,6 @@ export function InputBox({
|
|||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelector>
|
</ModelSelector>
|
||||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="推荐问题"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-[#e4e4e7] bg-background px-3 py-1.5 text-xs text-[#18181b] shadow-sm transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/60 dark:text-slate-200 dark:hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
<LightbulbIcon className="size-4 text-amber-500" />
|
|
||||||
推荐问题
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="top" align="end" className="max-h-80 w-72 overflow-y-auto">
|
|
||||||
{recommendedQuestions.map((q) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={q.id}
|
|
||||||
className="cursor-pointer whitespace-normal"
|
|
||||||
onSelect={() => {
|
|
||||||
textInput.setInput(q.content);
|
|
||||||
setFollowupsHidden(true);
|
|
||||||
setTimeout(() => requestFormSubmit(), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{desensitize(q.title)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
DatabaseZapIcon,
|
DatabaseZapIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
MessageSquareCodeIcon,
|
|
||||||
ScrollTextIcon,
|
ScrollTextIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -217,16 +216,6 @@ export function WorkspaceNavMenu() {
|
|||||||
岗位管理
|
岗位管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
navigate("/page/workspace/admin/prompt-templates")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MessageSquareCodeIcon />
|
|
||||||
提示词模板
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ export interface CustomPrompt {
|
|||||||
* selected.
|
* selected.
|
||||||
*/
|
*/
|
||||||
agent_id?: string | null;
|
agent_id?: string | null;
|
||||||
/** ``"user"`` (the user's own) or ``"position"`` (granted by their 岗位). */
|
|
||||||
origin?: string;
|
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUERY_KEY = ["user-prompts"] as const;
|
const QUERY_KEY = ["user-prompts"] as const;
|
||||||
const TEMPLATES_KEY = ["user-prompts", "templates"] as const;
|
|
||||||
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||||
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
||||||
|
|
||||||
@@ -120,91 +117,3 @@ export function useDeleteCustomPrompt() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------- admin prompt templates
|
|
||||||
//
|
|
||||||
// Templates are admin-maintained prompts that carry ``user_prompt`` tags. The
|
|
||||||
// 岗位 (position) sync engine clones the templates a user's position grants
|
|
||||||
// into that user's own prompts. Tagging itself reuses the generic tags API.
|
|
||||||
|
|
||||||
export interface PromptTemplate {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
sort_order?: number;
|
|
||||||
agent_id?: string | null;
|
|
||||||
created_at?: string | null;
|
|
||||||
updated_at?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const templatesUrl = () => `${getBackendBaseURL()}/api/user-prompts/templates`;
|
|
||||||
|
|
||||||
async function listTemplates(): Promise<PromptTemplate[]> {
|
|
||||||
const res = await apiFetch(templatesUrl());
|
|
||||||
if (!res.ok) throw new Error(`Failed to load templates: ${res.statusText}`);
|
|
||||||
const data = (await res.json()) as { templates: PromptTemplate[] };
|
|
||||||
return data.templates;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTemplate(input: { title: string; content: string; agent_id?: string | null }): Promise<PromptTemplate> {
|
|
||||||
const res = await apiFetch(templatesUrl(), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
|
||||||
throw new Error(err.detail ?? `Failed to create template: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<PromptTemplate>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTemplate(id: string, input: { title?: string; content?: string; agent_id?: string | null }): Promise<PromptTemplate> {
|
|
||||||
const res = await apiFetch(`${templatesUrl()}/${encodeURIComponent(id)}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
|
||||||
throw new Error(err.detail ?? `Failed to update template: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<PromptTemplate>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTemplate(id: string): Promise<void> {
|
|
||||||
const res = await apiFetch(`${templatesUrl()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
|
||||||
throw new Error(err.detail ?? `Failed to delete template: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePromptTemplates() {
|
|
||||||
return useQuery({ queryKey: TEMPLATES_KEY, queryFn: listTemplates });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePromptTemplate() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createTemplate,
|
|
||||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: TEMPLATES_KEY }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdatePromptTemplate() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, input }: { id: string; input: { title?: string; content?: string; agent_id?: string | null } }) =>
|
|
||||||
updateTemplate(id, input),
|
|
||||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: TEMPLATES_KEY }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeletePromptTemplate() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteTemplate,
|
|
||||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: TEMPLATES_KEY }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -240,13 +240,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/admin/positions",
|
path: "/page/strategy/admin/positions",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "mgmt-prompt-templates",
|
|
||||||
label: "提示词模板",
|
|
||||||
icon: FileText,
|
|
||||||
path: "/page/strategy/admin/prompt-templates",
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mgmt-user-leaderboard",
|
id: "mgmt-user-leaderboard",
|
||||||
label: "用户排行榜",
|
label: "用户排行榜",
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ export type PositionResourceType =
|
|||||||
| "agent"
|
| "agent"
|
||||||
| "skill"
|
| "skill"
|
||||||
| "scheduled_task"
|
| "scheduled_task"
|
||||||
| "user_prompt"
|
|
||||||
| "recommended_question";
|
| "recommended_question";
|
||||||
|
|
||||||
export const POSITION_RESOURCE_TYPES: PositionResourceType[] = [
|
export const POSITION_RESOURCE_TYPES: PositionResourceType[] = [
|
||||||
"agent",
|
"agent",
|
||||||
"skill",
|
"skill",
|
||||||
"scheduled_task",
|
"scheduled_task",
|
||||||
"user_prompt",
|
|
||||||
"recommended_question",
|
"recommended_question",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
|
|||||||
agent: "智能体",
|
agent: "智能体",
|
||||||
skill: "技能",
|
skill: "技能",
|
||||||
scheduled_task: "定时任务",
|
scheduled_task: "定时任务",
|
||||||
user_prompt: "常用提示词",
|
|
||||||
recommended_question: "推荐问题",
|
recommended_question: "推荐问题",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,8 +26,7 @@ export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
|
|||||||
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
||||||
skill: "不配置标签 = 可见全部技能;配置后只可见所选标签下的技能。",
|
skill: "不配置标签 = 可见全部技能;配置后只可见所选标签下的技能。",
|
||||||
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
||||||
user_prompt: "不配置标签 = 加载未打标签的全局模板;配置后加载所选标签下的提示词模板。",
|
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题(聊天框上方的推荐问题)。",
|
||||||
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题。",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Position {
|
export interface Position {
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ export type TagType =
|
|||||||
| "agent"
|
| "agent"
|
||||||
| "skill"
|
| "skill"
|
||||||
| "scheduled_task"
|
| "scheduled_task"
|
||||||
| "user_prompt"
|
|
||||||
| "recommended_question";
|
| "recommended_question";
|
||||||
|
|
||||||
export const TAG_TYPES: TagType[] = [
|
export const TAG_TYPES: TagType[] = [
|
||||||
"agent",
|
"agent",
|
||||||
"skill",
|
"skill",
|
||||||
"scheduled_task",
|
"scheduled_task",
|
||||||
"user_prompt",
|
|
||||||
"recommended_question",
|
"recommended_question",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -18,7 +16,6 @@ export const TAG_TYPE_LABELS: Record<TagType, string> = {
|
|||||||
agent: "智能体",
|
agent: "智能体",
|
||||||
skill: "技能",
|
skill: "技能",
|
||||||
scheduled_task: "定时任务",
|
scheduled_task: "定时任务",
|
||||||
user_prompt: "常用提示词",
|
|
||||||
recommended_question: "推荐问题",
|
recommended_question: "推荐问题",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PromptTemplatesPage as PromptTemplatesManagement } from "@/components/workspace/admin/prompt-templates-page";
|
|
||||||
|
|
||||||
export default function PromptTemplatesPage() {
|
|
||||||
return <PromptTemplatesManagement />;
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,6 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
|||||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
import SkillsPage from "./SkillsPage";
|
import SkillsPage from "./SkillsPage";
|
||||||
import PositionsPage from "./PositionsPage";
|
import PositionsPage from "./PositionsPage";
|
||||||
import PromptTemplatesPage from "./PromptTemplatesPage";
|
|
||||||
import TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
@@ -299,16 +298,6 @@ export default function StrategyRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="admin/prompt-templates"
|
|
||||||
element={
|
|
||||||
getStoredAuth()?.system_role === "admin" ? (
|
|
||||||
<PromptTemplatesPage />
|
|
||||||
) : (
|
|
||||||
<Navigate to="/page/workspace/chats/new" replace />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import AdminMemoryPage from './AdminMemoryPage';
|
|||||||
import AdminUserHubPage from './AdminUserHubPage';
|
import AdminUserHubPage from './AdminUserHubPage';
|
||||||
import TagsPage from './TagsPage';
|
import TagsPage from './TagsPage';
|
||||||
import PositionsPage from './PositionsPage';
|
import PositionsPage from './PositionsPage';
|
||||||
import PromptTemplatesPage from './PromptTemplatesPage';
|
|
||||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||||
@@ -487,18 +486,6 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="admin/prompt-templates"
|
|
||||||
element={
|
|
||||||
getStoredAuth()?.system_role === 'admin' ? (
|
|
||||||
<WorkspaceLayout {...layoutProps}>
|
|
||||||
<PromptTemplatesPage />
|
|
||||||
</WorkspaceLayout>
|
|
||||||
) : (
|
|
||||||
<Navigate to="/page/workspace/chats/new" replace />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="settings/recommended-questions"
|
path="settings/recommended-questions"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -333,9 +333,12 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
|
|||||||
### 岗位 (Position) Subscription System
|
### 岗位 (Position) Subscription System
|
||||||
|
|
||||||
An admin-managed aggregation layer that decides, per user, which agents / skills
|
An admin-managed aggregation layer that decides, per user, which agents / skills
|
||||||
/ scheduled tasks / custom prompts / recommended questions they are subscribed
|
/ scheduled tasks / recommended questions they are subscribed to or can see.
|
||||||
to or can see. **A user has at most one position** (`users.position_id`, 1:1);
|
**A user has at most one position** (`users.position_id`, 1:1); unassigned users
|
||||||
unassigned users inherit the single `is_default` position.
|
inherit the single `is_default` position. Note: the admin-configured prompts
|
||||||
|
shown to users ARE the **recommended questions** (`recommended_questions` table)
|
||||||
|
— there is no separate prompt-template store; the per-user `user_custom_prompts`
|
||||||
|
(personal 常用提示词 bar) is untouched by this system.
|
||||||
|
|
||||||
**Persistence** (`deerflow/persistence/positions/`, wired as
|
**Persistence** (`deerflow/persistence/positions/`, wired as
|
||||||
`app.state.position_store`; tables auto-created by `create_all`, migration
|
`app.state.position_store`; tables auto-created by `create_all`, migration
|
||||||
@@ -345,27 +348,24 @@ unassigned users inherit the single `is_default` position.
|
|||||||
- `position_tag_bindings` (`PositionTagBindingRow`): one row per
|
- `position_tag_bindings` (`PositionTagBindingRow`): one row per
|
||||||
`(position, resource_type, tag)`. **No binding for a resource_type = "use the
|
`(position, resource_type, tag)`. **No binding for a resource_type = "use the
|
||||||
default set".** `POSITION_RESOURCE_TYPES = (agent, skill, scheduled_task,
|
default set".** `POSITION_RESOURCE_TYPES = (agent, skill, scheduled_task,
|
||||||
user_prompt, recommended_question)` — the same set drives the tag types
|
recommended_question)` — the same set drives the tag types (`TAG_TYPES` was
|
||||||
(`TAG_TYPES` was extended with `user_prompt` + `recommended_question`).
|
extended with `recommended_question`).
|
||||||
|
|
||||||
**Sync engine** (`deerflow/services/position_sync.py`, wired as
|
**Sync engine** (`deerflow/services/position_sync.py`, wired as
|
||||||
`app.state.position_sync`). `PositionSyncService.resolve_visible(user, type)`
|
`app.state.position_sync`). `PositionSyncService.resolve_visible(user, type)`
|
||||||
returns `set | None` (None = unrestricted) for the **query-time** visibility
|
returns `set | None` (None = unrestricted) for the **query-time** visibility
|
||||||
types (`skill`, `recommended_question`). `sync_user(user)` **materializes** the
|
types (`skill`, `recommended_question`). `sync_user(user)` **materializes** the
|
||||||
other three into the existing "我的" tables, idempotently:
|
other two into the existing "我的" tables, idempotently:
|
||||||
- agents → `agent_favorites` rows with `origin='position'`
|
- agents → `agent_favorites` rows with `origin='position'`
|
||||||
- scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'`
|
- scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'`
|
||||||
- prompts → clones of admin **templates** into `user_custom_prompts`
|
|
||||||
(`origin='position'`, `template_id` set)
|
|
||||||
|
|
||||||
Sync only ever adds/removes `origin='position'` rows, so a user's own
|
Sync only ever adds/removes `origin='position'` rows, so a user's own
|
||||||
favorites/subscriptions/prompts are never clobbered. Default sets: agents =
|
favorites/subscriptions are never clobbered. Default sets: agents = built-ins
|
||||||
built-ins (`user_id IS NULL`); scheduled tasks = empty; prompts = untagged
|
(`user_id IS NULL`); scheduled tasks = empty; skills/questions = unrestricted.
|
||||||
templates; skills/questions = unrestricted. `origin`/`position_id` columns were
|
`origin`/`position_id` columns were added to `agent_favorites` and
|
||||||
added to `agent_favorites`, `scheduled_task_subscriptions`, and
|
`scheduled_task_subscriptions` — see migration `20260616_01` +
|
||||||
`user_custom_prompts` (+ `is_template`/`template_id`) — see migration
|
`scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these
|
||||||
`20260616_01` + `scripts/sql/column_additions_mysql.sql`. The running app also
|
via `_ensure_orm_columns_sync` on startup.
|
||||||
auto-adds these via `_ensure_orm_columns_sync` on startup.
|
|
||||||
|
|
||||||
**Triggers**: assigning/removing members, changing tag bindings (re-syncs all
|
**Triggers**: assigning/removing members, changing tag bindings (re-syncs all
|
||||||
members), becoming the default position (`sync_default_members`), and login
|
members), becoming the default position (`sync_default_members`), and login
|
||||||
@@ -376,13 +376,13 @@ default gets their grants materialized).
|
|||||||
position CRUD; `GET/PUT /{id}/tag-bindings`; `GET/POST/DELETE /{id}/members`
|
position CRUD; `GET/PUT /{id}/tag-bindings`; `GET/POST/DELETE /{id}/members`
|
||||||
(batch assign/unassign → triggers sync); `POST /{id}/resync`;
|
(batch assign/unassign → triggers sync); `POST /{id}/resync`;
|
||||||
`GET /users` (all users + their position); `PUT /users/{user_id}` (set one
|
`GET /users` (all users + their position); `PUT /users/{user_id}` (set one
|
||||||
user's position). Related changes: prompt **templates** CRUD under
|
user's position). Related changes: `GET /api/recommended-questions` is now
|
||||||
`/api/user-prompts/templates` (admin; `is_template` rows excluded from a user's
|
filtered by the caller's position (`?all=1` bypasses for admin management) and
|
||||||
own `GET /api/user-prompts`); `GET /api/recommended-questions` is now filtered
|
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`;
|
||||||
by the caller's position (`?all=1` bypasses for admin management);
|
|
||||||
`GET /api/skills?scope=mine` applies skill visibility. Frontend: `core/positions`
|
`GET /api/skills?scope=mine` applies skill visibility. Frontend: `core/positions`
|
||||||
+ admin `PositionManagementPage` / `PromptTemplatesPage` (侧边栏「综合管理」),
|
+ admin `PositionManagementPage` (侧边栏「综合管理 → 岗位管理」 + 右下角设置菜单).
|
||||||
recommended-questions moved to a bottom-right Popover in the chat input. Tests:
|
Recommended questions stay rendered **above** the chat input (their original
|
||||||
|
position), just filtered by the caller's position. Tests:
|
||||||
`tests/test_positions.py`.
|
`tests/test_positions.py`.
|
||||||
|
|
||||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||||
|
|||||||
@@ -13,21 +13,12 @@ from uuid import uuid4
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_optional_user_from_request
|
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/user-prompts", tags=["user-prompts"])
|
router = APIRouter(prefix="/api/user-prompts", tags=["user-prompts"])
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(request: Request) -> None:
|
|
||||||
user = await get_optional_user_from_request(request)
|
|
||||||
if user is None:
|
|
||||||
return
|
|
||||||
if getattr(user, "system_role", None) != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="提示词模板管理仅限管理员")
|
|
||||||
|
|
||||||
|
|
||||||
class UserPromptResponse(BaseModel):
|
class UserPromptResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
title: str = ""
|
title: str = ""
|
||||||
@@ -35,40 +26,10 @@ class UserPromptResponse(BaseModel):
|
|||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
# Optional single-agent association. NULL = global (shown for every agent).
|
# Optional single-agent association. NULL = global (shown for every agent).
|
||||||
agent_id: str | None = None
|
agent_id: str | None = None
|
||||||
# ``"user"`` (the user's own) or ``"position"`` (granted by their 岗位).
|
|
||||||
origin: str = "user"
|
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
updated_at: datetime | str | None = None
|
updated_at: datetime | str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PromptTemplateResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
title: str = ""
|
|
||||||
content: str
|
|
||||||
sort_order: int = 0
|
|
||||||
agent_id: str | None = None
|
|
||||||
created_at: datetime | str | None = None
|
|
||||||
updated_at: datetime | str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PromptTemplatesListResponse(BaseModel):
|
|
||||||
templates: list[PromptTemplateResponse]
|
|
||||||
|
|
||||||
|
|
||||||
class PromptTemplateCreateRequest(BaseModel):
|
|
||||||
title: str = Field(default="", max_length=255)
|
|
||||||
content: str = Field(..., min_length=1)
|
|
||||||
sort_order: int = Field(default=0)
|
|
||||||
agent_id: str | None = Field(default=None, max_length=64)
|
|
||||||
|
|
||||||
|
|
||||||
class PromptTemplateUpdateRequest(BaseModel):
|
|
||||||
title: str | None = Field(default=None, max_length=255)
|
|
||||||
content: str | None = Field(default=None, min_length=1)
|
|
||||||
sort_order: int | None = None
|
|
||||||
agent_id: str | None = Field(default=None, max_length=64)
|
|
||||||
|
|
||||||
|
|
||||||
class UserPromptsListResponse(BaseModel):
|
class UserPromptsListResponse(BaseModel):
|
||||||
prompts: list[UserPromptResponse]
|
prompts: list[UserPromptResponse]
|
||||||
|
|
||||||
@@ -157,72 +118,3 @@ async def delete_user_prompt(request: Request, prompt_id: str) -> None:
|
|||||||
deleted = await store.delete(prompt_id, user_id)
|
deleted = await store.delete(prompt_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Prompt not found")
|
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------- admin prompt templates
|
|
||||||
#
|
|
||||||
# Templates are admin-maintained prompts that carry tags (target_type
|
|
||||||
# ``user_prompt`` via /api/tags/assignments). The 岗位 (position) sync engine
|
|
||||||
# clones the templates whose tags a user's position grants into that user's own
|
|
||||||
# prompts (origin='position'). Tagging itself reuses the generic tags API.
|
|
||||||
|
|
||||||
|
|
||||||
def _require_template_store(request: Request):
|
|
||||||
store = _get_store(request)
|
|
||||||
if not hasattr(store, "list_templates"):
|
|
||||||
raise HTTPException(status_code=503, detail="Prompt templates require a database backend")
|
|
||||||
return store
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates", response_model=PromptTemplatesListResponse)
|
|
||||||
async def list_prompt_templates(request: Request) -> PromptTemplatesListResponse:
|
|
||||||
await _require_admin(request)
|
|
||||||
store = _require_template_store(request)
|
|
||||||
rows = await store.list_templates()
|
|
||||||
return PromptTemplatesListResponse(templates=[PromptTemplateResponse(**r) for r in rows])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/templates", response_model=PromptTemplateResponse, status_code=201)
|
|
||||||
async def create_prompt_template(request: Request, body: PromptTemplateCreateRequest) -> PromptTemplateResponse:
|
|
||||||
await _require_admin(request)
|
|
||||||
store = _require_template_store(request)
|
|
||||||
admin_id = _current_user_id(request)
|
|
||||||
row = await store.create_template(
|
|
||||||
admin_id,
|
|
||||||
{
|
|
||||||
"id": uuid4().hex,
|
|
||||||
"title": (body.title or "").strip(),
|
|
||||||
"content": body.content,
|
|
||||||
"sort_order": body.sort_order,
|
|
||||||
"agent_id": (body.agent_id or "").strip() or None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return PromptTemplateResponse(**row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/templates/{template_id}", response_model=PromptTemplateResponse)
|
|
||||||
async def update_prompt_template(request: Request, template_id: str, body: PromptTemplateUpdateRequest) -> PromptTemplateResponse:
|
|
||||||
await _require_admin(request)
|
|
||||||
store = _require_template_store(request)
|
|
||||||
data: dict = {}
|
|
||||||
if body.title is not None:
|
|
||||||
data["title"] = body.title.strip()
|
|
||||||
if body.content is not None:
|
|
||||||
data["content"] = body.content
|
|
||||||
if body.sort_order is not None:
|
|
||||||
data["sort_order"] = body.sort_order
|
|
||||||
if "agent_id" in body.model_fields_set:
|
|
||||||
data["agent_id"] = (body.agent_id or "").strip() or None
|
|
||||||
row = await store.update_template(template_id, data)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
|
||||||
return PromptTemplateResponse(**row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/templates/{template_id}", status_code=204)
|
|
||||||
async def delete_prompt_template(request: Request, template_id: str) -> None:
|
|
||||||
await _require_admin(request)
|
|
||||||
store = _require_template_store(request)
|
|
||||||
deleted = await store.delete_template(template_id)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
|
||||||
+1
-18
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
New tables ``positions`` + ``position_tag_bindings``; new column
|
New tables ``positions`` + ``position_tag_bindings``; new column
|
||||||
``users.position_id``; ``origin`` / ``position_id`` on ``agent_favorites`` and
|
``users.position_id``; ``origin`` / ``position_id`` on ``agent_favorites`` and
|
||||||
``scheduled_task_subscriptions``; ``is_template`` / ``origin`` / ``template_id``
|
``scheduled_task_subscriptions``.
|
||||||
/ ``position_id`` on ``user_custom_prompts``.
|
|
||||||
|
|
||||||
Note: the running app also auto-adds these via ``_ensure_orm_columns_sync`` and
|
Note: the running app also auto-adds these via ``_ensure_orm_columns_sync`` and
|
||||||
``create_all`` on startup; this migration keeps the alembic history consistent
|
``create_all`` on startup; this migration keeps the alembic history consistent
|
||||||
@@ -77,19 +76,6 @@ def upgrade() -> None:
|
|||||||
op.add_column(table, sa.Column("position_id", sa.String(64), nullable=True))
|
op.add_column(table, sa.Column("position_id", sa.String(64), nullable=True))
|
||||||
op.create_index(f"ix_{table}_position_id", table, ["position_id"])
|
op.create_index(f"ix_{table}_position_id", table, ["position_id"])
|
||||||
|
|
||||||
if _has_table("user_custom_prompts"):
|
|
||||||
if not _has_column("user_custom_prompts", "is_template"):
|
|
||||||
op.add_column("user_custom_prompts", sa.Column("is_template", sa.Boolean(), nullable=False, server_default=sa.text("0")))
|
|
||||||
op.create_index("ix_user_custom_prompts_is_template", "user_custom_prompts", ["is_template"])
|
|
||||||
if not _has_column("user_custom_prompts", "origin"):
|
|
||||||
op.add_column("user_custom_prompts", sa.Column("origin", sa.String(16), nullable=False, server_default="user"))
|
|
||||||
if not _has_column("user_custom_prompts", "template_id"):
|
|
||||||
op.add_column("user_custom_prompts", sa.Column("template_id", sa.String(64), nullable=True))
|
|
||||||
op.create_index("ix_user_custom_prompts_template_id", "user_custom_prompts", ["template_id"])
|
|
||||||
if not _has_column("user_custom_prompts", "position_id"):
|
|
||||||
op.add_column("user_custom_prompts", sa.Column("position_id", sa.String(64), nullable=True))
|
|
||||||
op.create_index("ix_user_custom_prompts_position_id", "user_custom_prompts", ["position_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
for table in ("agent_favorites", "scheduled_task_subscriptions"):
|
for table in ("agent_favorites", "scheduled_task_subscriptions"):
|
||||||
@@ -97,9 +83,6 @@ def downgrade() -> None:
|
|||||||
op.drop_column(table, "position_id")
|
op.drop_column(table, "position_id")
|
||||||
if _has_column(table, "origin"):
|
if _has_column(table, "origin"):
|
||||||
op.drop_column(table, "origin")
|
op.drop_column(table, "origin")
|
||||||
for col in ("position_id", "template_id", "origin", "is_template"):
|
|
||||||
if _has_column("user_custom_prompts", col):
|
|
||||||
op.drop_column("user_custom_prompts", col)
|
|
||||||
if _has_column("users", "position_id"):
|
if _has_column("users", "position_id"):
|
||||||
op.drop_column("users", "position_id")
|
op.drop_column("users", "position_id")
|
||||||
if _has_table("position_tag_bindings"):
|
if _has_table("position_tag_bindings"):
|
||||||
|
|||||||
-1
@@ -32,7 +32,6 @@ POSITION_RESOURCE_TYPES: tuple[str, ...] = (
|
|||||||
"agent",
|
"agent",
|
||||||
"skill",
|
"skill",
|
||||||
"scheduled_task",
|
"scheduled_task",
|
||||||
"user_prompt",
|
|
||||||
"recommended_question",
|
"recommended_question",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -21,9 +21,9 @@ from deerflow.persistence.base import Base
|
|||||||
from deerflow.persistence.types import BeijingDateTime
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
# Allowed tag/target types. Kept here so persistence + API share one source.
|
# Allowed tag/target types. Kept here so persistence + API share one source.
|
||||||
# ``user_prompt`` / ``recommended_question`` were added for the 岗位 (position)
|
# ``recommended_question`` was added for the 岗位 (position) subscription system
|
||||||
# subscription system so those resources can be tag-targeted like agents/skills.
|
# so admin-configured recommended questions can be tag-targeted like agents.
|
||||||
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task", "user_prompt", "recommended_question")
|
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task", "recommended_question")
|
||||||
|
|
||||||
|
|
||||||
class TagRow(Base):
|
class TagRow(Base):
|
||||||
|
|||||||
+1
-14
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Integer, String, Text
|
from sqlalchemy import Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
@@ -22,18 +22,5 @@ class UserCustomPromptRow(Base):
|
|||||||
# Optional association to a single agent. NULL = global (shown everywhere);
|
# Optional association to a single agent. NULL = global (shown everywhere);
|
||||||
# otherwise the prompt only surfaces when that agent is selected.
|
# otherwise the prompt only surfaces when that agent is selected.
|
||||||
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
# When True, this row is an admin-maintained **template** (not a user's own
|
|
||||||
# prompt). Templates are tag-targeted (via TagAssignmentRow,
|
|
||||||
# target_type='user_prompt') and cloned into a user's prompts by the 岗位
|
|
||||||
# sync engine. ``user_id`` of a template holds the admin's id (audit only).
|
|
||||||
is_template: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", index=True)
|
|
||||||
# Source of a non-template row: ``"user"`` (created by the user) or
|
|
||||||
# ``"position"`` (cloned from a template by the sync engine). Sync only
|
|
||||||
# touches ``"position"`` rows.
|
|
||||||
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user")
|
|
||||||
# For ``origin='position'`` clones: the template this row was cloned from
|
|
||||||
# (so re-sync can reconcile by template id) and the granting position.
|
|
||||||
template_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
||||||
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
+1
-62
@@ -25,76 +25,15 @@ class UserCustomPromptRepository:
|
|||||||
self._sf = session_factory
|
self._sf = session_factory
|
||||||
|
|
||||||
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
# Exclude admin templates (``is_template=True``) — those are not a
|
|
||||||
# user's own prompts; position-cloned rows (``is_template=False``,
|
|
||||||
# ``origin='position'``) DO show, so the user sees their 岗位 prompts.
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(UserCustomPromptRow)
|
select(UserCustomPromptRow)
|
||||||
.where(UserCustomPromptRow.user_id == user_id, UserCustomPromptRow.is_template.is_(False))
|
.where(UserCustomPromptRow.user_id == user_id)
|
||||||
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
|
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
|
||||||
)
|
)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [_row_to_dict(row) for row in result.scalars()]
|
return [_row_to_dict(row) for row in result.scalars()]
|
||||||
|
|
||||||
# ----------------------------------------------------- admin templates
|
|
||||||
|
|
||||||
async def list_templates(self) -> list[dict[str, Any]]:
|
|
||||||
stmt = select(UserCustomPromptRow).where(UserCustomPromptRow.is_template.is_(True)).order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
|
|
||||||
async with self._sf() as session:
|
|
||||||
return [_row_to_dict(row) for row in (await session.execute(stmt)).scalars()]
|
|
||||||
|
|
||||||
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
|
||||||
async with self._sf() as session:
|
|
||||||
row = await session.get(UserCustomPromptRow, template_id)
|
|
||||||
if row is None or not row.is_template:
|
|
||||||
return None
|
|
||||||
return _row_to_dict(row)
|
|
||||||
|
|
||||||
async def create_template(self, admin_user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
row = UserCustomPromptRow(
|
|
||||||
id=data["id"],
|
|
||||||
user_id=admin_user_id,
|
|
||||||
title=data.get("title") or "",
|
|
||||||
content=data["content"],
|
|
||||||
sort_order=data.get("sort_order", 0),
|
|
||||||
agent_id=(data.get("agent_id") or None),
|
|
||||||
is_template=True,
|
|
||||||
origin="user",
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
async with self._sf() as session:
|
|
||||||
session.add(row)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(row)
|
|
||||||
return _row_to_dict(row)
|
|
||||||
|
|
||||||
async def update_template(self, template_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
async with self._sf() as session:
|
|
||||||
row = await session.get(UserCustomPromptRow, template_id)
|
|
||||||
if row is None or not row.is_template:
|
|
||||||
return None
|
|
||||||
for key in ("title", "content", "sort_order"):
|
|
||||||
if key in data:
|
|
||||||
setattr(row, key, data[key])
|
|
||||||
if "agent_id" in data:
|
|
||||||
row.agent_id = data["agent_id"] or None
|
|
||||||
row.updated_at = datetime.now(UTC)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(row)
|
|
||||||
return _row_to_dict(row)
|
|
||||||
|
|
||||||
async def delete_template(self, template_id: str) -> bool:
|
|
||||||
async with self._sf() as session:
|
|
||||||
row = await session.get(UserCustomPromptRow, template_id)
|
|
||||||
if row is None or not row.is_template:
|
|
||||||
return False
|
|
||||||
await session.delete(row)
|
|
||||||
await session.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
row = UserCustomPromptRow(
|
row = UserCustomPromptRow(
|
||||||
|
|||||||
+8
-64
@@ -14,14 +14,15 @@ Resolution rule (per resource type):
|
|||||||
* For a resource type **without** bound tags → the *default set*:
|
* For a resource type **without** bound tags → the *default set*:
|
||||||
- ``agent`` → all built-in agents (``user_id IS NULL``)
|
- ``agent`` → all built-in agents (``user_id IS NULL``)
|
||||||
- ``scheduled_task`` → empty (no built-in tasks)
|
- ``scheduled_task`` → empty (no built-in tasks)
|
||||||
- ``user_prompt`` → admin templates that carry no tag
|
|
||||||
- ``skill`` / ``recommended_question`` → *unrestricted* (handled at query
|
- ``skill`` / ``recommended_question`` → *unrestricted* (handled at query
|
||||||
time, never materialized — see :meth:`resolve_visible`)
|
time, never materialized — see :meth:`resolve_visible`)
|
||||||
|
|
||||||
Materialization (``sync_user``) only ever touches rows it created itself
|
Materialization (``sync_user``) only ever touches rows it created itself
|
||||||
(``origin='position'``), so a user's own favorites / subscriptions / prompts are
|
(``origin='position'``), so a user's own favorites / subscriptions are never
|
||||||
never clobbered. The skill + recommended-question visibility filters are
|
clobbered. The skill + recommended-question visibility filters are computed live
|
||||||
computed live by :meth:`resolve_visible` and are *not* materialized.
|
by :meth:`resolve_visible` and are *not* materialized. (The admin-configured
|
||||||
|
prompts shown to users ARE the recommended questions — there is no separate
|
||||||
|
prompt-template store.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -30,19 +31,18 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||||
from deerflow.persistence.positions.base import PositionStore
|
from deerflow.persistence.positions.base import PositionStore
|
||||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
||||||
from deerflow.persistence.tags.base import TagStore
|
from deerflow.persistence.tags.base import TagStore
|
||||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Materialized into "我的" tables by sync_user.
|
# Materialized into "我的" tables by sync_user.
|
||||||
_MATERIALIZED_TYPES = ("agent", "scheduled_task", "user_prompt")
|
_MATERIALIZED_TYPES = ("agent", "scheduled_task")
|
||||||
# Resolved live at query time (visibility filters), never materialized.
|
# Resolved live at query time (visibility filters), never materialized.
|
||||||
_VISIBILITY_TYPES = ("skill", "recommended_question")
|
_VISIBILITY_TYPES = ("skill", "recommended_question")
|
||||||
|
|
||||||
@@ -89,20 +89,9 @@ class PositionSyncService:
|
|||||||
if resource_type == "agent":
|
if resource_type == "agent":
|
||||||
rows = (await session.execute(select(AgentRow.id).where(AgentRow.user_id.is_(None)))).all()
|
rows = (await session.execute(select(AgentRow.id).where(AgentRow.user_id.is_(None)))).all()
|
||||||
return {r[0] for r in rows}
|
return {r[0] for r in rows}
|
||||||
if resource_type == "scheduled_task":
|
# scheduled_task (and any other materialized type) defaults to empty.
|
||||||
return set()
|
|
||||||
if resource_type == "user_prompt":
|
|
||||||
# Admin templates that carry no tag = global default templates.
|
|
||||||
template_ids = {r[0] for r in (await session.execute(select(UserCustomPromptRow.id).where(UserCustomPromptRow.is_template.is_(True)))).all()}
|
|
||||||
if not template_ids:
|
|
||||||
return set()
|
|
||||||
tagged = set(await self._tags.list_targets_for_tags("user_prompt", await self._all_user_prompt_tag_ids()))
|
|
||||||
return template_ids - tagged
|
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
async def _all_user_prompt_tag_ids(self) -> list[str]:
|
|
||||||
return [t["id"] for t in await self._tags.list_tags("user_prompt")]
|
|
||||||
|
|
||||||
# --------------------------------------------------------- materialize
|
# --------------------------------------------------------- materialize
|
||||||
|
|
||||||
async def sync_user(self, user_id: str) -> None:
|
async def sync_user(self, user_id: str) -> None:
|
||||||
@@ -113,7 +102,6 @@ class PositionSyncService:
|
|||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
await self._sync_agents(session, user_id, pid)
|
await self._sync_agents(session, user_id, pid)
|
||||||
await self._sync_tasks(session, user_id, pid)
|
await self._sync_tasks(session, user_id, pid)
|
||||||
await self._sync_prompts(session, user_id, pid)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
async def _sync_agents(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
async def _sync_agents(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
||||||
@@ -140,50 +128,6 @@ class PositionSyncService:
|
|||||||
if task_id not in target:
|
if task_id not in target:
|
||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
|
|
||||||
async def _sync_prompts(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
|
||||||
target_template_ids = await self._resolve_materialized(user_id, "user_prompt", session)
|
|
||||||
# Load the templates we need to clone, so we can copy their content.
|
|
||||||
templates: dict[str, UserCustomPromptRow] = {}
|
|
||||||
if target_template_ids:
|
|
||||||
for row in (await session.execute(select(UserCustomPromptRow).where(UserCustomPromptRow.id.in_(target_template_ids), UserCustomPromptRow.is_template.is_(True)))).scalars():
|
|
||||||
templates[row.id] = row
|
|
||||||
existing = list(
|
|
||||||
(await session.execute(select(UserCustomPromptRow).where(UserCustomPromptRow.user_id == user_id, UserCustomPromptRow.origin == "position"))).scalars()
|
|
||||||
)
|
|
||||||
existing_by_template = {r.template_id: r for r in existing if r.template_id}
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
# Remove stale clones (template no longer granted or template deleted).
|
|
||||||
for tpl_id, row in existing_by_template.items():
|
|
||||||
if tpl_id not in templates:
|
|
||||||
await session.delete(row)
|
|
||||||
# Add / refresh clones for granted templates.
|
|
||||||
for tpl_id, tpl in templates.items():
|
|
||||||
row = existing_by_template.get(tpl_id)
|
|
||||||
if row is None:
|
|
||||||
session.add(
|
|
||||||
UserCustomPromptRow(
|
|
||||||
id=uuid.uuid4().hex,
|
|
||||||
user_id=user_id,
|
|
||||||
title=tpl.title,
|
|
||||||
content=tpl.content,
|
|
||||||
sort_order=tpl.sort_order,
|
|
||||||
agent_id=tpl.agent_id,
|
|
||||||
is_template=False,
|
|
||||||
origin="position",
|
|
||||||
template_id=tpl_id,
|
|
||||||
position_id=position_id,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Keep the user's clone in step with the template content.
|
|
||||||
row.title = tpl.title
|
|
||||||
row.content = tpl.content
|
|
||||||
row.agent_id = tpl.agent_id
|
|
||||||
row.position_id = position_id
|
|
||||||
row.updated_at = now
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- fan-out
|
# ------------------------------------------------------------- fan-out
|
||||||
|
|
||||||
async def sync_position(self, position_id: str) -> int:
|
async def sync_position(self, position_id: str) -> int:
|
||||||
|
|||||||
@@ -93,11 +93,6 @@ CALL _add_column_if_missing('agent_favorites', 'position_id', 'VARCHAR(64) NULL
|
|||||||
-- scheduled_task_subscriptions: 同上。
|
-- scheduled_task_subscriptions: 同上。
|
||||||
CALL _add_column_if_missing('scheduled_task_subscriptions', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
|
CALL _add_column_if_missing('scheduled_task_subscriptions', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
|
||||||
CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
|
CALL _add_column_if_missing('scheduled_task_subscriptions', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
|
||||||
-- user_custom_prompts: is_template 标记管理员模板;origin/template_id/position_id 记岗位克隆来源。
|
|
||||||
CALL _add_column_if_missing('user_custom_prompts', 'is_template', 'TINYINT(1) NOT NULL DEFAULT 0');
|
|
||||||
CALL _add_column_if_missing('user_custom_prompts', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
|
|
||||||
CALL _add_column_if_missing('user_custom_prompts', 'template_id', 'VARCHAR(64) NULL DEFAULT NULL');
|
|
||||||
CALL _add_column_if_missing('user_custom_prompts', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
|
|
||||||
|
|
||||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||||
|
|||||||
Reference in new issue
Block a user