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>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<div className="flex items-center justify-center pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1103,35 +1122,6 @@ export function InputBox({
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</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
|
||||
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",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageSquareCodeIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -217,16 +216,6 @@ export function WorkspaceNavMenu() {
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/prompt-templates")
|
||||
}
|
||||
>
|
||||
<MessageSquareCodeIcon />
|
||||
提示词模板
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -15,14 +15,11 @@ export interface CustomPrompt {
|
||||
* selected.
|
||||
*/
|
||||
agent_id?: string | null;
|
||||
/** ``"user"`` (the user's own) or ``"position"`` (granted by their 岗位). */
|
||||
origin?: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["user-prompts"] as const;
|
||||
const TEMPLATES_KEY = ["user-prompts", "templates"] as const;
|
||||
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||
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",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-templates",
|
||||
label: "提示词模板",
|
||||
icon: FileText,
|
||||
path: "/page/strategy/admin/prompt-templates",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-leaderboard",
|
||||
label: "用户排行榜",
|
||||
|
||||
@@ -5,14 +5,12 @@ export type PositionResourceType =
|
||||
| "agent"
|
||||
| "skill"
|
||||
| "scheduled_task"
|
||||
| "user_prompt"
|
||||
| "recommended_question";
|
||||
|
||||
export const POSITION_RESOURCE_TYPES: PositionResourceType[] = [
|
||||
"agent",
|
||||
"skill",
|
||||
"scheduled_task",
|
||||
"user_prompt",
|
||||
"recommended_question",
|
||||
];
|
||||
|
||||
@@ -21,7 +19,6 @@ export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
|
||||
agent: "智能体",
|
||||
skill: "技能",
|
||||
scheduled_task: "定时任务",
|
||||
user_prompt: "常用提示词",
|
||||
recommended_question: "推荐问题",
|
||||
};
|
||||
|
||||
@@ -29,8 +26,7 @@ export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
|
||||
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
||||
skill: "不配置标签 = 可见全部技能;配置后只可见所选标签下的技能。",
|
||||
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
||||
user_prompt: "不配置标签 = 加载未打标签的全局模板;配置后加载所选标签下的提示词模板。",
|
||||
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题。",
|
||||
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题(聊天框上方的推荐问题)。",
|
||||
};
|
||||
|
||||
export interface Position {
|
||||
|
||||
@@ -2,14 +2,12 @@ export type TagType =
|
||||
| "agent"
|
||||
| "skill"
|
||||
| "scheduled_task"
|
||||
| "user_prompt"
|
||||
| "recommended_question";
|
||||
|
||||
export const TAG_TYPES: TagType[] = [
|
||||
"agent",
|
||||
"skill",
|
||||
"scheduled_task",
|
||||
"user_prompt",
|
||||
"recommended_question",
|
||||
];
|
||||
|
||||
@@ -18,7 +16,6 @@ export const TAG_TYPE_LABELS: Record<TagType, string> = {
|
||||
agent: "智能体",
|
||||
skill: "技能",
|
||||
scheduled_task: "定时任务",
|
||||
user_prompt: "常用提示词",
|
||||
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 SkillsPage from "./SkillsPage";
|
||||
import PositionsPage from "./PositionsPage";
|
||||
import PromptTemplatesPage from "./PromptTemplatesPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
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>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -40,7 +40,6 @@ import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PromptTemplatesPage from './PromptTemplatesPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
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
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
|
||||
@@ -333,9 +333,12 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
|
||||
### 岗位 (Position) Subscription System
|
||||
|
||||
An admin-managed aggregation layer that decides, per user, which agents / skills
|
||||
/ scheduled tasks / custom prompts / recommended questions they are subscribed
|
||||
to or can see. **A user has at most one position** (`users.position_id`, 1:1);
|
||||
unassigned users inherit the single `is_default` position.
|
||||
/ scheduled tasks / recommended questions they are subscribed to or can see.
|
||||
**A user has at most one position** (`users.position_id`, 1:1); unassigned users
|
||||
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
|
||||
`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, resource_type, tag)`. **No binding for a resource_type = "use the
|
||||
default set".** `POSITION_RESOURCE_TYPES = (agent, skill, scheduled_task,
|
||||
user_prompt, recommended_question)` — the same set drives the tag types
|
||||
(`TAG_TYPES` was extended with `user_prompt` + `recommended_question`).
|
||||
recommended_question)` — the same set drives the tag types (`TAG_TYPES` was
|
||||
extended with `recommended_question`).
|
||||
|
||||
**Sync engine** (`deerflow/services/position_sync.py`, wired as
|
||||
`app.state.position_sync`). `PositionSyncService.resolve_visible(user, type)`
|
||||
returns `set | None` (None = unrestricted) for the **query-time** visibility
|
||||
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'`
|
||||
- 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
|
||||
favorites/subscriptions/prompts are never clobbered. Default sets: agents =
|
||||
built-ins (`user_id IS NULL`); scheduled tasks = empty; prompts = untagged
|
||||
templates; skills/questions = unrestricted. `origin`/`position_id` columns were
|
||||
added to `agent_favorites`, `scheduled_task_subscriptions`, and
|
||||
`user_custom_prompts` (+ `is_template`/`template_id`) — see migration
|
||||
`20260616_01` + `scripts/sql/column_additions_mysql.sql`. The running app also
|
||||
auto-adds these via `_ensure_orm_columns_sync` on startup.
|
||||
favorites/subscriptions are never clobbered. Default sets: agents = built-ins
|
||||
(`user_id IS NULL`); scheduled tasks = empty; skills/questions = unrestricted.
|
||||
`origin`/`position_id` columns were added to `agent_favorites` and
|
||||
`scheduled_task_subscriptions` — see migration `20260616_01` +
|
||||
`scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these
|
||||
via `_ensure_orm_columns_sync` on startup.
|
||||
|
||||
**Triggers**: assigning/removing members, changing tag bindings (re-syncs all
|
||||
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`
|
||||
(batch assign/unassign → triggers sync); `POST /{id}/resync`;
|
||||
`GET /users` (all users + their position); `PUT /users/{user_id}` (set one
|
||||
user's position). Related changes: prompt **templates** CRUD under
|
||||
`/api/user-prompts/templates` (admin; `is_template` rows excluded from a user's
|
||||
own `GET /api/user-prompts`); `GET /api/recommended-questions` is now filtered
|
||||
by the caller's position (`?all=1` bypasses for admin management);
|
||||
user's position). Related changes: `GET /api/recommended-questions` is now
|
||||
filtered by the caller's position (`?all=1` bypasses for admin management) and
|
||||
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`;
|
||||
`GET /api/skills?scope=mine` applies skill visibility. Frontend: `core/positions`
|
||||
+ admin `PositionManagementPage` / `PromptTemplatesPage` (侧边栏「综合管理」),
|
||||
recommended-questions moved to a bottom-right Popover in the chat input. Tests:
|
||||
+ admin `PositionManagementPage` (侧边栏「综合管理 → 岗位管理」 + 右下角设置菜单).
|
||||
Recommended questions stay rendered **above** the chat input (their original
|
||||
position), just filtered by the caller's position. Tests:
|
||||
`tests/test_positions.py`.
|
||||
|
||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||
|
||||
@@ -13,21 +13,12 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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):
|
||||
id: str
|
||||
title: str = ""
|
||||
@@ -35,40 +26,10 @@ class UserPromptResponse(BaseModel):
|
||||
sort_order: int = 0
|
||||
# Optional single-agent association. NULL = global (shown for every agent).
|
||||
agent_id: str | None = None
|
||||
# ``"user"`` (the user's own) or ``"position"`` (granted by their 岗位).
|
||||
origin: str = "user"
|
||||
created_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):
|
||||
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)
|
||||
if not deleted:
|
||||
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
|
||||
``users.position_id``; ``origin`` / ``position_id`` on ``agent_favorites`` and
|
||||
``scheduled_task_subscriptions``; ``is_template`` / ``origin`` / ``template_id``
|
||||
/ ``position_id`` on ``user_custom_prompts``.
|
||||
``scheduled_task_subscriptions``.
|
||||
|
||||
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
|
||||
@@ -77,19 +76,6 @@ def upgrade() -> None:
|
||||
op.add_column(table, sa.Column("position_id", sa.String(64), nullable=True))
|
||||
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:
|
||||
for table in ("agent_favorites", "scheduled_task_subscriptions"):
|
||||
@@ -97,9 +83,6 @@ def downgrade() -> None:
|
||||
op.drop_column(table, "position_id")
|
||||
if _has_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"):
|
||||
op.drop_column("users", "position_id")
|
||||
if _has_table("position_tag_bindings"):
|
||||
|
||||
-1
@@ -32,7 +32,6 @@ POSITION_RESOURCE_TYPES: tuple[str, ...] = (
|
||||
"agent",
|
||||
"skill",
|
||||
"scheduled_task",
|
||||
"user_prompt",
|
||||
"recommended_question",
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -21,9 +21,9 @@ from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
# Allowed tag/target types. Kept here so persistence + API share one source.
|
||||
# ``user_prompt`` / ``recommended_question`` were added for the 岗位 (position)
|
||||
# subscription system so those resources can be tag-targeted like agents/skills.
|
||||
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task", "user_prompt", "recommended_question")
|
||||
# ``recommended_question`` was added for the 岗位 (position) subscription system
|
||||
# so admin-configured recommended questions can be tag-targeted like agents.
|
||||
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task", "recommended_question")
|
||||
|
||||
|
||||
class TagRow(Base):
|
||||
|
||||
+1
-14
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
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 deerflow.persistence.base import Base
|
||||
@@ -22,18 +22,5 @@ class UserCustomPromptRow(Base):
|
||||
# Optional association to a single agent. NULL = global (shown everywhere);
|
||||
# otherwise the prompt only surfaces when that agent is selected.
|
||||
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))
|
||||
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
|
||||
|
||||
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 = (
|
||||
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())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
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]:
|
||||
now = datetime.now(UTC)
|
||||
row = UserCustomPromptRow(
|
||||
|
||||
+8
-64
@@ -14,14 +14,15 @@ Resolution rule (per resource type):
|
||||
* For a resource type **without** bound tags → the *default set*:
|
||||
- ``agent`` → all built-in agents (``user_id IS NULL``)
|
||||
- ``scheduled_task`` → empty (no built-in tasks)
|
||||
- ``user_prompt`` → admin templates that carry no tag
|
||||
- ``skill`` / ``recommended_question`` → *unrestricted* (handled at query
|
||||
time, never materialized — see :meth:`resolve_visible`)
|
||||
|
||||
Materialization (``sync_user``) only ever touches rows it created itself
|
||||
(``origin='position'``), so a user's own favorites / subscriptions / prompts are
|
||||
never clobbered. The skill + recommended-question visibility filters are
|
||||
computed live by :meth:`resolve_visible` and are *not* materialized.
|
||||
(``origin='position'``), so a user's own favorites / subscriptions are never
|
||||
clobbered. The skill + recommended-question visibility filters are computed live
|
||||
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
|
||||
@@ -30,19 +31,18 @@ import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
from deerflow.persistence.positions.base import PositionStore
|
||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
||||
from deerflow.persistence.tags.base import TagStore
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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.
|
||||
_VISIBILITY_TYPES = ("skill", "recommended_question")
|
||||
|
||||
@@ -89,20 +89,9 @@ class PositionSyncService:
|
||||
if resource_type == "agent":
|
||||
rows = (await session.execute(select(AgentRow.id).where(AgentRow.user_id.is_(None)))).all()
|
||||
return {r[0] for r in rows}
|
||||
if resource_type == "scheduled_task":
|
||||
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
|
||||
# scheduled_task (and any other materialized type) defaults to empty.
|
||||
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
|
||||
|
||||
async def sync_user(self, user_id: str) -> None:
|
||||
@@ -113,7 +102,6 @@ class PositionSyncService:
|
||||
async with self._sf() as session:
|
||||
await self._sync_agents(session, user_id, pid)
|
||||
await self._sync_tasks(session, user_id, pid)
|
||||
await self._sync_prompts(session, user_id, pid)
|
||||
await session.commit()
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
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: 同上。
|
||||
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');
|
||||
-- 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;
|
||||
|
||||
Reference in new issue
Block a user