feat: 新增岗位管理、提示词模板与资源标签,完善用户提示词与推荐问题接口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2f414c6e08
commit
3855c30edd
52 files changed
+3142
-56
No files matched your search
@@ -0,0 +1,479 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
Loader2,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UserPlusIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, 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 {
|
||||||
|
POSITION_RESOURCE_HINT,
|
||||||
|
POSITION_RESOURCE_LABELS,
|
||||||
|
POSITION_RESOURCE_TYPES,
|
||||||
|
resourceTypeToTagType,
|
||||||
|
useAddMembers,
|
||||||
|
useCreatePosition,
|
||||||
|
useDeletePosition,
|
||||||
|
usePositionMembers,
|
||||||
|
usePositionTagBindings,
|
||||||
|
usePositionUsers,
|
||||||
|
usePositions,
|
||||||
|
useRemoveMembers,
|
||||||
|
useResyncPosition,
|
||||||
|
useSetTagBindings,
|
||||||
|
useUpdatePosition,
|
||||||
|
type Position,
|
||||||
|
type PositionResourceType,
|
||||||
|
type TagBindings,
|
||||||
|
} from "@/core/positions";
|
||||||
|
import { useTags } from "@/core/tags";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
return String(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline multi-select chip row for the tags of one resource type. */
|
||||||
|
function TagMultiSelect({
|
||||||
|
resourceType,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
resourceType: PositionResourceType;
|
||||||
|
value: string[];
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const { data: tags, isLoading } = useTags(resourceTypeToTagType(resourceType));
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loader2 className="text-muted-foreground size-4 animate-spin" />;
|
||||||
|
}
|
||||||
|
if (!tags || tags.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
暂无{POSITION_RESOURCE_LABELS[resourceType]}标签,请先到「标签管理」创建。
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const checked = value.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editing,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editing: Position | null;
|
||||||
|
}) {
|
||||||
|
const createPosition = useCreatePosition();
|
||||||
|
const updatePosition = useUpdatePosition();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isDefault, setIsDefault] = useState(false);
|
||||||
|
|
||||||
|
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||||
|
const syncKey = `${open}:${editing?.id ?? "new"}`;
|
||||||
|
if (open && syncedFor !== syncKey) {
|
||||||
|
setName(editing?.name ?? "");
|
||||||
|
setDescription(editing?.description ?? "");
|
||||||
|
setIsDefault(editing?.is_default ?? false);
|
||||||
|
setSyncedFor(syncKey);
|
||||||
|
}
|
||||||
|
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||||
|
|
||||||
|
const pending = createPosition.isPending || updatePosition.isPending;
|
||||||
|
const handleSave = async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updatePosition.mutateAsync({ id: editing.id, input: { name: trimmed, description, is_default: isDefault } });
|
||||||
|
} else {
|
||||||
|
await createPosition.mutateAsync({ name: trimmed, description, is_default: isDefault });
|
||||||
|
}
|
||||||
|
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={name} onChange={(e) => setName(e.target.value)} placeholder="如:理赔专员" maxLength={191} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">描述</label>
|
||||||
|
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} maxLength={1024} />
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
||||||
|
设为默认岗位(未分配岗位的用户继承此岗位的配置)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={pending || !name.trim()}>
|
||||||
|
{pending && <Loader2 className="size-4 animate-spin" />}保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddMembersDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
positionId,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
positionId: string;
|
||||||
|
}) {
|
||||||
|
const { data: users, isLoading } = usePositionUsers();
|
||||||
|
const addMembers = useAddMembers();
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setSelected(new Set());
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = users ?? [];
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return q ? list.filter((u) => u.email.toLowerCase().includes(q)) : list;
|
||||||
|
}, [users, search]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
try {
|
||||||
|
await addMembers.mutateAsync({ positionId, userIds: [...selected] });
|
||||||
|
toast.success(`已为 ${selected.size} 名用户配置岗位`);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>批量添加成员</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索邮箱…" />
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-md border">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6">
|
||||||
|
<Loader2 className="size-4 animate-spin" />加载用户…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<div className="text-muted-foreground py-6 text-center text-sm">无匹配用户</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((u) => {
|
||||||
|
const checked = selected.has(u.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(u.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 hover:bg-muted/50",
|
||||||
|
checked && "bg-muted/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{u.email}
|
||||||
|
{u.system_role === "admin" && <span className="text-muted-foreground ml-2 text-xs">(管理员)</span>}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{u.position_id && u.position_id !== positionId && (
|
||||||
|
<span className="text-muted-foreground text-xs">已属其他岗位</span>
|
||||||
|
)}
|
||||||
|
<span className={cn("flex size-4 items-center justify-center rounded border", checked ? "bg-foreground text-background border-foreground" : "border-muted-foreground/40")}>
|
||||||
|
{checked && <CheckIcon className="size-3" />}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={addMembers.isPending || selected.size === 0}>
|
||||||
|
{addMembers.isPending && <Loader2 className="size-4 animate-spin" />}添加 {selected.size > 0 ? `(${selected.size})` : ""}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionDetail({ position }: { position: Position }) {
|
||||||
|
const { data: serverBindings } = usePositionTagBindings(position.id);
|
||||||
|
const { data: members } = usePositionMembers(position.id);
|
||||||
|
const setBindings = useSetTagBindings();
|
||||||
|
const removeMembers = useRemoveMembers();
|
||||||
|
const resync = useResyncPosition();
|
||||||
|
const [draft, setDraft] = useState<TagBindings>({});
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
|
// Reload the draft whenever the selected position or its server bindings change.
|
||||||
|
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
||||||
|
const loadKey = `${position.id}:${serverBindings ? "ready" : "loading"}`;
|
||||||
|
if (serverBindings && loadedFor !== loadKey) {
|
||||||
|
setDraft({ ...serverBindings });
|
||||||
|
setLoadedFor(loadKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setType = (rt: PositionResourceType, ids: string[]) =>
|
||||||
|
setDraft((prev) => ({ ...prev, [rt]: ids }));
|
||||||
|
|
||||||
|
const handleSaveBindings = async () => {
|
||||||
|
try {
|
||||||
|
await setBindings.mutateAsync({ positionId: position.id, bindings: draft });
|
||||||
|
toast.success("标签配置已保存,已同步岗位成员");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResync = async () => {
|
||||||
|
try {
|
||||||
|
const n = await resync.mutateAsync(position.id);
|
||||||
|
toast.success(`已重新同步 ${n} 名成员`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await removeMembers.mutateAsync({ positionId: position.id, userIds: [userId] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold">默认资源标签配置</h3>
|
||||||
|
<Button size="sm" onClick={handleSaveBindings} disabled={setBindings.isPending}>
|
||||||
|
{setBindings.isPending && <Loader2 className="size-4 animate-spin" />}保存标签配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y">
|
||||||
|
{POSITION_RESOURCE_TYPES.map((rt) => (
|
||||||
|
<div key={rt} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-medium">{POSITION_RESOURCE_LABELS[rt]}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{POSITION_RESOURCE_HINT[rt]}</span>
|
||||||
|
</div>
|
||||||
|
<TagMultiSelect resourceType={rt} value={draft[rt] ?? []} onChange={(ids) => setType(rt, ids)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="flex items-center gap-1.5 text-sm font-semibold">
|
||||||
|
<UsersIcon className="size-4" />岗位成员 ({members?.length ?? 0})
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={handleResync} disabled={resync.isPending}>
|
||||||
|
{resync.isPending ? <Loader2 className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}重新同步
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
<UserPlusIcon className="size-4" />添加成员
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{members && members.length > 0 ? (
|
||||||
|
<div className="flex flex-col divide-y">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center justify-between py-2 text-sm">
|
||||||
|
<span className="truncate">{m.email}</span>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleRemove(m.id)} aria-label="移除">
|
||||||
|
<Trash2Icon className="text-destructive size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-6 text-center text-sm">暂无成员</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddMembersDialog open={addOpen} onOpenChange={setAddOpen} positionId={position.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PositionManagementPage() {
|
||||||
|
const { data: positions, isLoading, error } = usePositions();
|
||||||
|
const deletePosition = useDeletePosition();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Position | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => positions?.find((p) => p.id === selectedId) ?? null,
|
||||||
|
[positions, selectedId],
|
||||||
|
);
|
||||||
|
// Auto-select the first position once loaded.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId && positions && positions.length > 0) setSelectedId(positions[0].id);
|
||||||
|
}, [positions, selectedId]);
|
||||||
|
|
||||||
|
const handleDelete = async (p: Position) => {
|
||||||
|
if (!window.confirm(`确认删除岗位「${p.name}」?其成员将回退到默认岗位。`)) return;
|
||||||
|
try {
|
||||||
|
await deletePosition.mutateAsync(p.id);
|
||||||
|
if (selectedId === p.id) setSelectedId(null);
|
||||||
|
} 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); setDialogOpen(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 && (
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{positions && positions.length === 0 && (
|
||||||
|
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||||
|
还没有岗位,点击「新增岗位」创建。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{positions?.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setSelectedId(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"group cursor-pointer rounded-lg border p-3 transition-colors",
|
||||||
|
selectedId === p.id ? "border-foreground bg-muted/40" : "hover:bg-muted/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{p.name}</span>
|
||||||
|
{p.is_default && (
|
||||||
|
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-950 dark:text-blue-300">默认</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); setEditing(p); setDialogOpen(true); }} aria-label="编辑">
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); void handleDelete(p); }} aria-label="删除">
|
||||||
|
<Trash2Icon className="text-destructive size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
|
<UsersIcon className="size-3" />{p.member_count} 名成员
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{selected ? (
|
||||||
|
<PositionDetail key={selected.id} position={selected} />
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground rounded-lg border border-dashed py-16 text-center text-sm">
|
||||||
|
选择左侧一个岗位以配置标签与成员
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PositionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} editing={editing} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type Tag,
|
type Tag,
|
||||||
type TagType,
|
type TagType,
|
||||||
} from "@/core/tags";
|
} from "@/core/tags";
|
||||||
|
import { TAG_TYPES, TAG_TYPE_LABELS } from "@/core/tags/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function TagFormDialog({
|
function TagFormDialog({
|
||||||
@@ -191,11 +192,11 @@ export function TagManagementPage() {
|
|||||||
onValueChange={(v) => setActiveType(v as TagType)}
|
onValueChange={(v) => setActiveType(v as TagType)}
|
||||||
>
|
>
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
|
{TAG_TYPES.map((tt) => (
|
||||||
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
|
<TabsTrigger key={tt} value={tt}>
|
||||||
<TabsTrigger value="scheduled_task">
|
{TAG_TYPE_LABELS[tt]}
|
||||||
{t.adminTags.typeScheduledTask}
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button size="sm" onClick={openCreate}>
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
|||||||
@@ -521,25 +521,6 @@ 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">
|
||||||
@@ -1060,6 +1041,35 @@ 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="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"
|
className="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"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -176,13 +176,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||||
const [fillModel, setFillModel] = useState<string | null>(null);
|
const [fillModel, setFillModel] = useState<string | null>(null);
|
||||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
|
||||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
|
||||||
|
|
||||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
const isAdmin = storedAuth?.system_role === "admin";
|
const isAdmin = storedAuth?.system_role === "admin";
|
||||||
|
|
||||||
|
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||||
|
// Non-admins get their 岗位 (position) visibility applied (scope=mine): a
|
||||||
|
// position that binds skill tags restricts which skills they can see.
|
||||||
|
const { skills, isLoading, error } = useSkills({
|
||||||
|
search,
|
||||||
|
tagIds,
|
||||||
|
sort,
|
||||||
|
scope: isAdmin ? undefined : "mine",
|
||||||
|
});
|
||||||
|
|
||||||
// 把可见技能分类成 UI 关心的几个桶。
|
// 把可见技能分类成 UI 关心的几个桶。
|
||||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckIcon, Loader2 } 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 { resolveTagColor } from "@/components/workspace/tags/tag-colors";
|
||||||
|
import { useResourceTags, useSetResourceTags, useTags, type TagType } from "@/core/tags";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Generic admin dialog to assign a resource's tags (multi-select chips). */
|
||||||
|
export function ResourceTagDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
title = "打标签",
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
targetType: TagType;
|
||||||
|
targetId: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const { data: tags } = useTags(targetType);
|
||||||
|
const { data: current } = useResourceTags(targetType, targetId, open);
|
||||||
|
const setTags = useSetResourceTags();
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||||
|
const syncKey = `${open}:${targetId}:${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, targetId, tagIds: selected });
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,11 +15,14 @@ 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`;
|
||||||
|
|
||||||
@@ -117,3 +120,91 @@ 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
|
Briefcase,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Database,
|
Database,
|
||||||
@@ -246,6 +247,20 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/admin/users",
|
path: "/page/strategy/admin/users",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mgmt-positions",
|
||||||
|
label: "岗位管理",
|
||||||
|
icon: Briefcase,
|
||||||
|
path: "/page/strategy/admin/positions",
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mgmt-prompt-templates",
|
||||||
|
label: "提示词模板",
|
||||||
|
icon: MessageSquareCode,
|
||||||
|
path: "/page/strategy/admin/prompt-templates",
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mgmt-user-leaderboard",
|
id: "mgmt-user-leaderboard",
|
||||||
label: "?????",
|
label: "?????",
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Position,
|
||||||
|
PositionCreateInput,
|
||||||
|
PositionListResponse,
|
||||||
|
PositionMember,
|
||||||
|
PositionUpdateInput,
|
||||||
|
PositionUser,
|
||||||
|
TagBindings,
|
||||||
|
TagBindingsResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
async function parseDetail(res: Response, fallback: string): Promise<Error> {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||||
|
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
|
||||||
|
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = () => `${getBackendBaseURL()}/api/positions`;
|
||||||
|
|
||||||
|
export async function listPositions(): Promise<Position[]> {
|
||||||
|
const res = await apiFetch(base());
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to list positions");
|
||||||
|
const data = (await res.json()) as PositionListResponse;
|
||||||
|
return data.positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPosition(input: PositionCreateInput): Promise<Position> {
|
||||||
|
const res = await apiFetch(base(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to create position");
|
||||||
|
return (await res.json()) as Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePosition(id: string, input: PositionUpdateInput): Promise<Position> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to update position");
|
||||||
|
return (await res.json()) as Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePosition(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to delete position");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTagBindings(positionId: string): Promise<TagBindings> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to load tag bindings");
|
||||||
|
const data = (await res.json()) as TagBindingsResponse;
|
||||||
|
return data.bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTagBindings(positionId: string, bindings: TagBindings): Promise<TagBindings> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ bindings }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to save tag bindings");
|
||||||
|
const data = (await res.json()) as TagBindingsResponse;
|
||||||
|
return data.bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMembers(positionId: string): Promise<PositionMember[]> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to list members");
|
||||||
|
const data = (await res.json()) as { members: PositionMember[] };
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ user_ids: userIds }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to add members");
|
||||||
|
const data = (await res.json()) as { members: PositionMember[] };
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ user_ids: userIds }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to remove members");
|
||||||
|
const data = (await res.json()) as { members: PositionMember[] };
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resyncPosition(positionId: string): Promise<number> {
|
||||||
|
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/resync`, { method: "POST" });
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to resync position");
|
||||||
|
const data = (await res.json()) as { synced: number };
|
||||||
|
return data.synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPositionUsers(): Promise<PositionUser[]> {
|
||||||
|
const res = await apiFetch(`${base()}/users`);
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to list users");
|
||||||
|
const data = (await res.json()) as { users: PositionUser[] };
|
||||||
|
return data.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserPosition(userId: string, positionId: string | null): Promise<void> {
|
||||||
|
const res = await apiFetch(`${base()}/users/${encodeURIComponent(userId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ position_id: positionId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await parseDetail(res, "Failed to set user position");
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addMembers,
|
||||||
|
createPosition,
|
||||||
|
deletePosition,
|
||||||
|
getTagBindings,
|
||||||
|
listMembers,
|
||||||
|
listPositionUsers,
|
||||||
|
listPositions,
|
||||||
|
removeMembers,
|
||||||
|
resyncPosition,
|
||||||
|
setTagBindings,
|
||||||
|
setUserPosition,
|
||||||
|
updatePosition,
|
||||||
|
} from "./api";
|
||||||
|
import type {
|
||||||
|
PositionCreateInput,
|
||||||
|
PositionUpdateInput,
|
||||||
|
TagBindings,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const positionKeys = {
|
||||||
|
root: ["positions"] as const,
|
||||||
|
list: ["positions", "list"] as const,
|
||||||
|
bindings: (id: string) => ["positions", "bindings", id] as const,
|
||||||
|
members: (id: string) => ["positions", "members", id] as const,
|
||||||
|
users: ["positions", "users"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePositions() {
|
||||||
|
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePositionUsers() {
|
||||||
|
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePositionTagBindings(positionId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: positionKeys.bindings(positionId ?? ""),
|
||||||
|
queryFn: () => getTagBindings(positionId as string),
|
||||||
|
enabled: !!positionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePositionMembers(positionId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: positionKeys.members(positionId ?? ""),
|
||||||
|
queryFn: () => listMembers(positionId as string),
|
||||||
|
enabled: !!positionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () => void qc.invalidateQueries({ queryKey: positionKeys.root });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePosition() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: PositionCreateInput) => createPosition(input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePosition() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: string; input: PositionUpdateInput }) => updatePosition(id, input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePosition() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => deletePosition(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetTagBindings() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ positionId, bindings }: { positionId: string; bindings: TagBindings }) =>
|
||||||
|
setTagBindings(positionId, bindings),
|
||||||
|
onSuccess: (_data, { positionId }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: positionKeys.bindings(positionId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddMembers() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
|
||||||
|
addMembers(positionId, userIds),
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveMembers() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
|
||||||
|
removeMembers(positionId, userIds),
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResyncPosition() {
|
||||||
|
return useMutation({ mutationFn: (positionId: string) => resyncPosition(positionId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetUserPosition() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, positionId }: { userId: string; positionId: string | null }) =>
|
||||||
|
setUserPosition(userId, positionId),
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./api";
|
||||||
|
export * from "./hooks";
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { TagType } from "@/core/tags/types";
|
||||||
|
|
||||||
|
/** Resource types a 岗位 (position) can grant, by tag. */
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
|
||||||
|
// A position's resource types are exactly the tag types — reuse the labels.
|
||||||
|
export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
|
||||||
|
agent: "智能体",
|
||||||
|
skill: "技能",
|
||||||
|
scheduled_task: "定时任务",
|
||||||
|
user_prompt: "常用提示词",
|
||||||
|
recommended_question: "推荐问题",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
|
||||||
|
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
||||||
|
skill: "不配置标签 = 可见全部技能;配置后只可见所选标签下的技能。",
|
||||||
|
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
||||||
|
user_prompt: "不配置标签 = 加载未打标签的全局模板;配置后加载所选标签下的提示词模板。",
|
||||||
|
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
is_default: boolean;
|
||||||
|
member_count: number;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionListResponse {
|
||||||
|
positions: Position[];
|
||||||
|
resource_types: PositionResourceType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionCreateInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionUpdateInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resource_type -> [tag_id] */
|
||||||
|
export type TagBindings = Partial<Record<PositionResourceType, string[]>>;
|
||||||
|
|
||||||
|
export interface TagBindingsResponse {
|
||||||
|
position_id: string;
|
||||||
|
bindings: TagBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionMember {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
system_role: string;
|
||||||
|
position_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resource types double as tag types for the binding pickers.
|
||||||
|
export function resourceTypeToTagType(rt: PositionResourceType): TagType {
|
||||||
|
return rt as TagType;
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import type {
|
|||||||
|
|
||||||
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
|
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
|
||||||
|
|
||||||
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> {
|
export async function listRecommendedQuestions(all = false): Promise<RecommendedQuestion[]> {
|
||||||
const res = await apiFetch(BASE());
|
// ``all=true`` is the admin management view: every question, unfiltered by the
|
||||||
|
// caller's 岗位 (position). The chat input uses the default (filtered) view.
|
||||||
|
const res = await apiFetch(`${BASE()}${all ? "?all=1" : ""}`);
|
||||||
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
|
||||||
const data = (await res.json()) as { questions: RecommendedQuestion[] };
|
const data = (await res.json()) as { questions: RecommendedQuestion[] };
|
||||||
return data.questions;
|
return data.questions;
|
||||||
|
|||||||
@@ -10,11 +10,21 @@ import {
|
|||||||
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
|
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
|
||||||
|
|
||||||
const QK = ["recommended-questions"] as const;
|
const QK = ["recommended-questions"] as const;
|
||||||
|
const ADMIN_QK = ["recommended-questions", "admin"] as const;
|
||||||
|
|
||||||
export function useRecommendedQuestions() {
|
export function useRecommendedQuestions() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: QK,
|
queryKey: QK,
|
||||||
queryFn: listRecommendedQuestions,
|
queryFn: () => listRecommendedQuestions(false),
|
||||||
|
});
|
||||||
|
return { questions: data ?? [], isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin management view — every question, unfiltered by 岗位. */
|
||||||
|
export function useAdminRecommendedQuestions() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ADMIN_QK,
|
||||||
|
queryFn: () => listRecommendedQuestions(true),
|
||||||
});
|
});
|
||||||
return { questions: data ?? [], isLoading, error };
|
return { questions: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface ListSkillsParams {
|
|||||||
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||||
/** Keep only skills filed under this publish square (发布广场). */
|
/** Keep only skills filed under this publish square (发布广场). */
|
||||||
square?: string;
|
square?: string;
|
||||||
|
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
|
||||||
|
scope?: "mine";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||||
@@ -17,6 +19,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
|||||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||||
if (params.sort) qs.set("sort", params.sort);
|
if (params.sort) qs.set("sort", params.sort);
|
||||||
if (params.square) qs.set("square", params.square);
|
if (params.square) qs.set("square", params.square);
|
||||||
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
const query = qs.toString();
|
const query = qs.toString();
|
||||||
const skills = await apiFetch(
|
const skills = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export function useSkills(params: ListSkillsParams = {}) {
|
|||||||
const search = params.search ?? "";
|
const search = params.search ?? "";
|
||||||
const tagIds = params.tagIds ?? [];
|
const tagIds = params.tagIds ?? [];
|
||||||
const sort = params.sort ?? "time_desc";
|
const sort = params.sort ?? "time_desc";
|
||||||
|
const scope = params.scope;
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["skills", search, [...tagIds].sort(), sort],
|
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
|
||||||
queryFn: () => loadSkills({ search, tagIds, sort }),
|
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
|
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
export type TagType = "agent" | "skill" | "scheduled_task";
|
export type TagType =
|
||||||
|
| "agent"
|
||||||
|
| "skill"
|
||||||
|
| "scheduled_task"
|
||||||
|
| "user_prompt"
|
||||||
|
| "recommended_question";
|
||||||
|
|
||||||
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"];
|
export const TAG_TYPES: TagType[] = [
|
||||||
|
"agent",
|
||||||
|
"skill",
|
||||||
|
"scheduled_task",
|
||||||
|
"user_prompt",
|
||||||
|
"recommended_question",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Human labels for the tag types (used by the tag management + 岗位 pages).
|
||||||
|
export const TAG_TYPE_LABELS: Record<TagType, string> = {
|
||||||
|
agent: "智能体",
|
||||||
|
skill: "技能",
|
||||||
|
scheduled_task: "定时任务",
|
||||||
|
user_prompt: "常用提示词",
|
||||||
|
recommended_question: "推荐问题",
|
||||||
|
};
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -343,6 +343,23 @@ export function useThreadStream({
|
|||||||
) {
|
) {
|
||||||
const e = event as { type: "llm_retry"; message: string };
|
const e = event as { type: "llm_retry"; message: string };
|
||||||
toast(e.message);
|
toast(e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context compression about to run a (blocking) summarization LLM call —
|
||||||
|
// surface a transient hint so the wait before the answer streams isn't a
|
||||||
|
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
|
||||||
|
if (
|
||||||
|
typeof event === "object" &&
|
||||||
|
event !== null &&
|
||||||
|
"type" in event &&
|
||||||
|
event.type === "context_compacting" &&
|
||||||
|
"message" in event &&
|
||||||
|
typeof event.message === "string" &&
|
||||||
|
event.message.trim()
|
||||||
|
) {
|
||||||
|
const e = event as { type: "context_compacting"; message: string };
|
||||||
|
toast(e.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PositionManagementPage } from "@/components/workspace/admin/position-management-page";
|
||||||
|
|
||||||
|
export default function PositionsPage() {
|
||||||
|
return <PositionManagementPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PromptTemplatesPage as PromptTemplatesManagement } from "@/components/workspace/admin/prompt-templates-page";
|
||||||
|
|
||||||
|
export default function PromptTemplatesPage() {
|
||||||
|
return <PromptTemplatesManagement />;
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
TagIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ResourceTagDialog } from "@/components/workspace/tags/resource-tag-dialog";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -24,9 +26,9 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
useAdminRecommendedQuestions,
|
||||||
useCreateRecommendedQuestion,
|
useCreateRecommendedQuestion,
|
||||||
useDeleteRecommendedQuestion,
|
useDeleteRecommendedQuestion,
|
||||||
useRecommendedQuestions,
|
|
||||||
useReorderRecommendedQuestions,
|
useReorderRecommendedQuestions,
|
||||||
useUpdateRecommendedQuestion,
|
useUpdateRecommendedQuestion,
|
||||||
} from "@/core/recommended-questions/hooks";
|
} from "@/core/recommended-questions/hooks";
|
||||||
@@ -65,7 +67,7 @@ export default function RecommendedQuestionsPage({
|
|||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { questions, isLoading, error } = useRecommendedQuestions();
|
const { questions, isLoading, error } = useAdminRecommendedQuestions();
|
||||||
const createMutation = useCreateRecommendedQuestion();
|
const createMutation = useCreateRecommendedQuestion();
|
||||||
const updateMutation = useUpdateRecommendedQuestion();
|
const updateMutation = useUpdateRecommendedQuestion();
|
||||||
const deleteMutation = useDeleteRecommendedQuestion();
|
const deleteMutation = useDeleteRecommendedQuestion();
|
||||||
@@ -75,6 +77,7 @@ export default function RecommendedQuestionsPage({
|
|||||||
const [formTitle, setFormTitle] = useState("");
|
const [formTitle, setFormTitle] = useState("");
|
||||||
const [formContent, setFormContent] = useState("");
|
const [formContent, setFormContent] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
|
||||||
|
const [tagTarget, setTagTarget] = useState<RecommendedQuestion | null>(null);
|
||||||
|
|
||||||
// drag state
|
// drag state
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
@@ -245,6 +248,16 @@ export default function RecommendedQuestionsPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => setTagTarget(q)}
|
||||||
|
disabled={isSaving}
|
||||||
|
title="打标签(按岗位分发)"
|
||||||
|
>
|
||||||
|
<TagIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -308,6 +321,17 @@ export default function RecommendedQuestionsPage({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 打标签弹窗(按岗位分发推荐问题) */}
|
||||||
|
{tagTarget && (
|
||||||
|
<ResourceTagDialog
|
||||||
|
open={tagTarget !== null}
|
||||||
|
onOpenChange={(o) => !o && setTagTarget(null)}
|
||||||
|
targetType="recommended_question"
|
||||||
|
targetId={tagTarget.id}
|
||||||
|
title={`为「${tagTarget.title}」打标签`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 删除确认弹窗 */}
|
{/* 删除确认弹窗 */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
|||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
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 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";
|
||||||
@@ -287,6 +289,26 @@ export default function StrategyRoutes() {
|
|||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={<TagsPage />}
|
element={<TagsPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/positions"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<PositionsPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/prompt-templates"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<PromptTemplatesPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import AdminUserActivityPage from './AdminUserActivityPage';
|
|||||||
import AdminMemoryPage from './AdminMemoryPage';
|
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 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';
|
||||||
@@ -471,6 +473,30 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/positions"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === 'admin' ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<PositionsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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={
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
|||||||
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
||||||
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
||||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
||||||
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
|
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
|
||||||
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||||
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
||||||
@@ -175,7 +175,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
|||||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||||
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation ? timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block ? there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call ? a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read?merge?write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
|
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation ? timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block ? there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call ? a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read?merge?write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
|
||||||
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
|
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
|
||||||
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
|
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
|
||||||
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||||
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
||||||
@@ -328,6 +328,61 @@ Admin-configurable, **access-control-free** categories that published agents/ski
|
|||||||
|
|
||||||
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
|
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
|
||||||
|
|
||||||
|
### 岗位 (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.
|
||||||
|
|
||||||
|
**Persistence** (`deerflow/persistence/positions/`, wired as
|
||||||
|
`app.state.position_store`; tables auto-created by `create_all`, migration
|
||||||
|
`20260616_01`):
|
||||||
|
- `positions` (`PositionRow`): `id`, `name` (unique), `description`,
|
||||||
|
`is_default` (only one at a time — the store clears the flag on others).
|
||||||
|
- `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`).
|
||||||
|
|
||||||
|
**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:
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**Triggers**: assigning/removing members, changing tag bindings (re-syncs all
|
||||||
|
members), becoming the default position (`sync_default_members`), and login
|
||||||
|
(`auth.py::_sync_user_position`, best-effort so a brand-new user inheriting the
|
||||||
|
default gets their grants materialized).
|
||||||
|
|
||||||
|
**API** (`app/gateway/routers/positions.py`, `/api/positions`, **admin only**):
|
||||||
|
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);
|
||||||
|
`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:
|
||||||
|
`tests/test_positions.py`.
|
||||||
|
|
||||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||||
|
|
||||||
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ from app.gateway.routers import (
|
|||||||
notifications,
|
notifications,
|
||||||
parallel_agent_tasks,
|
parallel_agent_tasks,
|
||||||
parallel_agents,
|
parallel_agents,
|
||||||
|
positions,
|
||||||
public_embed,
|
public_embed,
|
||||||
public_leaderboard,
|
public_leaderboard,
|
||||||
public_scheduled_tasks,
|
public_scheduled_tasks,
|
||||||
@@ -1397,6 +1398,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Tags API is mounted at /api/tags
|
# Tags API is mounted at /api/tags
|
||||||
app.include_router(tags.router)
|
app.include_router(tags.router)
|
||||||
|
|
||||||
|
# 岗位 (position) management API is mounted at /api/positions
|
||||||
|
app.include_router(positions.router)
|
||||||
|
|
||||||
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||||
app.include_router(thread_runs.router)
|
app.include_router(thread_runs.router)
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.tag_store = make_tag_store(sf)
|
app.state.tag_store = make_tag_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.positions import make_position_store
|
||||||
|
|
||||||
|
app.state.position_store = make_position_store(sf)
|
||||||
|
|
||||||
|
from deerflow.services.position_sync import PositionSyncService
|
||||||
|
|
||||||
|
app.state.position_sync = PositionSyncService(sf, app.state.position_store, app.state.tag_store)
|
||||||
|
|
||||||
from deerflow.persistence.thread_shares import make_thread_share_store
|
from deerflow.persistence.thread_shares import make_thread_share_store
|
||||||
|
|
||||||
app.state.thread_share_store = make_thread_share_store(sf)
|
app.state.thread_share_store = make_thread_share_store(sf)
|
||||||
@@ -371,6 +379,22 @@ def get_tag_store(request: Request) -> TagStore:
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_position_store(request: Request):
|
||||||
|
"""Return the 岗位 (position) store."""
|
||||||
|
val = getattr(request.app.state, "position_store", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Position store not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_position_sync(request: Request):
|
||||||
|
"""Return the position → subscription sync service."""
|
||||||
|
val = getattr(request.app.state, "position_sync", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Position sync service not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def get_thread_share_store(request: Request):
|
def get_thread_share_store(request: Request):
|
||||||
"""Return the conversation share-link store."""
|
"""Return the conversation share-link store."""
|
||||||
val = getattr(request.app.state, "thread_share_store", None)
|
val = getattr(request.app.state, "thread_share_store", None)
|
||||||
|
|||||||
@@ -202,6 +202,21 @@ def _username_to_email(username: str) -> str:
|
|||||||
return f"{local}@{domain}"
|
return f"{local}@{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_user_position(request: Request, user_id: str) -> None:
|
||||||
|
"""Best-effort: materialize the user's 岗位 (position) grants on login.
|
||||||
|
|
||||||
|
Ensures a user who has never been synced — e.g. a brand-new account that
|
||||||
|
inherits the ``is_default`` position — gets the default agents / tasks /
|
||||||
|
prompts loaded into their "我的" tables. Failures never block login.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sync = getattr(request.app.state, "position_sync", None)
|
||||||
|
if sync is not None:
|
||||||
|
await sync.sync_user(str(user_id))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("position sync on login failed for user %s", user_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_user_by_username(username: str):
|
async def _get_or_create_user_by_username(username: str):
|
||||||
"""Resolve a username to a local user, auto-registering when absent.
|
"""Resolve a username to a local user, auto-registering when absent.
|
||||||
|
|
||||||
@@ -476,6 +491,7 @@ async def login_local(
|
|||||||
_record_login_success(client_ip)
|
_record_login_success(client_ip)
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||||
_set_session_cookie(response, token, request)
|
_set_session_cookie(response, token, request)
|
||||||
|
await _sync_user_position(request, user.id)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
|
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
|
||||||
@@ -520,6 +536,7 @@ async def login_by_username(
|
|||||||
|
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||||
_set_session_cookie(response, token, request)
|
_set_session_cookie(response, token, request)
|
||||||
|
await _sync_user_position(request, user.id)
|
||||||
|
|
||||||
return UsernameLoginResponse(
|
return UsernameLoginResponse(
|
||||||
access_token=token,
|
access_token=token,
|
||||||
@@ -570,6 +587,7 @@ async def login_by_token(
|
|||||||
|
|
||||||
access_token = create_access_token(str(user.id), token_version=user.token_version)
|
access_token = create_access_token(str(user.id), token_version=user.token_version)
|
||||||
_set_session_cookie(response, access_token, request)
|
_set_session_cookie(response, access_token, request)
|
||||||
|
await _sync_user_position(request, user.id)
|
||||||
|
|
||||||
return UsernameLoginResponse(
|
return UsernameLoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
@@ -600,6 +618,7 @@ async def register(request: Request, response: Response, body: RegisterRequest):
|
|||||||
|
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||||
_set_session_cookie(response, token, request)
|
_set_session_cookie(response, token, request)
|
||||||
|
await _sync_user_position(request, user.id)
|
||||||
|
|
||||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""岗位 (position) management API — admin only.
|
||||||
|
|
||||||
|
A position aggregates, per resource type, the tags whose resources are granted
|
||||||
|
to its members. Assigning a user to a position (1:1) materializes those grants
|
||||||
|
into the user's "我的" tables via the sync engine; recommended-question and
|
||||||
|
skill visibility are filtered live elsewhere.
|
||||||
|
|
||||||
|
All endpoints require ``system_role == "admin"`` (auth-disabled mode lets calls
|
||||||
|
through, matching the other admin routers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_optional_user_from_request, get_position_store, get_position_sync, get_tag_store
|
||||||
|
from deerflow.persistence.positions import POSITION_RESOURCE_TYPES, PositionStore
|
||||||
|
from deerflow.persistence.tags import TagStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/positions", tags=["positions"])
|
||||||
|
|
||||||
|
|
||||||
|
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="岗位管理仅限管理员")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bindings(bindings: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for resource_type, tag_ids in bindings.items():
|
||||||
|
if resource_type not in POSITION_RESOURCE_TYPES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Invalid resource type '{resource_type}'. Allowed: {', '.join(POSITION_RESOURCE_TYPES)}")
|
||||||
|
out[resource_type] = list(tag_ids or [])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ schemas
|
||||||
|
|
||||||
|
|
||||||
|
class PositionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
is_default: bool = False
|
||||||
|
member_count: int = 0
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PositionListResponse(BaseModel):
|
||||||
|
positions: list[PositionResponse]
|
||||||
|
resource_types: list[str] = Field(default_factory=lambda: list(POSITION_RESOURCE_TYPES))
|
||||||
|
|
||||||
|
|
||||||
|
class PositionCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=191)
|
||||||
|
description: str = Field(default="", max_length=1024)
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PositionUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=191)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
is_default: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TagBindingsResponse(BaseModel):
|
||||||
|
position_id: str
|
||||||
|
bindings: dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class TagBindingsRequest(BaseModel):
|
||||||
|
bindings: dict[str, list[str]] = Field(default_factory=dict, description="resource_type -> [tag_id]; omitted/empty type = default set")
|
||||||
|
|
||||||
|
|
||||||
|
class MemberResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class MembersResponse(BaseModel):
|
||||||
|
members: list[MemberResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class MembersMutationRequest(BaseModel):
|
||||||
|
user_ids: list[str] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPositionRequest(BaseModel):
|
||||||
|
position_id: str | None = Field(default=None, description="null clears the user's position")
|
||||||
|
|
||||||
|
|
||||||
|
class PositionUserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str = ""
|
||||||
|
system_role: str = "user"
|
||||||
|
position_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PositionUsersResponse(BaseModel):
|
||||||
|
users: list[PositionUserResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- position CRUD
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=PositionListResponse, summary="List Positions")
|
||||||
|
async def list_positions(request: Request, store: PositionStore = Depends(get_position_store)) -> PositionListResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
records = await store.list_positions()
|
||||||
|
return PositionListResponse(positions=[PositionResponse(**r) for r in records])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=PositionUsersResponse, summary="List All Users + Their Position")
|
||||||
|
async def list_position_users(request: Request, store: PositionStore = Depends(get_position_store)) -> PositionUsersResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
users = await store.list_all_users()
|
||||||
|
return PositionUsersResponse(users=[PositionUserResponse(**u) for u in users])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PositionResponse, status_code=201, summary="Create Position")
|
||||||
|
async def create_position(request: Request, body: PositionCreateRequest, store: PositionStore = Depends(get_position_store)) -> PositionResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
try:
|
||||||
|
record = await store.create_position({"id": uuid.uuid4().hex, "name": body.name.strip(), "description": body.description, "is_default": body.is_default})
|
||||||
|
return PositionResponse(**record)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{position_id}", response_model=PositionResponse, summary="Update Position")
|
||||||
|
async def update_position(
|
||||||
|
request: Request,
|
||||||
|
position_id: str,
|
||||||
|
body: PositionUpdateRequest,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> PositionResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
if update_data.get("name"):
|
||||||
|
update_data["name"] = update_data["name"].strip()
|
||||||
|
try:
|
||||||
|
record = await store.update_position(position_id, update_data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
# Becoming the default changes what unassigned users inherit.
|
||||||
|
if update_data.get("is_default"):
|
||||||
|
await sync.sync_default_members()
|
||||||
|
return PositionResponse(**record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{position_id}", status_code=204, summary="Delete Position")
|
||||||
|
async def delete_position(
|
||||||
|
request: Request,
|
||||||
|
position_id: str,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> None:
|
||||||
|
await _require_admin(request)
|
||||||
|
member_ids = await store.list_member_ids(position_id)
|
||||||
|
deleted = await store.delete_position(position_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
# Former members now fall back to the default position; re-sync them.
|
||||||
|
for uid in member_ids:
|
||||||
|
await sync.sync_user(uid)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- tag bindings
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{position_id}/tag-bindings", response_model=TagBindingsResponse, summary="Get Tag Bindings")
|
||||||
|
async def get_tag_bindings(request: Request, position_id: str, store: PositionStore = Depends(get_position_store)) -> TagBindingsResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
if await store.get_position(position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
bindings = await store.get_tag_bindings(position_id)
|
||||||
|
return TagBindingsResponse(position_id=position_id, bindings=bindings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{position_id}/tag-bindings", response_model=TagBindingsResponse, summary="Set Tag Bindings")
|
||||||
|
async def set_tag_bindings(
|
||||||
|
request: Request,
|
||||||
|
position_id: str,
|
||||||
|
body: TagBindingsRequest,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
tag_store: TagStore = Depends(get_tag_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> TagBindingsResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
if await store.get_position(position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
cleaned = _validate_bindings(body.bindings)
|
||||||
|
# Validate every tag id exists and matches its resource type.
|
||||||
|
for resource_type, tag_ids in cleaned.items():
|
||||||
|
if not tag_ids:
|
||||||
|
continue
|
||||||
|
known = {t["id"] for t in await tag_store.list_tags(resource_type)}
|
||||||
|
for tid in tag_ids:
|
||||||
|
if tid not in known:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Tag '{tid}' is not a '{resource_type}' tag")
|
||||||
|
bindings = await store.set_tag_bindings(position_id, cleaned)
|
||||||
|
await sync.sync_position(position_id)
|
||||||
|
return TagBindingsResponse(position_id=position_id, bindings=bindings)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- members
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{position_id}/members", response_model=MembersResponse, summary="List Position Members")
|
||||||
|
async def list_members(request: Request, position_id: str, store: PositionStore = Depends(get_position_store)) -> MembersResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
if await store.get_position(position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
members = await store.list_members(position_id)
|
||||||
|
return MembersResponse(members=[MemberResponse(**m) for m in members])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{position_id}/members", response_model=MembersResponse, summary="Add Position Members")
|
||||||
|
async def add_members(
|
||||||
|
request: Request,
|
||||||
|
position_id: str,
|
||||||
|
body: MembersMutationRequest,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> MembersResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
if await store.get_position(position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
# Re-sync users leaving their previous position too, so its grants are dropped.
|
||||||
|
prev_positions = {uid: await store.get_user_position_id(uid) for uid in body.user_ids}
|
||||||
|
await store.assign_users(position_id, body.user_ids)
|
||||||
|
for uid in body.user_ids:
|
||||||
|
await sync.sync_user(uid)
|
||||||
|
prev = prev_positions.get(uid)
|
||||||
|
if prev and prev != position_id:
|
||||||
|
logger.debug("user %s moved from position %s to %s", uid, prev, position_id)
|
||||||
|
members = await store.list_members(position_id)
|
||||||
|
return MembersResponse(members=[MemberResponse(**m) for m in members])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{position_id}/members", response_model=MembersResponse, summary="Remove Position Members")
|
||||||
|
async def remove_members(
|
||||||
|
request: Request,
|
||||||
|
position_id: str,
|
||||||
|
body: MembersMutationRequest,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> MembersResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
if await store.get_position(position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
# Only unassign users that are actually in this position.
|
||||||
|
member_ids = set(await store.list_member_ids(position_id))
|
||||||
|
victims = [uid for uid in body.user_ids if uid in member_ids]
|
||||||
|
await store.unassign_users(victims)
|
||||||
|
for uid in victims:
|
||||||
|
await sync.sync_user(uid)
|
||||||
|
members = await store.list_members(position_id)
|
||||||
|
return MembersResponse(members=[MemberResponse(**m) for m in members])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{position_id}/resync", summary="Re-sync Position Members")
|
||||||
|
async def resync_position(
|
||||||
|
request: Request,
|
||||||
|
position_id: str,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> dict:
|
||||||
|
await _require_admin(request)
|
||||||
|
if await store.get_position(position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
count = await sync.sync_position(position_id)
|
||||||
|
return {"synced": count}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- per-user position (1:1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}", response_model=PositionResponse | None, summary="Set User Position")
|
||||||
|
async def set_user_position(
|
||||||
|
request: Request,
|
||||||
|
user_id: str,
|
||||||
|
body: UserPositionRequest,
|
||||||
|
store: PositionStore = Depends(get_position_store),
|
||||||
|
sync=Depends(get_position_sync),
|
||||||
|
) -> PositionResponse | None:
|
||||||
|
await _require_admin(request)
|
||||||
|
if body.position_id:
|
||||||
|
if await store.get_position(body.position_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position not found")
|
||||||
|
await store.assign_users(body.position_id, [user_id])
|
||||||
|
else:
|
||||||
|
await store.unassign_users([user_id])
|
||||||
|
await sync.sync_user(user_id)
|
||||||
|
if not body.position_id:
|
||||||
|
return None
|
||||||
|
record = await store.get_position(body.position_id)
|
||||||
|
return PositionResponse(**record) if record else None
|
||||||
@@ -6,13 +6,22 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["recommended-questions"])
|
router = APIRouter(prefix="/api", tags=["recommended-questions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_id(request: Request) -> str:
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
return str(user.id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
class RecommendedQuestionResponse(BaseModel):
|
class RecommendedQuestionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
@@ -50,9 +59,25 @@ def _get_store(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/recommended-questions", response_model=RecommendedQuestionsListResponse)
|
@router.get("/recommended-questions", response_model=RecommendedQuestionsListResponse)
|
||||||
async def list_recommended_questions(request: Request) -> RecommendedQuestionsListResponse:
|
async def list_recommended_questions(
|
||||||
|
request: Request,
|
||||||
|
all: bool = Query(default=False, description="Admin management view: return every question, unfiltered by 岗位."),
|
||||||
|
) -> RecommendedQuestionsListResponse:
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
rows = await store.list_all()
|
rows = await store.list_all()
|
||||||
|
if not all:
|
||||||
|
# Filter by the caller's 岗位 (position) visibility. ``None`` =
|
||||||
|
# unrestricted (no skill/question tag binding → show all default
|
||||||
|
# questions); a set = show only the questions carrying the bound tags.
|
||||||
|
sync = getattr(request.app.state, "position_sync", None)
|
||||||
|
if sync is not None:
|
||||||
|
try:
|
||||||
|
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("recommended-question visibility resolve failed", exc_info=True)
|
||||||
|
visible = None
|
||||||
|
if visible is not None:
|
||||||
|
rows = [r for r in rows if r["id"] in visible]
|
||||||
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
|
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -777,6 +777,7 @@ async def list_skills(
|
|||||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
|
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
|
||||||
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
||||||
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
|
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
|
||||||
|
scope: str | None = Query(default=None, description="``mine`` filters to the skills the caller's 岗位 (position) makes visible."),
|
||||||
sort: str = Query(
|
sort: str = Query(
|
||||||
default="time_desc",
|
default="time_desc",
|
||||||
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
|
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
|
||||||
@@ -846,6 +847,19 @@ async def list_skills(
|
|||||||
for r in responses
|
for r in responses
|
||||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||||
]
|
]
|
||||||
|
# 岗位 (position) visibility: when the caller asks for "我的技能",
|
||||||
|
# restrict to the skills their position grants. ``None`` from the sync
|
||||||
|
# service = unrestricted (no skill tag binding → all skills visible).
|
||||||
|
if scope == "mine" and not admin:
|
||||||
|
sync = getattr(request.app.state, "position_sync", None)
|
||||||
|
if sync is not None:
|
||||||
|
try:
|
||||||
|
visible = await sync.resolve_visible(user_id, "skill")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("skill visibility resolve failed", exc_info=True)
|
||||||
|
visible = None
|
||||||
|
if visible is not None:
|
||||||
|
responses = [r for r in responses if r.name in visible]
|
||||||
responses = _sort_skill_responses(responses, sort)
|
responses = _sort_skill_responses(responses, sort)
|
||||||
return SkillsListResponse(skills=responses)
|
return SkillsListResponse(skills=responses)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -13,12 +13,21 @@ 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 = ""
|
||||||
@@ -26,10 +35,40 @@ 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]
|
||||||
|
|
||||||
@@ -118,3 +157,72 @@ 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")
|
||||||
@@ -22,6 +22,8 @@ LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists
|
|||||||
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
|
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
|
||||||
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
|
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
|
||||||
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
|
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
|
||||||
|
- **Threshold-first**: while the full current conversation is still under the trigger, the request is passed through untouched — no cache lookup, no summary substitution. Compression only ever engages once the real context crosses the configured trigger.
|
||||||
|
- When the genuine (blocking) summarize path actually runs an LLM call, the middleware emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel so the chat UI can show a transient "正在压缩" toast (the cheap cache-reuse path stays silent). Frontend handler: `onCustomEvent` in `frontend-web/src/core/threads/hooks.ts`.
|
||||||
|
|
||||||
> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered.
|
> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered.
|
||||||
|
|
||||||
@@ -326,7 +328,7 @@ Summarization runs after ThreadData and Sandbox initialization but before Title
|
|||||||
|
|
||||||
- Configuration is loaded once at startup
|
- Configuration is loaded once at startup
|
||||||
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
|
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
|
||||||
- The per-thread summary cache lives in the middleware instance (in-memory, bounded). It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
|
- The per-thread summary cache lives in a **process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded), **not** on the middleware instance. `make_lead_agent` rebuilds a fresh middleware every run, so an instance cache would be cold each turn and re-run a blocking summary LLM call on every turn of a long conversation; the module-level cache keyed by `thread_id` lets a summary survive across turns. It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
|
||||||
|
|
||||||
## Example Configurations
|
## Example Configurations
|
||||||
|
|
||||||
|
|||||||
+79
-12
@@ -18,6 +18,7 @@ post-compaction behavior).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from collections.abc import Awaitable, Callable, Collection
|
from collections.abc import Awaitable, Callable, Collection
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -131,6 +132,27 @@ class _SummaryCacheEntry:
|
|||||||
reusable: bool
|
reusable: bool
|
||||||
|
|
||||||
|
|
||||||
|
# Process-wide summary cache shared across every middleware instance.
|
||||||
|
#
|
||||||
|
# ``make_lead_agent`` rebuilds the agent — and therefore a *fresh*
|
||||||
|
# ``DeerFlowSummarizationMiddleware`` — on every run. If the cache lived on the
|
||||||
|
# instance (``self._summary_cache``) it would be empty at the start of each
|
||||||
|
# user turn, forcing a brand-new (blocking) summarization LLM call on *every*
|
||||||
|
# turn of a long conversation. Hoisting it to module scope, keyed by
|
||||||
|
# ``thread_id``, lets a summary computed on one turn be reused on the next,
|
||||||
|
# so only the first threshold crossing (or a genuine re-compaction) pays for an
|
||||||
|
# LLM call. A lock guards the non-atomic LRU eviction against concurrent
|
||||||
|
# threads (subagents / parallel conversations share this process).
|
||||||
|
_GLOBAL_SUMMARY_CACHE: "OrderedDict[str, _SummaryCacheEntry]" = OrderedDict()
|
||||||
|
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_global_summary_cache() -> None:
|
||||||
|
"""Drop every cached summary. Intended for tests/diagnostics only."""
|
||||||
|
with _GLOBAL_SUMMARY_CACHE_LOCK:
|
||||||
|
_GLOBAL_SUMMARY_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||||
"""Summarization middleware with pre-compression hook dispatch and skill rescue."""
|
"""Summarization middleware with pre-compression hook dispatch and skill rescue."""
|
||||||
|
|
||||||
@@ -153,8 +175,9 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens)
|
self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens)
|
||||||
self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill)
|
self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill)
|
||||||
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
|
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
|
||||||
# Lets us reuse a summary across model calls instead of re-running the LLM every invocation.
|
# Points at the *process-wide* cache (not a fresh per-instance dict) so a
|
||||||
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
|
# summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``.
|
||||||
|
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
@@ -208,6 +231,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
return payload # type: ignore[return-value]
|
return payload # type: ignore[return-value]
|
||||||
# action == "summarize"
|
# action == "summarize"
|
||||||
messages_to_summarize, preserved, thread_id = payload # type: ignore[misc]
|
messages_to_summarize, preserved, thread_id = payload # type: ignore[misc]
|
||||||
|
self._emit_compacting_notice()
|
||||||
self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None))
|
self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None))
|
||||||
summary = self._create_summary(messages_to_summarize)
|
summary = self._create_summary(messages_to_summarize)
|
||||||
return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id)
|
return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id)
|
||||||
@@ -221,10 +245,34 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
if action == "reuse":
|
if action == "reuse":
|
||||||
return payload # type: ignore[return-value]
|
return payload # type: ignore[return-value]
|
||||||
messages_to_summarize, preserved, thread_id = payload # type: ignore[misc]
|
messages_to_summarize, preserved, thread_id = payload # type: ignore[misc]
|
||||||
|
self._emit_compacting_notice()
|
||||||
self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None))
|
self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None))
|
||||||
summary = await self._acreate_summary(messages_to_summarize)
|
summary = await self._acreate_summary(messages_to_summarize)
|
||||||
return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id)
|
return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _emit_compacting_notice() -> None:
|
||||||
|
"""Best-effort UI hint that a (blocking) summarization LLM call is starting.
|
||||||
|
|
||||||
|
Only the genuine summarize path calls this — the cheap cache-reuse path
|
||||||
|
does no LLM work and stays silent. Emitted on LangGraph's ``custom``
|
||||||
|
stream channel; the chat frontend renders it as a transient toast. A
|
||||||
|
no-op when there is no active stream writer (e.g. unit tests, non-stream
|
||||||
|
runs), so callers never branch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from langgraph.config import get_stream_writer
|
||||||
|
|
||||||
|
writer = get_stream_writer()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if writer is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
writer({"type": "context_compacting", "message": "正在压缩上下文以容纳更长对话…"})
|
||||||
|
except Exception:
|
||||||
|
logger.debug("summarization: failed to emit context_compacting notice", exc_info=True)
|
||||||
|
|
||||||
def _plan_compression(
|
def _plan_compression(
|
||||||
self,
|
self,
|
||||||
request: ModelRequest,
|
request: ModelRequest,
|
||||||
@@ -243,23 +291,30 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
self._ensure_message_ids(messages)
|
self._ensure_message_ids(messages)
|
||||||
thread_id = self._resolve_request_thread_id(request)
|
|
||||||
|
|
||||||
cached = self._summary_cache.get(thread_id) if thread_id else None
|
# Threshold is the first gate: while the *full* current conversation is
|
||||||
|
# still under the configured trigger, there is nothing to compress —
|
||||||
|
# pass it through untouched. No cache lookup, no summary substitution.
|
||||||
|
# Compression only ever engages once the real context crosses the
|
||||||
|
# trigger, which is exactly when it is needed.
|
||||||
|
if not self._should_summarize(messages, self.token_counter(messages)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = self._resolve_request_thread_id(request)
|
||||||
|
if thread_id is None:
|
||||||
|
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
|
||||||
|
cached = self._get_cache_entry(thread_id) if thread_id else None
|
||||||
present_ids = {m.id for m in messages}
|
present_ids = {m.id for m in messages}
|
||||||
if cached is not None and cached.reusable and cached.summarized_ids <= present_ids:
|
if cached is not None and cached.reusable and cached.summarized_ids <= present_ids:
|
||||||
preserved = [m for m in messages if m.id not in cached.summarized_ids]
|
preserved = [m for m in messages if m.id not in cached.summarized_ids]
|
||||||
effective = [self._summary_message(cached.summary_text), *preserved]
|
effective = [self._summary_message(cached.summary_text), *preserved]
|
||||||
if not self._should_summarize(effective, self.token_counter(effective)):
|
if not self._should_summarize(effective, self.token_counter(effective)):
|
||||||
# Refresh LRU recency and reuse without an LLM call.
|
# Refresh LRU recency and reuse without an LLM call.
|
||||||
self._summary_cache.move_to_end(thread_id)
|
self._touch_cache_entry(thread_id)
|
||||||
return ("reuse", effective)
|
return ("reuse", effective)
|
||||||
# New messages have pushed the compressed context back over the
|
# New messages have pushed the compressed context back over the
|
||||||
# threshold — fall through and re-summarize a larger prefix.
|
# threshold — fall through and re-summarize a larger prefix.
|
||||||
|
|
||||||
if not self._should_summarize(messages, self.token_counter(messages)):
|
|
||||||
return None
|
|
||||||
|
|
||||||
cutoff_index = self._determine_cutoff_index(messages)
|
cutoff_index = self._determine_cutoff_index(messages)
|
||||||
if cutoff_index <= 0:
|
if cutoff_index <= 0:
|
||||||
return None
|
return None
|
||||||
@@ -294,12 +349,24 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
|
|
||||||
return compressed
|
return compressed
|
||||||
|
|
||||||
|
def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None:
|
||||||
|
"""Return the cached entry for ``thread_id`` (thread-safe read)."""
|
||||||
|
with _GLOBAL_SUMMARY_CACHE_LOCK:
|
||||||
|
return self._summary_cache.get(thread_id)
|
||||||
|
|
||||||
|
def _touch_cache_entry(self, thread_id: str) -> None:
|
||||||
|
"""Refresh LRU recency for ``thread_id`` if still present (thread-safe)."""
|
||||||
|
with _GLOBAL_SUMMARY_CACHE_LOCK:
|
||||||
|
if thread_id in self._summary_cache:
|
||||||
|
self._summary_cache.move_to_end(thread_id)
|
||||||
|
|
||||||
def _store_cache_entry(self, thread_id: str, entry: _SummaryCacheEntry) -> None:
|
def _store_cache_entry(self, thread_id: str, entry: _SummaryCacheEntry) -> None:
|
||||||
"""Insert/refresh a cache entry, evicting the oldest thread if over capacity."""
|
"""Insert/refresh a cache entry, evicting the oldest thread if over capacity."""
|
||||||
self._summary_cache[thread_id] = entry
|
with _GLOBAL_SUMMARY_CACHE_LOCK:
|
||||||
self._summary_cache.move_to_end(thread_id)
|
self._summary_cache[thread_id] = entry
|
||||||
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
|
self._summary_cache.move_to_end(thread_id)
|
||||||
self._summary_cache.popitem(last=False)
|
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
|
||||||
|
self._summary_cache.popitem(last=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_request_thread_id(request: ModelRequest) -> str | None:
|
def _resolve_request_thread_id(request: ModelRequest) -> str | None:
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ class AgentFavoriteRow(Base):
|
|||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
# Where this favorite came from: ``"user"`` = the user bookmarked it
|
||||||
|
# themselves; ``"position"`` = it was auto-granted by the user's 岗位
|
||||||
|
# (position) via the subscription sync engine. Sync only ever adds/removes
|
||||||
|
# ``"position"`` rows, so a user's own bookmarks are never clobbered.
|
||||||
|
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user")
|
||||||
|
# The position that granted this favorite (only set when ``origin='position'``).
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
"""岗位 (position) subscription system.
|
||||||
|
|
||||||
|
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``.
|
||||||
|
|
||||||
|
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
|
||||||
|
per the repo convention (see backend/CLAUDE.md → Schema Migrations).
|
||||||
|
|
||||||
|
Revision ID: 20260616_01
|
||||||
|
Revises: 20260612_01
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260616_01"
|
||||||
|
down_revision = "20260612_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in inspect(op.get_bind()).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(col["name"] == column_name for col in inspect(op.get_bind()).get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("positions"):
|
||||||
|
op.create_table(
|
||||||
|
"positions",
|
||||||
|
sa.Column("id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(191), nullable=False),
|
||||||
|
sa.Column("description", sa.String(1024), nullable=False, server_default=""),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("name", name="uq_positions_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_positions_is_default", "positions", ["is_default"])
|
||||||
|
|
||||||
|
if not _has_table("position_tag_bindings"):
|
||||||
|
op.create_table(
|
||||||
|
"position_tag_bindings",
|
||||||
|
sa.Column("id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("position_id", sa.String(64), nullable=False),
|
||||||
|
sa.Column("resource_type", sa.String(32), nullable=False),
|
||||||
|
sa.Column("tag_id", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("position_id", "resource_type", "tag_id", name="uq_position_tag_bindings"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_position_tag_bindings_position_id", "position_tag_bindings", ["position_id"])
|
||||||
|
op.create_index("ix_position_tag_bindings_resource_type", "position_tag_bindings", ["resource_type"])
|
||||||
|
op.create_index("ix_position_tag_bindings_tag_id", "position_tag_bindings", ["tag_id"])
|
||||||
|
op.create_index("ix_position_tag_bindings_position_type", "position_tag_bindings", ["position_id", "resource_type"])
|
||||||
|
|
||||||
|
if _has_table("users") and not _has_column("users", "position_id"):
|
||||||
|
op.add_column("users", sa.Column("position_id", sa.String(64), nullable=True))
|
||||||
|
op.create_index("ix_users_position_id", "users", ["position_id"])
|
||||||
|
|
||||||
|
for table in ("agent_favorites", "scheduled_task_subscriptions"):
|
||||||
|
if _has_table(table) and not _has_column(table, "origin"):
|
||||||
|
op.add_column(table, sa.Column("origin", sa.String(16), nullable=False, server_default="user"))
|
||||||
|
if _has_table(table) and not _has_column(table, "position_id"):
|
||||||
|
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"):
|
||||||
|
if _has_column(table, "position_id"):
|
||||||
|
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"):
|
||||||
|
op.drop_table("position_tag_bindings")
|
||||||
|
if _has_table("positions"):
|
||||||
|
op.drop_table("positions")
|
||||||
+3
@@ -41,6 +41,7 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
|||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.notifications.model import NotificationRow
|
from deerflow.persistence.notifications.model import NotificationRow
|
||||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||||
|
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
|
||||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||||
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
||||||
@@ -88,6 +89,8 @@ __all__ = [
|
|||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
|
"PositionRow",
|
||||||
|
"PositionTagBindingRow",
|
||||||
"RecommendedQuestionRow",
|
"RecommendedQuestionRow",
|
||||||
"RoundtableChainRow",
|
"RoundtableChainRow",
|
||||||
"RoundtableDraftRow",
|
"RoundtableDraftRow",
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"""Persistence layer for 岗位 (positions) and per-resource tag bindings."""
|
||||||
|
|
||||||
|
from deerflow.persistence.positions.base import PositionStore
|
||||||
|
from deerflow.persistence.positions.memory import MemoryPositionStore
|
||||||
|
from deerflow.persistence.positions.model import (
|
||||||
|
POSITION_RESOURCE_TYPES,
|
||||||
|
PositionRow,
|
||||||
|
PositionTagBindingRow,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.positions.sql import PositionRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PositionStore",
|
||||||
|
"MemoryPositionStore",
|
||||||
|
"PositionRepository",
|
||||||
|
"PositionRow",
|
||||||
|
"PositionTagBindingRow",
|
||||||
|
"POSITION_RESOURCE_TYPES",
|
||||||
|
"make_position_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_position_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryPositionStore()
|
||||||
|
return PositionRepository(session_factory)
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
"""Abstract persistence API for 岗位 (positions) and their tag bindings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PositionStore(ABC):
|
||||||
|
# -------------------------------------------------------------- positions
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_positions(self) -> list[dict[str, Any]]:
|
||||||
|
"""List all positions. Each record carries a derived ``member_count``."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_position(self, position_id: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_position(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a position. ``data`` must carry ``id`` and ``name``.
|
||||||
|
|
||||||
|
Raises ``ValueError`` if ``name`` is not unique. When ``is_default`` is
|
||||||
|
truthy, any other default position is cleared first.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_position(self, position_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Update ``name`` / ``description`` / ``is_default``.
|
||||||
|
|
||||||
|
Returns ``None`` if the position does not exist. Raises ``ValueError``
|
||||||
|
on a name collision. Setting ``is_default=True`` clears the flag on the
|
||||||
|
other positions.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_position(self, position_id: str) -> bool:
|
||||||
|
"""Delete a position, its tag bindings, and unassign its members."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_default_position_id(self) -> str | None:
|
||||||
|
"""Return the id of the ``is_default`` position, or ``None``."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ tag bindings
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_tag_bindings(self, position_id: str) -> dict[str, list[str]]:
|
||||||
|
"""Return ``{resource_type: [tag_id, ...]}`` for one position.
|
||||||
|
|
||||||
|
Resource types with no bound tags are omitted (callers treat a missing
|
||||||
|
key as "use the default set").
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_tag_bindings(self, position_id: str, bindings: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||||
|
"""Replace the full set of tag bindings for a position.
|
||||||
|
|
||||||
|
``bindings`` maps each resource_type to its desired tag-id list. A
|
||||||
|
resource_type omitted from ``bindings`` (or mapped to ``[]``) ends up
|
||||||
|
with no rows — i.e. it falls back to the default set.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- members
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_user_position_id(self, user_id: str) -> str | None:
|
||||||
|
"""Return the position id explicitly assigned to a user, or ``None``."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_members(self, position_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return ``[{id, email}, ...]`` of users assigned to this position."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def assign_users(self, position_id: str, user_ids: list[str]) -> int:
|
||||||
|
"""Assign a position to many users (1:1 — overwrites any prior position).
|
||||||
|
|
||||||
|
Returns the number of users updated.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def unassign_users(self, user_ids: list[str]) -> int:
|
||||||
|
"""Clear the position of many users. Returns the number updated."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_member_ids(self, position_id: str) -> list[str]:
|
||||||
|
"""Return just the user ids assigned to this position (sync fan-out)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_assigned_user_ids(self) -> list[str]:
|
||||||
|
"""Return every user id that has an explicit position (for bulk resync)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_all_users(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return ``[{id, email, system_role, position_id}, ...]`` for every user.
|
||||||
|
|
||||||
|
Powers the admin member-picker and the per-user position editor.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
"""In-memory ``PositionStore`` implementation for tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryPositionStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._positions: dict[str, dict[str, Any]] = {}
|
||||||
|
# position_id -> {resource_type -> [tag_id]}
|
||||||
|
self._bindings: dict[str, dict[str, list[str]]] = {}
|
||||||
|
# user_id -> position_id
|
||||||
|
self._user_position: dict[str, str] = {}
|
||||||
|
# user_id -> email (optional; populated via register_user for nicer member lists)
|
||||||
|
self._user_email: dict[str, str] = {}
|
||||||
|
|
||||||
|
# test helper — not part of the abstract API
|
||||||
|
def register_user(self, user_id: str, email: str = "") -> None:
|
||||||
|
self._user_email[user_id] = email
|
||||||
|
|
||||||
|
def _member_count(self, position_id: str) -> int:
|
||||||
|
return sum(1 for pid in self._user_position.values() if pid == position_id)
|
||||||
|
|
||||||
|
def _to_dict(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = copy.deepcopy(record)
|
||||||
|
out["member_count"] = self._member_count(record["id"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- positions
|
||||||
|
|
||||||
|
async def list_positions(self) -> list[dict[str, Any]]:
|
||||||
|
rows = sorted(self._positions.values(), key=lambda r: r["created_at"])
|
||||||
|
return [self._to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def get_position(self, position_id: str) -> dict[str, Any] | None:
|
||||||
|
record = self._positions.get(position_id)
|
||||||
|
return self._to_dict(record) if record is not None else None
|
||||||
|
|
||||||
|
def _clear_default(self, keep_id: str | None) -> None:
|
||||||
|
for r in self._positions.values():
|
||||||
|
if r["id"] != keep_id:
|
||||||
|
r["is_default"] = False
|
||||||
|
|
||||||
|
async def create_position(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = data["name"]
|
||||||
|
if any(r["name"] == name for r in self._positions.values()):
|
||||||
|
raise ValueError(f"Position '{name}' already exists")
|
||||||
|
is_default = bool(data.get("is_default"))
|
||||||
|
if is_default:
|
||||||
|
self._clear_default(keep_id=data["id"])
|
||||||
|
record = {
|
||||||
|
"id": data["id"],
|
||||||
|
"name": name,
|
||||||
|
"description": data.get("description") or "",
|
||||||
|
"is_default": is_default,
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
self._positions[data["id"]] = record
|
||||||
|
return self._to_dict(record)
|
||||||
|
|
||||||
|
async def update_position(self, position_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
record = self._positions.get(position_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
new_name = data.get("name", record["name"])
|
||||||
|
if new_name is not None and any(r["id"] != position_id and r["name"] == new_name for r in self._positions.values()):
|
||||||
|
raise ValueError(f"Position '{new_name}' already exists")
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
record["name"] = data["name"]
|
||||||
|
if "description" in data and data["description"] is not None:
|
||||||
|
record["description"] = data["description"]
|
||||||
|
if "is_default" in data and data["is_default"] is not None:
|
||||||
|
record["is_default"] = bool(data["is_default"])
|
||||||
|
if record["is_default"]:
|
||||||
|
self._clear_default(keep_id=position_id)
|
||||||
|
record["updated_at"] = _now()
|
||||||
|
return self._to_dict(record)
|
||||||
|
|
||||||
|
async def delete_position(self, position_id: str) -> bool:
|
||||||
|
if position_id not in self._positions:
|
||||||
|
return False
|
||||||
|
del self._positions[position_id]
|
||||||
|
self._bindings.pop(position_id, None)
|
||||||
|
for uid in [uid for uid, pid in self._user_position.items() if pid == position_id]:
|
||||||
|
del self._user_position[uid]
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_default_position_id(self) -> str | None:
|
||||||
|
for r in self._positions.values():
|
||||||
|
if r["is_default"]:
|
||||||
|
return r["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ tag bindings
|
||||||
|
|
||||||
|
async def get_tag_bindings(self, position_id: str) -> dict[str, list[str]]:
|
||||||
|
return copy.deepcopy(self._bindings.get(position_id, {}))
|
||||||
|
|
||||||
|
async def set_tag_bindings(self, position_id: str, bindings: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||||
|
cleaned = {rt: list(dict.fromkeys(tag_ids)) for rt, tag_ids in bindings.items() if tag_ids}
|
||||||
|
self._bindings[position_id] = cleaned
|
||||||
|
return copy.deepcopy(cleaned)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- members
|
||||||
|
|
||||||
|
async def get_user_position_id(self, user_id: str) -> str | None:
|
||||||
|
return self._user_position.get(user_id)
|
||||||
|
|
||||||
|
async def list_members(self, position_id: str) -> list[dict[str, Any]]:
|
||||||
|
return [{"id": uid, "email": self._user_email.get(uid, "")} for uid, pid in self._user_position.items() if pid == position_id]
|
||||||
|
|
||||||
|
async def assign_users(self, position_id: str, user_ids: list[str]) -> int:
|
||||||
|
for uid in user_ids:
|
||||||
|
self._user_position[uid] = position_id
|
||||||
|
return len(user_ids)
|
||||||
|
|
||||||
|
async def unassign_users(self, user_ids: list[str]) -> int:
|
||||||
|
count = 0
|
||||||
|
for uid in user_ids:
|
||||||
|
if uid in self._user_position:
|
||||||
|
del self._user_position[uid]
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def list_member_ids(self, position_id: str) -> list[str]:
|
||||||
|
return [uid for uid, pid in self._user_position.items() if pid == position_id]
|
||||||
|
|
||||||
|
async def list_assigned_user_ids(self) -> list[str]:
|
||||||
|
return list(self._user_position.keys())
|
||||||
|
|
||||||
|
async def list_all_users(self) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": uid, "email": email, "system_role": "user", "position_id": self._user_position.get(uid)}
|
||||||
|
for uid, email in sorted(self._user_email.items(), key=lambda kv: kv[1])
|
||||||
|
]
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
"""ORM models for 岗位 (positions) and their per-resource tag bindings.
|
||||||
|
|
||||||
|
A **position** (岗位) is an admin-managed aggregation layer that decides which
|
||||||
|
agents / skills / scheduled tasks / custom prompts / recommended questions a
|
||||||
|
user is subscribed to. Each user has at most one position (the link lives on
|
||||||
|
``users.position_id``, a 1:1 relation — see ``UserRow``).
|
||||||
|
|
||||||
|
Two tables here:
|
||||||
|
|
||||||
|
- ``positions`` — the position itself. ``is_default`` marks the fallback
|
||||||
|
position inherited by users who have not been assigned one explicitly.
|
||||||
|
- ``position_tag_bindings`` — for each ``(position, resource_type)`` the set of
|
||||||
|
tags whose resources are granted to the position's members. **No binding for
|
||||||
|
a resource_type means "use the default set"** (see ``deerflow.services.
|
||||||
|
position_sync`` for what "default" resolves to per type).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Index, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
# Resource types a position can grant. The first three reuse the existing
|
||||||
|
# typed-tag namespace (``deerflow.persistence.tags``); ``user_prompt`` and
|
||||||
|
# ``recommended_question`` are added by the same change (see TAG_TYPES).
|
||||||
|
POSITION_RESOURCE_TYPES: tuple[str, ...] = (
|
||||||
|
"agent",
|
||||||
|
"skill",
|
||||||
|
"scheduled_task",
|
||||||
|
"user_prompt",
|
||||||
|
"recommended_question",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PositionRow(Base):
|
||||||
|
__tablename__ = "positions"
|
||||||
|
__table_args__ = (UniqueConstraint("name", name="uq_positions_name"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(191), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||||
|
# Exactly one position should carry ``is_default=True`` at a time (the
|
||||||
|
# router enforces "only one default" by clearing the flag on others). Users
|
||||||
|
# with no explicit ``users.position_id`` inherit this position's grants.
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, 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))
|
||||||
|
|
||||||
|
|
||||||
|
class PositionTagBindingRow(Base):
|
||||||
|
"""One bound tag for a ``(position, resource_type)`` pair.
|
||||||
|
|
||||||
|
The absence of *any* row for a ``(position, resource_type)`` means the
|
||||||
|
position uses the **default set** for that resource type. One or more rows
|
||||||
|
mean the position grants the *union* of the resources carrying those tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "position_tag_bindings"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("position_id", "resource_type", "tag_id", name="uq_position_tag_bindings"),
|
||||||
|
Index("ix_position_tag_bindings_position_type", "position_id", "resource_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
position_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||||
|
tag_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
"""SQLAlchemy-backed position + tag-binding repository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.positions.base import PositionStore
|
||||||
|
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
|
||||||
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
|
|
||||||
|
def _position_to_dict(row: PositionRow, member_count: int = 0) -> dict[str, Any]:
|
||||||
|
data = row.to_dict()
|
||||||
|
for key in ("created_at", "updated_at"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
data[key] = value.isoformat()
|
||||||
|
data["member_count"] = member_count
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class PositionRepository(PositionStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _member_counts(session: AsyncSession, position_ids: list[str]) -> dict[str, int]:
|
||||||
|
if not position_ids:
|
||||||
|
return {}
|
||||||
|
stmt = select(UserRow.position_id, func.count()).where(UserRow.position_id.in_(position_ids)).group_by(UserRow.position_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {pid: count for pid, count in result.all() if pid is not None}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _clear_default(session: AsyncSession, keep_id: str | None) -> None:
|
||||||
|
stmt = update(PositionRow).where(PositionRow.is_default.is_(True))
|
||||||
|
if keep_id is not None:
|
||||||
|
stmt = stmt.where(PositionRow.id != keep_id)
|
||||||
|
await session.execute(stmt.values(is_default=False))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- positions
|
||||||
|
|
||||||
|
async def list_positions(self) -> list[dict[str, Any]]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
rows = list((await session.execute(select(PositionRow).order_by(PositionRow.created_at.asc()))).scalars())
|
||||||
|
counts = await self._member_counts(session, [r.id for r in rows])
|
||||||
|
return [_position_to_dict(r, counts.get(r.id, 0)) for r in rows]
|
||||||
|
|
||||||
|
async def get_position(self, position_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(PositionRow, position_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
counts = await self._member_counts(session, [position_id])
|
||||||
|
return _position_to_dict(row, counts.get(position_id, 0))
|
||||||
|
|
||||||
|
async def create_position(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
is_default = bool(data.get("is_default"))
|
||||||
|
row = PositionRow(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description") or "",
|
||||||
|
is_default=is_default,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
if is_default:
|
||||||
|
await self._clear_default(session, keep_id=row.id)
|
||||||
|
session.add(row)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise ValueError(f"Position '{data['name']}' already exists") from exc
|
||||||
|
await session.refresh(row)
|
||||||
|
return _position_to_dict(row, 0)
|
||||||
|
|
||||||
|
async def update_position(self, position_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(PositionRow, position_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
row.name = data["name"]
|
||||||
|
if "description" in data and data["description"] is not None:
|
||||||
|
row.description = data["description"]
|
||||||
|
if "is_default" in data and data["is_default"] is not None:
|
||||||
|
row.is_default = bool(data["is_default"])
|
||||||
|
if row.is_default:
|
||||||
|
await self._clear_default(session, keep_id=position_id)
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise ValueError(f"Position '{data.get('name')}' already exists") from exc
|
||||||
|
await session.refresh(row)
|
||||||
|
counts = await self._member_counts(session, [position_id])
|
||||||
|
return _position_to_dict(row, counts.get(position_id, 0))
|
||||||
|
|
||||||
|
async def delete_position(self, position_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(PositionRow, position_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.execute(delete(PositionTagBindingRow).where(PositionTagBindingRow.position_id == position_id))
|
||||||
|
await session.execute(update(UserRow).where(UserRow.position_id == position_id).values(position_id=None))
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_default_position_id(self) -> str | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
return (await session.execute(select(PositionRow.id).where(PositionRow.is_default.is_(True)).limit(1))).scalar_one_or_none()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ tag bindings
|
||||||
|
|
||||||
|
async def get_tag_bindings(self, position_id: str) -> dict[str, list[str]]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
rows = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(PositionTagBindingRow).where(PositionTagBindingRow.position_id == position_id).order_by(PositionTagBindingRow.created_at.asc())
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for r in rows:
|
||||||
|
out.setdefault(r.resource_type, []).append(r.tag_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def set_tag_bindings(self, position_id: str, bindings: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
async with self._sf() as session:
|
||||||
|
await session.execute(delete(PositionTagBindingRow).where(PositionTagBindingRow.position_id == position_id))
|
||||||
|
for resource_type, tag_ids in bindings.items():
|
||||||
|
for tag_id in dict.fromkeys(tag_ids):
|
||||||
|
session.add(
|
||||||
|
PositionTagBindingRow(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
position_id=position_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
tag_id=tag_id,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return await self.get_tag_bindings(position_id)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- members
|
||||||
|
|
||||||
|
async def get_user_position_id(self, user_id: str) -> str | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
return (await session.execute(select(UserRow.position_id).where(UserRow.id == user_id))).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_members(self, position_id: str) -> list[dict[str, Any]]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
rows = (await session.execute(select(UserRow.id, UserRow.email).where(UserRow.position_id == position_id).order_by(UserRow.email.asc()))).all()
|
||||||
|
return [{"id": uid, "email": email} for uid, email in rows]
|
||||||
|
|
||||||
|
async def assign_users(self, position_id: str, user_ids: list[str]) -> int:
|
||||||
|
if not user_ids:
|
||||||
|
return 0
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(update(UserRow).where(UserRow.id.in_(user_ids)).values(position_id=position_id))
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def unassign_users(self, user_ids: list[str]) -> int:
|
||||||
|
if not user_ids:
|
||||||
|
return 0
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(update(UserRow).where(UserRow.id.in_(user_ids)).values(position_id=None))
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def list_member_ids(self, position_id: str) -> list[str]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
return [uid for (uid,) in (await session.execute(select(UserRow.id).where(UserRow.position_id == position_id))).all()]
|
||||||
|
|
||||||
|
async def list_assigned_user_ids(self) -> list[str]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
return [uid for (uid,) in (await session.execute(select(UserRow.id).where(UserRow.position_id.isnot(None)))).all()]
|
||||||
|
|
||||||
|
async def list_all_users(self) -> list[dict[str, Any]]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
rows = (await session.execute(select(UserRow.id, UserRow.email, UserRow.system_role, UserRow.position_id).order_by(UserRow.email.asc()))).all()
|
||||||
|
return [{"id": uid, "email": email, "system_role": role, "position_id": pid} for uid, email, role, pid in rows]
|
||||||
+5
@@ -55,6 +55,11 @@ class ScheduledTaskSubscriptionRow(Base):
|
|||||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
notify_element: Mapped[bool] = mapped_column(Boolean, default=False)
|
notify_element: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
# Source of this subscription: ``"user"`` (subscribed manually from the
|
||||||
|
# 广场) or ``"position"`` (auto-granted by the user's 岗位 via the sync
|
||||||
|
# engine). Sync only ever touches ``"position"`` rows.
|
||||||
|
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user")
|
||||||
|
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,14 @@ class TagStore(ABC):
|
|||||||
async def unassign_all(self, target_type: str, target_id: str) -> int:
|
async def unassign_all(self, target_type: str, target_id: str) -> int:
|
||||||
"""Drop every tag assignment for a resource. Returns the count removed."""
|
"""Drop every tag assignment for a resource. Returns the count removed."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_targets_for_tags(self, target_type: str, tag_ids: list[str]) -> list[str]:
|
||||||
|
"""Return the ids of ``target_type`` resources carrying ANY of ``tag_ids`` (OR).
|
||||||
|
|
||||||
|
Reverse of :meth:`list_assignments`. Powers the 岗位 (position) sync
|
||||||
|
engine and the per-position visibility filters: "which resources does
|
||||||
|
this set of tags grant?". Returns a de-duplicated list; empty when
|
||||||
|
``tag_ids`` is empty.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
@@ -136,3 +136,15 @@ class MemoryTagStore(TagStore):
|
|||||||
for aid in victims:
|
for aid in victims:
|
||||||
del self._assignments[aid]
|
del self._assignments[aid]
|
||||||
return len(victims)
|
return len(victims)
|
||||||
|
|
||||||
|
async def list_targets_for_tags(self, target_type: str, tag_ids: list[str]) -> list[str]:
|
||||||
|
wanted = set(tag_ids)
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for a in self._assignments.values():
|
||||||
|
if a["target_type"] == target_type and a["tag_id"] in wanted and a["target_id"] not in seen:
|
||||||
|
seen.add(a["target_id"])
|
||||||
|
out.append(a["target_id"])
|
||||||
|
return out
|
||||||
+3
-1
@@ -21,7 +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.
|
||||||
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task")
|
# ``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")
|
||||||
|
|
||||||
|
|
||||||
class TagRow(Base):
|
class TagRow(Base):
|
||||||
|
|||||||
@@ -185,3 +185,15 @@ class TagRepository(TagStore):
|
|||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount or 0
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def list_targets_for_tags(self, target_type: str, tag_ids: list[str]) -> list[str]:
|
||||||
|
unique_ids = list(dict.fromkeys(tag_ids))
|
||||||
|
if not unique_ids:
|
||||||
|
return []
|
||||||
|
stmt = (
|
||||||
|
select(TagAssignmentRow.target_id)
|
||||||
|
.where(TagAssignmentRow.target_type == target_type, TagAssignmentRow.tag_id.in_(unique_ids))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
return [tid for (tid,) in (await session.execute(stmt)).all()]
|
||||||
@@ -49,6 +49,11 @@ class UserRow(Base):
|
|||||||
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
token_version: Mapped[int] = mapped_column(nullable=False, default=0)
|
token_version: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||||
|
|
||||||
|
# 岗位 (position) the user is assigned to (1:1). NULL = no explicit
|
||||||
|
# position → the user inherits the ``is_default`` position's grants. Drives
|
||||||
|
# the subscription sync engine (see ``deerflow.services.position_sync``).
|
||||||
|
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index(
|
Index(
|
||||||
"idx_users_oauth_identity",
|
"idx_users_oauth_identity",
|
||||||
|
|||||||
+14
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import Integer, String, Text
|
from sqlalchemy import Boolean, 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,5 +22,18 @@ 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))
|
||||||
+62
-1
@@ -25,15 +25,76 @@ 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)
|
.where(UserCustomPromptRow.user_id == user_id, UserCustomPromptRow.is_template.is_(False))
|
||||||
.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(
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cross-cutting harness services (no ``app.*`` imports)."""
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""岗位 (position) → resource subscription **sync engine**.
|
||||||
|
|
||||||
|
A user's position decides which agents / scheduled tasks / custom prompts they
|
||||||
|
are subscribed to, and which skills / recommended questions they can see. This
|
||||||
|
service turns a position's *tag bindings* into concrete, materialized rows in
|
||||||
|
the existing "我的" tables (idempotent, re-runnable).
|
||||||
|
|
||||||
|
Resolution rule (per resource type):
|
||||||
|
|
||||||
|
* The user's position is ``users.position_id``; if unset, the ``is_default``
|
||||||
|
position is used; if there is none, every type falls back to its default set.
|
||||||
|
* For a resource type **with** bound tags → the union of resources carrying any
|
||||||
|
of those tags.
|
||||||
|
* 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, 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")
|
||||||
|
# Resolved live at query time (visibility filters), never materialized.
|
||||||
|
_VISIBILITY_TYPES = ("skill", "recommended_question")
|
||||||
|
|
||||||
|
|
||||||
|
class PositionSyncService:
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession], position_store: PositionStore, tag_store: TagStore) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
self._positions = position_store
|
||||||
|
self._tags = tag_store
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- resolution
|
||||||
|
|
||||||
|
async def _effective_position_id(self, user_id: str) -> str | None:
|
||||||
|
pid = await self._positions.get_user_position_id(user_id)
|
||||||
|
if pid:
|
||||||
|
return pid
|
||||||
|
return await self._positions.get_default_position_id()
|
||||||
|
|
||||||
|
async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None:
|
||||||
|
"""Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted.
|
||||||
|
|
||||||
|
Used by the skills list + recommended-questions filters. ``None`` means
|
||||||
|
"no position restriction → show everything"; a set means "restrict to
|
||||||
|
exactly these ids".
|
||||||
|
"""
|
||||||
|
pid = await self._effective_position_id(user_id)
|
||||||
|
if not pid:
|
||||||
|
return None
|
||||||
|
bindings = await self._positions.get_tag_bindings(pid)
|
||||||
|
tag_ids = bindings.get(resource_type) or []
|
||||||
|
if not tag_ids:
|
||||||
|
return None
|
||||||
|
return set(await self._tags.list_targets_for_tags(resource_type, tag_ids))
|
||||||
|
|
||||||
|
async def _resolve_materialized(self, user_id: str, resource_type: str, session: AsyncSession) -> set[str]:
|
||||||
|
pid = await self._effective_position_id(user_id)
|
||||||
|
bindings = await self._positions.get_tag_bindings(pid) if pid else {}
|
||||||
|
tag_ids = bindings.get(resource_type) or []
|
||||||
|
if tag_ids:
|
||||||
|
return set(await self._tags.list_targets_for_tags(resource_type, tag_ids))
|
||||||
|
return await self._default_set(resource_type, session)
|
||||||
|
|
||||||
|
async def _default_set(self, resource_type: str, session: AsyncSession) -> set[str]:
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
"""Reconcile all materialized subscriptions for one user (idempotent)."""
|
||||||
|
if self._sf is None:
|
||||||
|
return
|
||||||
|
pid = await self._effective_position_id(user_id)
|
||||||
|
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:
|
||||||
|
target = await self._resolve_materialized(user_id, "agent", session)
|
||||||
|
rows = list((await session.execute(select(AgentFavoriteRow).where(AgentFavoriteRow.user_id == user_id))).scalars())
|
||||||
|
owned_any = {r.agent_id for r in rows}
|
||||||
|
position_rows = {r.agent_id: r for r in rows if r.origin == "position"}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for agent_id in target - owned_any:
|
||||||
|
session.add(AgentFavoriteRow(id=uuid.uuid4().hex, user_id=user_id, agent_id=agent_id, origin="position", position_id=position_id, created_at=now))
|
||||||
|
for agent_id, row in position_rows.items():
|
||||||
|
if agent_id not in target:
|
||||||
|
await session.delete(row)
|
||||||
|
|
||||||
|
async def _sync_tasks(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
||||||
|
target = await self._resolve_materialized(user_id, "scheduled_task", session)
|
||||||
|
rows = list((await session.execute(select(ScheduledTaskSubscriptionRow).where(ScheduledTaskSubscriptionRow.user_id == user_id))).scalars())
|
||||||
|
owned_any = {r.task_id for r in rows}
|
||||||
|
position_rows = {r.task_id: r for r in rows if r.origin == "position"}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for task_id in target - owned_any:
|
||||||
|
session.add(ScheduledTaskSubscriptionRow(id=uuid.uuid4().hex, task_id=task_id, user_id=user_id, notify_element=False, origin="position", position_id=position_id, created_at=now, updated_at=now))
|
||||||
|
for task_id, row in position_rows.items():
|
||||||
|
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:
|
||||||
|
"""Re-sync every member of a position. Returns the member count."""
|
||||||
|
member_ids = await self._positions.list_member_ids(position_id)
|
||||||
|
for uid in member_ids:
|
||||||
|
try:
|
||||||
|
await self.sync_user(uid)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("position sync failed for user %s (position %s)", uid, position_id)
|
||||||
|
return len(member_ids)
|
||||||
|
|
||||||
|
async def sync_default_members(self) -> int:
|
||||||
|
"""Re-sync users with NO explicit position (they inherit the default)."""
|
||||||
|
default_id = await self._positions.get_default_position_id()
|
||||||
|
if not default_id:
|
||||||
|
return 0
|
||||||
|
assigned = set(await self._positions.list_assigned_user_ids())
|
||||||
|
count = 0
|
||||||
|
if self._sf is None:
|
||||||
|
return 0
|
||||||
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
|
async with self._sf() as session:
|
||||||
|
all_ids = [uid for (uid,) in (await session.execute(select(UserRow.id))).all()]
|
||||||
|
for uid in all_ids:
|
||||||
|
if uid in assigned:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self.sync_user(uid)
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception("default position sync failed for user %s", uid)
|
||||||
|
return count
|
||||||
@@ -82,6 +82,23 @@ CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL D
|
|||||||
-- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
|
-- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
|
||||||
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL');
|
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL');
|
||||||
|
|
||||||
|
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
|
||||||
|
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
|
||||||
|
-- 以下是给【已存在的表】补的列:
|
||||||
|
-- users.position_id: 用户所属岗位(1:1)。ORM: UserRow.position_id = String(64) NULL。
|
||||||
|
CALL _add_column_if_missing('users', 'position_id', 'VARCHAR(64) NULL DEFAULT NULL');
|
||||||
|
-- agent_favorites: origin 区分用户自收藏 vs 岗位授予;position_id 记授予岗位。
|
||||||
|
CALL _add_column_if_missing('agent_favorites', 'origin', "VARCHAR(16) NOT NULL DEFAULT 'user'");
|
||||||
|
CALL _add_column_if_missing('agent_favorites', 'position_id', 'VARCHAR(64) NULL DEFAULT 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;
|
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user