refactor: 移除提示词模板页面及相关接口,精简岗位/标签/用户提示词模型

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-16 14:32:21 +08:00
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: "用户排行榜",
+1 -5
View File
@@ -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 {
-3
View File
@@ -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 />;
}
-11
View File
@@ -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={
+21 -21
View File
@@ -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")
@@ -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"):
@@ -32,7 +32,6 @@ POSITION_RESOURCE_TYPES: tuple[str, ...] = (
"agent", "agent",
"skill", "skill",
"scheduled_task", "scheduled_task",
"user_prompt",
"recommended_question", "recommended_question",
) )
@@ -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):
@@ -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))
@@ -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(
@@ -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;