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>
)}
{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: "用户排行榜",
+1 -5
View File
@@ -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 {
-3
View File
@@ -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 />;
}
-11
View File
@@ -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={
+21 -21
View File
@@ -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")
@@ -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"):
@@ -32,7 +32,6 @@ POSITION_RESOURCE_TYPES: tuple[str, ...] = (
"agent",
"skill",
"scheduled_task",
"user_prompt",
"recommended_question",
)
@@ -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):
@@ -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))
@@ -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(
@@ -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;