feat: 标签管理功能 + 问答前缀对用户隐藏

标签管理:
- 新增 tags/tag_assignments 持久化层、迁移、/api/tags 路由
- 标签分智能体/技能/定时任务三类, 仅同类型可互打, 仅 admin 可增删改与打标签
- 三类资源列表支持按名称模糊检索与按标签筛选(走后端查询参数)
- 前端新增标签管理页、TagPickerDialog(点击即时绑定)、彩色标签 chip

问答前缀:
- 前缀不再拼入消息 content, 改存 additional_kwargs 由 PromptPrefixMiddleware
  在调模型前临时注入, 用户界面看不到前缀但模型仍收到
- 修正 thread_existed 闸门导致首轮前缀被跳过的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-15 16:19:08 +08:00
1 parent aeba15eb16
commit 646cb8caeb
47 files changed
+2180 -109

No files matched your search

@@ -0,0 +1,293 @@
"use client";
import { Loader2, PencilIcon, PlusIcon, 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { TagChip } from "@/components/workspace/tags/tag-chip";
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateTag,
useDeleteTag,
useTags,
useUpdateTag,
type Tag,
type TagType,
} from "@/core/tags";
import { cn } from "@/lib/utils";
function TagFormDialog({
open,
onOpenChange,
tagType,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tagType: TagType;
editing: Tag | null;
}) {
const { t } = useI18n();
const createTag = useCreateTag();
const updateTag = useUpdateTag();
const [name, setName] = useState("");
const [color, setColor] = useState("gray");
const [description, setDescription] = useState("");
// Sync form state whenever the dialog (re)opens.
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setColor(editing?.color ?? "gray");
setDescription(editing?.description ?? "");
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createTag.isPending || updateTag.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updateTag.mutateAsync({
id: editing.id,
input: { name: trimmed, color, description },
});
} else {
await createTag.mutateAsync({
name: trimmed,
tag_type: tagType,
color,
description,
});
}
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.name}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.adminTags.namePlaceholder}
maxLength={256}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.color}</label>
<div className="flex flex-wrap gap-2">
{TAG_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setColor(c.name)}
aria-label={c.name}
className={cn(
"size-6 rounded-full border-2",
c.swatch,
color === c.name
? "border-foreground"
: "border-transparent",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{t.adminTags.tagDescription}
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.adminTags.descriptionPlaceholder}
maxLength={1024}
rows={2}
/>
</div>
{name.trim() && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Preview:</span>
<TagChip tag={{ name: name.trim(), color }} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.adminTags.cancel}
</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}
{t.adminTags.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TagManagementPage() {
const { t } = useI18n();
const [activeType, setActiveType] = useState<TagType>("agent");
const { data: tags, isLoading, error } = useTags(activeType);
const deleteTag = useDeleteTag();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tag | null>(null);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const openEdit = (tag: Tag) => {
setEditing(tag);
setDialogOpen(true);
};
const handleDelete = async (tag: Tag) => {
if (!window.confirm(t.adminTags.deleteConfirm)) return;
try {
await deleteTag.mutateAsync(tag.id);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminTags.title}
description={t.adminTags.description}
>
<div className="flex items-center justify-between gap-4">
<Tabs
value={activeType}
onValueChange={(v) => setActiveType(v as TagType)}
>
<TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
<TabsTrigger value="scheduled_task">
{t.adminTags.typeScheduledTask}
</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />
{t.adminTags.createButton}
</Button>
</div>
<div className="mt-4">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive py-4 text-sm">{String(error)}</div>
)}
{!isLoading && !error && tags && tags.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
{t.adminTags.empty}
</div>
)}
{!isLoading && !error && tags && tags.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">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.actions}
</th>
</tr>
</thead>
<tbody>
{tags.map((tag, i) => (
<tr
key={tag.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">
<TagChip tag={tag} />
</td>
<td className="text-muted-foreground px-4 py-3">
{tag.description || "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{tag.usage_count}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(tag)}
aria-label={t.adminTags.edit}
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tag)}
aria-label={t.adminTags.delete}
>
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SettingsSection>
<TagFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
tagType={activeType}
editing={editing}
/>
</div>
);
}
@@ -6,6 +6,7 @@ import {
EyeOffIcon,
MessageSquareIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -33,6 +34,8 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
@@ -242,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || canDelete || isOwned) && (
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{isOwned && (
<Button
size="icon"
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
)}
</>
);
}
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -83,10 +87,19 @@ export function AgentGallery() {
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="mx-6 mt-4 w-auto self-start">
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="mine">自己的</TabsTrigger>
</TabsList>
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
<TabsList className="w-auto self-start">
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="mine">自己的</TabsTrigger>
</TabsList>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="w-[420px] max-w-[50%]"
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
@@ -12,6 +12,7 @@ import {
PinIcon,
PinOffIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
@@ -46,6 +47,9 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
@@ -66,36 +70,27 @@ import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
const { skills, isLoading, error } = useSkills();
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
<SkillSettingsList onClose={onClose} />
</SettingsSection>
);
}
function SkillSettingsList({
skills,
onClose,
}: {
skills: Skill[];
onClose?: () => void;
}) {
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("all");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -211,10 +206,22 @@ function SkillSettingsList({
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="max-w-xl"
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<SkillRow
key={skill.name}
@@ -258,6 +265,7 @@ function SkillRow({
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -392,8 +400,22 @@ function SkillRow({
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</ItemContent>
<ItemActions>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
@@ -473,6 +495,15 @@ function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
)}
</>
);
}
@@ -0,0 +1,58 @@
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags";
import { resolveTagColor } from "./tag-colors";
/** A read-only (or removable) coloured chip for a single tag. */
export function TagChip({
tag,
onRemove,
className,
}: {
tag: Pick<Tag, "name" | "color">;
onRemove?: () => void;
className?: string;
}) {
const color = resolveTagColor(tag.color);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
</button>
)}
</span>
);
}
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
export function TagChipList({
tags,
className,
}: {
tags: Pick<Tag, "name" | "color">[];
className?: string;
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
</div>
);
}
@@ -0,0 +1,30 @@
// Fixed tag color palette. The backend only stores the color *name*; the
// Tailwind classes are resolved here so the class strings stay static and
// survive the production purge.
export interface TagColorDef {
name: string;
/** Classes for a rendered chip (background + text + border). */
chip: string;
/** A solid swatch used in the colour picker. */
swatch: string;
}
export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
];
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef {
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
}
@@ -0,0 +1,140 @@
"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,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
import { resolveTagColor } from "./tag-colors";
/**
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
* unbinds it immediately (no separate save step). Only tags of ``targetType``
* are offered, enforcing "a tag may only tag its own type".
*/
export function TagPickerDialog({
open,
onOpenChange,
targetType,
targetId,
currentTags,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
currentTags: Tag[];
}) {
const { t } = useI18n();
const { data: allTags, isLoading } = useTags(targetType);
const setResourceTags = useSetResourceTags();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}`;
if (open && syncedFor !== syncKey) {
setSelected(new Set(currentTags.map((tg) => tg.id)));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
// Clicking a tag mutates immediately. Local state updates optimistically and
// rolls back if the request fails.
const toggle = (tagId: string) => {
const prev = selected;
const next = new Set(prev);
if (next.has(tagId)) next.delete(tagId);
else next.add(tagId);
setSelected(next);
setResourceTags.mutate(
{ targetType, targetId, tagIds: [...next] },
{
onError: (e) => {
setSelected(prev);
toast.error(String(e instanceof Error ? e.message : e));
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
</DialogHeader>
<div className="min-h-[120px]">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{!isLoading && (!allTags || allTags.length === 0) && (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.adminTags.pickerEmpty}
</div>
)}
{!isLoading && allTags && allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const checked = selected.has(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.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
color.chip,
checked
? "ring-2 ring-offset-1 ring-foreground/40"
: "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3.5" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="items-center sm:justify-between">
<span className="text-muted-foreground text-xs">
{setResourceTags.isPending ? (
<span className="flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t.adminTags.saving}
</span>
) : (
t.adminTags.pickerSelectedCount.replace(
"{count}",
String(selected.size),
)
)}
</span>
<Button onClick={() => onOpenChange(false)}>
{t.adminTags.done}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,80 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
const ALL = "__all__";
/**
* Reusable name-search + tag-filter toolbar for resource list pages.
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke. Filtering is open to all users.
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagId,
onTagIdChange,
className,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagId: string | null;
onTagIdChange: (value: string | null) => void;
className?: string;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
const [input, setInput] = useState("");
const cbRef = useRef(onSearchChange);
cbRef.current = onSearchChange;
useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id);
}, [input]);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
</div>
<Select
value={tagId ?? ALL}
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder={t.adminTags.filterByTag} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
{(tags ?? []).map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -7,6 +7,7 @@ import {
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
TagsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -134,6 +135,14 @@ export function WorkspaceNavMenu() {
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+13 -2
View File
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
}
}
export async function listAgents(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
export interface ListAgentsParams {
search?: string;
tagId?: string | null;
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createAgent,
@@ -9,10 +14,13 @@ import {
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
export function useAgents() {
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
queryFn: () => listAgents(),
queryKey: ["agents", search, tagId],
queryFn: () => listAgents({ search, tagId }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
+3
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export interface Agent {
id: string;
name: string;
@@ -11,6 +13,7 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
}
export interface CreateAgentRequest {
@@ -625,6 +625,40 @@ export const enUS: Translations = {
empty: "No users found",
},
adminTags: {
title: "Tag Management",
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
typeAgent: "Agent Tags",
typeSkill: "Skill Tags",
typeScheduledTask: "Scheduled Task Tags",
name: "Name",
color: "Color",
tagDescription: "Description",
usageCount: "Usage",
actions: "Actions",
empty: "No tags yet",
createButton: "New Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
namePlaceholder: "Tag name",
descriptionPlaceholder: "Optional description",
save: "Save",
cancel: "Cancel",
edit: "Edit",
delete: "Delete",
pickerTitle: "Edit Tags",
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
pickerSelectedCount: "{count} selected",
saving: "Saving…",
done: "Done",
manageTags: "Tags",
searchPlaceholder: "Search by name",
filterByTag: "Filter by tag",
allTags: "All tags",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
@@ -538,6 +538,43 @@ export interface Translations {
empty: string;
};
// Admin tag management page
adminTags: {
title: string;
description: string;
typeAgent: string;
typeSkill: string;
typeScheduledTask: string;
name: string;
color: string;
tagDescription: string;
usageCount: string;
actions: string;
empty: string;
createButton: string;
createTitle: string;
editTitle: string;
deleteConfirm: string;
namePlaceholder: string;
descriptionPlaceholder: string;
save: string;
cancel: string;
edit: string;
delete: string;
// tag picker dialog
pickerTitle: string;
pickerHint: string;
pickerEmpty: string;
pickerSelectedCount: string;
saving: string;
done: string;
manageTags: string;
// search controls on resource lists
searchPlaceholder: string;
filterByTag: string;
allTags: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
@@ -594,6 +594,40 @@ export const zhCN: Translations = {
empty: "暂无用户",
},
adminTags: {
title: "标签管理",
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
typeAgent: "智能体标签",
typeSkill: "技能标签",
typeScheduledTask: "定时任务标签",
name: "名称",
color: "颜色",
tagDescription: "描述",
usageCount: "使用数",
actions: "操作",
empty: "暂无标签",
createButton: "新建标签",
createTitle: "创建标签",
editTitle: "编辑标签",
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
namePlaceholder: "标签名称",
descriptionPlaceholder: "描述(可选)",
save: "保存",
cancel: "取消",
edit: "编辑",
delete: "删除",
pickerTitle: "编辑标签",
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
pickerSelectedCount: "已选 {count} 个",
saving: "保存中…",
done: "完成",
manageTags: "标签",
searchPlaceholder: "按名称搜索",
filterByTag: "按标签筛选",
allTags: "全部标签",
},
notifications: {
title: "通知",
bellAria: "打开通知",
+13 -2
View File
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
export async function listScheduledTasks() {
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
export interface ListScheduledTasksParams {
search?: string;
tagId?: string | null;
}
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
);
}
export async function createScheduledTask(data: {
+14 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createScheduledTask,
@@ -30,11 +35,16 @@ export function useSchedulerThread() {
});
}
export function useScheduledTasks() {
export function useScheduledTasks(
params: { search?: string; tagId?: string | null } = {},
) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: listScheduledTasks,
queryKey: ["scheduled-tasks", "list", search, tagId],
queryFn: () => listScheduledTasks({ search, tagId }),
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
}
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
@@ -29,6 +31,7 @@ export type ScheduledTask = {
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
creator_email?: string | null;
tags?: Tag[];
};
export type PublishedScheduledTask = {
+13 -2
View File
@@ -3,8 +3,19 @@ import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
export interface ListSkillsParams {
search?: string;
tagId?: string | null;
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
);
const json = await skills.json();
return json.skills as Skill[];
}
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deleteCustomSkill,
@@ -13,10 +18,13 @@ import type { SkillState } from "./type";
import { loadSkills } from ".";
export function useSkills() {
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: () => loadSkills(),
queryKey: ["skills", search, tagId],
queryFn: () => loadSkills({ search, tagId }),
placeholderData: keepPreviousData,
});
return { skills: data ?? [], isLoading, error };
}
+4
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
@@ -23,4 +25,6 @@ export interface Skill {
use_count?: number;
view_count?: number;
patch_count?: number;
// Tags assigned to this skill (admin-managed).
tags?: Tag[];
}
+83
View File
@@ -0,0 +1,83 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
Tag,
TagCreateInput,
TagListResponse,
TagType,
TagUpdateInput,
} 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/tags`;
export async function listTags(tagType?: TagType): Promise<Tag[]> {
const query = tagType ? `?tag_type=${encodeURIComponent(tagType)}` : "";
const res = await apiFetch(`${base()}${query}`);
if (!res.ok) throw await parseDetail(res, "Failed to list tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function createTag(input: TagCreateInput): Promise<Tag> {
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 tag");
return (await res.json()) as Tag;
}
export async function updateTag(id: string, input: TagUpdateInput): Promise<Tag> {
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 tag");
return (await res.json()) as Tag;
}
export async function deleteTag(id: string): Promise<void> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) throw await parseDetail(res, "Failed to delete tag");
}
export async function getResourceTags(
targetType: TagType,
targetId: string,
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
);
if (!res.ok) throw await parseDetail(res, "Failed to load resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function setResourceTags(
targetType: TagType,
targetId: string,
tagIds: string[],
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_ids: tagIds }),
},
);
if (!res.ok) throw await parseDetail(res, "Failed to update resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
+88
View File
@@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createTag,
deleteTag,
getResourceTags,
listTags,
setResourceTags,
updateTag,
} from "./api";
import type { TagCreateInput, TagType, TagUpdateInput } from "./types";
// Tags are global (admin-managed), so the cache key is not user-scoped.
const tagKeys = {
root: ["tags"] as const,
list: (tagType?: TagType) => ["tags", "list", tagType ?? "all"] as const,
assignment: (targetType: TagType, targetId: string) =>
["tags", "assignment", targetType, targetId] as const,
};
export function useTags(tagType?: TagType) {
return useQuery({
queryKey: tagKeys.list(tagType),
queryFn: () => listTags(tagType),
});
}
function useInvalidateTags() {
const queryClient = useQueryClient();
return () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
};
}
export function useCreateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (input: TagCreateInput) => createTag(input),
onSuccess: invalidate,
});
}
export function useUpdateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: TagUpdateInput }) =>
updateTag(id, input),
onSuccess: invalidate,
});
}
export function useDeleteTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (id: string) => deleteTag(id),
onSuccess: invalidate,
});
}
export function useResourceTags(targetType: TagType, targetId: string, enabled = true) {
return useQuery({
queryKey: tagKeys.assignment(targetType, targetId),
queryFn: () => getResourceTags(targetType, targetId),
enabled,
});
}
export function useSetResourceTags() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
targetType,
targetId,
tagIds,
}: {
targetType: TagType;
targetId: string;
tagIds: string[];
}) => setResourceTags(targetType, targetId, tagIds),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
// Resource list pages embed the tags, so refresh them too.
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
+31
View File
@@ -0,0 +1,31 @@
export type TagType = "agent" | "skill" | "scheduled_task";
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"];
export interface Tag {
id: string;
name: string;
tag_type: TagType;
color: string;
description: string;
usage_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface TagListResponse {
tags: Tag[];
}
export interface TagCreateInput {
name: string;
tag_type: TagType;
color?: string;
description?: string;
}
export interface TagUpdateInput {
name?: string;
color?: string;
description?: string;
}
+11 -10
View File
@@ -93,7 +93,9 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
@@ -101,19 +103,17 @@ export default function ChatPage() {
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const messageToSend = shouldApplyPrefix
? {
...message,
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
}
: message;
// The prefix is intentionally NOT concatenated into the message text:
// doing so would make the user see it. We only flag the request via
// context; the backend tags the message and PromptPrefixMiddleware
// injects the prefix into the LLM request only.
void sendMessage(
threadId,
messageToSend,
message,
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
);
},
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
[sendMessage, threadId, isNewThread, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
);
const handleClarificationSubmit = useCallback(
@@ -129,13 +129,14 @@ export default function ChatPage() {
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
if (hasStartedConversation) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined;
return {
label: promptPrefixSettings.switch_label || "前缀",
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
+45 -1
View File
@@ -15,6 +15,7 @@ import {
Play,
Plus,
RefreshCw,
TagsIcon,
Trash2,
} from "lucide-react";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -35,7 +36,11 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
@@ -283,7 +288,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const { data: tasks = [], isLoading } = useScheduledTasks();
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -299,6 +307,8 @@ export default function ScheduledTasksPage() {
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -466,6 +476,14 @@ export default function ScheduledTasksPage() {
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === "mine" && (
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
/>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -527,7 +545,23 @@ export default function ScheduledTasksPage() {
</div>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
{isOwner ? (
<>
<Button
@@ -934,6 +968,16 @@ export default function ScheduledTasksPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
targetType="scheduled_task"
targetId={tagPickerTask.task_id}
currentTags={tagPickerTask.tags ?? []}
/>
)}
</WorkspaceContainer>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { TagManagementPage } from "@/components/workspace/admin/tag-management-page";
export default function TagsPage() {
return <TagManagementPage />;
}
@@ -25,6 +25,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -183,6 +184,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
+14 -12
View File
@@ -163,16 +163,17 @@ 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.
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
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
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** - Context reduction when approaching token limits (optional, if enabled)
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
### Configuration System
@@ -213,9 +214,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags`); `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents; `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
@@ -38,6 +38,7 @@ from app.gateway.routers import (
skills,
suggestions,
system_settings,
tags,
thread_runs,
threads,
uploads,
@@ -595,6 +596,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Notifications API is mounted at /api/notifications
app.include_router(notifications.router)
# Tags API is mounted at /api/tags
app.include_router(tags.router)
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
app.include_router(thread_runs.router)
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
from deerflow.persistence.notifications import NotificationStore
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.persistence.thread_meta.base import ThreadMetaStore
@@ -117,6 +118,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.recommended_question_store = make_recommended_question_store(sf)
from deerflow.persistence.tags import make_tag_store
app.state.tag_store = make_tag_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -209,6 +214,14 @@ def get_notification_store(request: Request) -> NotificationStore:
return val
def get_tag_store(request: Request) -> TagStore:
"""Return the tag store."""
val = getattr(request.app.state, "tag_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Tag store not available")
return val
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -9,14 +9,15 @@ from typing import Any
from uuid import uuid4
import yaml
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store
from app.gateway.deps import get_agent_store, get_tag_store
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.paths import get_paths
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -42,6 +43,8 @@ class AgentResponse(BaseModel):
tool_groups: list[str] | None = None
skills: list[str] | None = None
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents."""
@@ -238,15 +241,30 @@ async def _get_editable_or_raise(
summary="List Agents",
description="List built-in agents, the current user's agents, and published agents.",
)
async def list_agents(request: Request) -> AgentsListResponse:
async def list_agents(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
tag_id: str | None = Query(default=None, description="Keep only agents carrying this tag"),
) -> AgentsListResponse:
_require_agents_api_enabled()
try:
store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store)
user_id = _current_user_id(request)
agents = await store.list_visible(user_id)
return AgentsListResponse(agents=[_record_to_response(a, include_soul=True) for a in agents])
responses = [_record_to_response(a, include_soul=True) for a in agents]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.id, [])
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
if tag_id:
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
return AgentsListResponse(agents=responses)
except HTTPException:
raise
except Exception as e:
@@ -485,6 +503,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
shutil.rmtree(agent_dir)
try:
await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
except HTTPException:
raise
@@ -10,11 +10,11 @@ from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_local_provider
from app.gateway.deps import get_current_user, get_local_provider, get_tag_store
from deerflow.runtime.scheduler import get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
@@ -174,11 +174,25 @@ async def get_scheduler_thread(request: Request) -> dict:
@router.get("")
async def list_scheduled_tasks(request: Request) -> list[dict]:
async def list_scheduled_tasks(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"),
tag_id: str | None = Query(default=None, description="Keep only tasks carrying this tag"),
) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
tasks = await service.list_tasks(user_id)
await _enrich_with_creator(tasks)
tag_store = get_tag_store(request)
assignments = await tag_store.list_assignments("scheduled_task", [str(t.get("task_id")) for t in tasks])
for task in tasks:
task["tags"] = assignments.get(str(task.get("task_id")), [])
if search:
needle = search.strip().lower()
tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()]
if tag_id:
tasks = [t for t in tasks if any(tg.get("id") == tag_id for tg in t.get("tags", []))]
return tasks
@@ -378,6 +392,10 @@ async def delete_scheduled_task(task_id: str, request: Request) -> dict:
deleted = await service.delete_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
try:
await get_tag_store(request).unassign_all("scheduled_task", task_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
return {"success": True}
@@ -5,15 +5,16 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Request, UploadFile
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store, get_config, get_skill_store
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills import Skill
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
@@ -95,6 +96,7 @@ class SkillResponse(BaseModel):
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
class SkillsListResponse(BaseModel):
@@ -208,6 +210,24 @@ async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[
return {record["name"]: record for record in records}
async def _attach_and_filter_tags(
tag_store: TagStore,
responses: list[SkillResponse],
search: str | None,
tag_id: str | None,
) -> list[SkillResponse]:
"""Bulk-attach tags to skill responses, then apply name/tag filters."""
assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.name, [])
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
if tag_id:
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
return responses
@router.get(
"/skills",
response_model=SkillsListResponse,
@@ -218,6 +238,9 @@ async def list_skills(
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
tag_id: str | None = Query(default=None, description="Keep only skills carrying this tag"),
) -> SkillsListResponse:
try:
user_id = _current_user_id(request)
@@ -234,6 +257,7 @@ async def list_skills(
if record is None:
continue
responses.append(_skill_to_response(skill, record))
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_id)
return SkillsListResponse(skills=responses)
except Exception as e:
logger.error(f"Failed to load skills: {e}", exc_info=True)
@@ -553,6 +577,11 @@ async def delete_custom_skill(
else:
await skill_store.delete(skill_name, user_id)
try:
await get_tag_store(request).unassign_all("skill", skill_name)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
# Strip references from every agent that pulled this skill in,
# and notify their owners (no-op for self).
await cascade_skill_unpublished_or_deleted(
@@ -0,0 +1,212 @@
"""Tag management API.
Tags are split into three types (``agent`` / ``skill`` / ``scheduled_task``).
A tag may only be applied to a resource of its own type. Tag CRUD and tag
assignment are admin-only; listing tags and reading a resource's tags is open
to everyone (the frontend needs them to render chips and search filters).
"""
from __future__ import annotations
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request, get_tag_store
from deerflow.persistence.tags import TAG_TYPES, TagStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tags", tags=["tags"])
# --------------------------------------------------------------------------- auth
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
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="Tag management is admin-only")
def _validate_tag_type(tag_type: str) -> str:
if tag_type not in TAG_TYPES:
raise HTTPException(status_code=422, detail=f"Invalid tag type '{tag_type}'. Allowed: {', '.join(TAG_TYPES)}")
return tag_type
# ------------------------------------------------------------------- schemas
class TagResponse(BaseModel):
id: str
name: str
tag_type: str
color: str = "gray"
description: str = ""
usage_count: int = 0
created_at: str | None = None
updated_at: str | None = None
class TagListResponse(BaseModel):
tags: list[TagResponse]
class TagCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=256)
tag_type: str = Field(..., description="One of: agent, skill, scheduled_task")
color: str = Field(default="gray", max_length=16)
description: str = Field(default="", max_length=1024)
class TagUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=256)
color: str | None = Field(default=None, max_length=16)
description: str | None = Field(default=None, max_length=1024)
class TagAssignmentRequest(BaseModel):
tag_ids: list[str] = Field(default_factory=list, description="Full set of tag ids to assign; replaces the existing set")
# ----------------------------------------------------------------- tag CRUD
@router.get("", response_model=TagListResponse, summary="List Tags")
async def list_tags(
tag_type: str | None = Query(default=None, description="Filter by tag type"),
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
if tag_type is not None:
_validate_tag_type(tag_type)
try:
records = await store.list_tags(tag_type)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except Exception as e:
logger.error("Failed to list tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list tags: {e}")
@router.post("", response_model=TagResponse, status_code=201, summary="Create Tag")
async def create_tag(
body: TagCreateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
await _require_admin(request)
_validate_tag_type(body.tag_type)
try:
record = await store.create_tag(
{
"id": uuid.uuid4().hex,
"name": body.name.strip(),
"tag_type": body.tag_type,
"color": body.color,
"description": body.description,
}
)
return TagResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to create tag: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to create tag: {e}")
@router.put("/{tag_id}", response_model=TagResponse, summary="Update Tag")
async def update_tag(
tag_id: str,
body: TagUpdateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
await _require_admin(request)
update_data = body.model_dump(exclude_unset=True)
if "name" in update_data and update_data["name"] is not None:
update_data["name"] = update_data["name"].strip()
try:
record = await store.update_tag(tag_id, update_data)
if record is None:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' not found")
return TagResponse(**record)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update tag: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update tag: {e}")
@router.delete("/{tag_id}", status_code=204, summary="Delete Tag")
async def delete_tag(
tag_id: str,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> None:
await _require_admin(request)
try:
deleted = await store.delete_tag(tag_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' not found")
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete tag: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete tag: {e}")
# --------------------------------------------------------------- assignments
@router.get(
"/assignments/{target_type}/{target_id}",
response_model=TagListResponse,
summary="Get Resource Tags",
)
async def get_resource_tags(
target_type: str,
target_id: str,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
_validate_tag_type(target_type)
try:
records = await store.get_target_tags(target_type, target_id)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except Exception as e:
logger.error("Failed to get resource tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get resource tags: {e}")
@router.put(
"/assignments/{target_type}/{target_id}",
response_model=TagListResponse,
summary="Set Resource Tags",
)
async def set_resource_tags(
target_type: str,
target_id: str,
body: TagAssignmentRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
await _require_admin(request)
_validate_tag_type(target_type)
try:
records = await store.set_target_tags(target_type, target_id, body.tag_ids)
return TagListResponse(tags=[TagResponse(**r) for r in records])
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to set resource tags: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to set resource tags: {e}")
@@ -100,12 +100,15 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
"""Return ``graph_input`` with the prefix prepended to its first HumanMessage.
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
The prefix is stored in ``additional_kwargs['prompt_prefix']`` rather than
concatenated into ``content``. This keeps the persisted/displayed message
clean — the user never sees the prefix — while ``PromptPrefixMiddleware``
injects it into the content of the request actually sent to the LLM.
Leaves the input unchanged when there are no messages or the first message
is not a human turn. Idempotent in the sense that it only touches the first
human message, so re-running this with the same prefix would double-prepend
— callers must gate it on first-turn detection.
is not a human turn.
"""
if not prefix:
return graph_input
@@ -115,14 +118,9 @@ def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str
new_messages = list(messages)
for idx, msg in enumerate(new_messages):
if isinstance(msg, HumanMessage):
existing = msg.content
if isinstance(existing, str):
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing}".strip("\n"))
elif isinstance(existing, list):
# Structured content (text blocks + media). Prepend a new text block.
new_messages[idx] = HumanMessage(content=[{"type": "text", "text": prefix}, *existing])
else:
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing!s}")
extra = dict(getattr(msg, "additional_kwargs", None) or {})
extra["prompt_prefix"] = prefix
new_messages[idx] = msg.model_copy(update={"additional_kwargs": extra})
break
return {**graph_input, "messages": new_messages}
@@ -380,7 +378,6 @@ async def start_run(
# Upsert thread metadata so the thread appears in /threads/search,
# even for threads that were never explicitly created via POST /threads
# (e.g. stateless runs).
thread_existed = False
try:
existing = await run_ctx.thread_store.get(thread_id)
if existing is None:
@@ -390,7 +387,6 @@ async def start_run(
metadata=body.metadata,
)
else:
thread_existed = True
await run_ctx.thread_store.update_status(thread_id, "running")
except Exception:
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
@@ -398,13 +394,15 @@ async def start_run(
agent_factory = resolve_agent_factory(body.assistant_id)
graph_input = normalize_input(body.input)
# Server-side safety net for the admin-configurable prompt prefix: when the
# caller opted in via context.prompt_prefix_enabled AND this is the very
# first turn of the thread, prepend the configured prefix to the human
# message. We deliberately skip already-running threads so the prefix is
# never inserted twice — the frontend toggle is a per-message hint, but
# this check is what makes the feature safe against client bugs / replays.
if not thread_existed and _prompt_prefix_opted_in(body):
# Admin-configurable prompt prefix: when the caller opted in via
# context.prompt_prefix_enabled, tag the human message with the configured
# prefix (stored in additional_kwargs, not concatenated into content, so the
# user never sees it — PromptPrefixMiddleware injects it for the LLM only).
# The frontend only sends the flag on the opening turn, so this naturally
# applies once. We intentionally do NOT gate on thread-row existence: the
# thread_meta row is frequently pre-created, which previously skipped the
# prefix for genuine first turns.
if _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
@@ -8,9 +8,10 @@ from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.memory.summarization_hook import memory_flush_hook
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
@@ -257,6 +258,10 @@ def _build_middlewares(
resolved_app_config = app_config or get_app_config()
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
# Inject the admin prompt prefix into the model request (kept out of the
# persisted/displayed message so the user never sees it).
middlewares.append(PromptPrefixMiddleware())
# Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
if summarization_middleware is not None:
@@ -304,10 +309,10 @@ def _build_middlewares(
if custom_middlewares:
middlewares.extend(custom_middlewares)
middlewares.append(SetupAgentApprovalMiddleware())
# ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware())
middlewares.append(SetupAgentApprovalMiddleware())
# ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware())
return middlewares
@@ -0,0 +1,78 @@
"""Middleware that injects the admin-configured prompt prefix at model-call time.
The prompt-prefix feature lets an admin prepend a fixed instruction to the
user's question before it reaches the LLM. To keep the prefix invisible in the
chat UI, the Gateway does **not** concatenate it into the persisted
``HumanMessage.content``; instead it tags the first human message with
``additional_kwargs["prompt_prefix"]`` (see ``app.gateway.services``).
This middleware reads that tag and prepends the prefix to the message content
*only in the request handed to the model*. The persisted state — and therefore
what the frontend renders — keeps the clean, prefix-free content.
"""
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import HumanMessage
PROMPT_PREFIX_KEY = "prompt_prefix"
class PromptPrefixMiddleware(AgentMiddleware[AgentState]):
"""Prepends ``additional_kwargs['prompt_prefix']`` to human messages for the LLM only.
The transformation is applied to the model request copy, never written back
to state, so it is naturally idempotent across the multiple model calls of a
single turn.
"""
@staticmethod
def _apply(messages: list) -> list | None:
"""Return a patched copy with prefixes prepended, or None if nothing to do."""
patched: list | None = None
for idx, msg in enumerate(messages):
if not isinstance(msg, HumanMessage):
continue
raw_prefix = (getattr(msg, "additional_kwargs", None) or {}).get(PROMPT_PREFIX_KEY)
prefix = str(raw_prefix).strip() if raw_prefix else ""
if not prefix:
continue
content = msg.content
if isinstance(content, str):
new_content: object = f"{prefix}\n\n{content}".strip("\n")
elif isinstance(content, list):
# Structured content (text blocks + media): prepend a text block.
new_content = [{"type": "text", "text": prefix}, *content]
else:
new_content = f"{prefix}\n\n{content!s}"
if patched is None:
patched = list(messages)
# model_copy preserves the message id so streaming dedup is unaffected.
patched[idx] = msg.model_copy(update={"content": new_content})
return patched
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
patched = self._apply(request.messages)
if patched is not None:
request = request.override(messages=patched)
return handler(request)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
patched = self._apply(request.messages)
if patched is not None:
request = request.override(messages=patched)
return await handler(request)
@@ -0,0 +1,60 @@
"""Create tags and tag_assignments tables.
Revision ID: 20260515_01
Revises: 20260514_02
Create Date: 2026-05-15
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260515_01"
down_revision = "20260514_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("tags"):
op.create_table(
"tags",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("tag_type", sa.String(32), nullable=False),
sa.Column("color", sa.String(16), nullable=False, server_default="gray"),
sa.Column("description", sa.String(1024), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("tag_type", "name", name="uq_tags_type_name"),
)
op.create_index("ix_tags_tag_type", "tags", ["tag_type"])
if not _has_table("tag_assignments"):
op.create_table(
"tag_assignments",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("tag_id", sa.String(64), nullable=False),
sa.Column("target_type", sa.String(32), nullable=False),
sa.Column("target_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("tag_id", "target_type", "target_id", name="uq_tag_assignments_tag_target"),
)
op.create_index("ix_tag_assignments_tag_id", "tag_assignments", ["tag_id"])
op.create_index("ix_tag_assignments_target_type", "tag_assignments", ["target_type"])
op.create_index("ix_tag_assignments_target_id", "tag_assignments", ["target_id"])
def downgrade() -> None:
if _has_table("tag_assignments"):
op.drop_table("tag_assignments")
if _has_table("tags"):
op.drop_table("tags")
@@ -30,6 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
SchedulerThreadRow,
)
from deerflow.persistence.skills.model import SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow
@@ -47,6 +48,8 @@ __all__ = [
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
"TagAssignmentRow",
"TagRow",
"ThreadMetaRow",
"UserRow",
]
@@ -0,0 +1,14 @@
"""Persistence layer for tags and tag-to-resource assignments."""
from deerflow.persistence.tags.base import TagStore
from deerflow.persistence.tags.memory import MemoryTagStore
from deerflow.persistence.tags.model import TAG_TYPES
from deerflow.persistence.tags.sql import TagRepository
__all__ = ["TagStore", "MemoryTagStore", "TagRepository", "TAG_TYPES", "make_tag_store"]
def make_tag_store(session_factory):
if session_factory is None:
return MemoryTagStore()
return TagRepository(session_factory)
@@ -0,0 +1,73 @@
"""Abstract persistence API for tags and tag-to-resource assignments."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class TagStore(ABC):
# ------------------------------------------------------------------ tags
@abstractmethod
async def list_tags(self, tag_type: str | None = None) -> list[dict[str, Any]]:
"""List tags, optionally filtered by ``tag_type``.
Each record includes a derived ``usage_count`` (number of assignments).
"""
raise NotImplementedError
@abstractmethod
async def get_tag(self, tag_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_tag(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create a tag. ``data`` must carry ``id``, ``name``, ``tag_type``.
Raises ``ValueError`` if ``(tag_type, name)`` is not unique.
"""
raise NotImplementedError
@abstractmethod
async def update_tag(self, tag_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update ``name`` / ``color`` / ``description``. ``tag_type`` is immutable.
Returns ``None`` if the tag does not exist. Raises ``ValueError`` on a
name collision within the type.
"""
raise NotImplementedError
@abstractmethod
async def delete_tag(self, tag_id: str) -> bool:
"""Delete a tag and cascade-remove all of its assignments."""
raise NotImplementedError
# ----------------------------------------------------------- assignments
@abstractmethod
async def get_target_tags(self, target_type: str, target_id: str) -> list[dict[str, Any]]:
"""Return the tags currently assigned to one resource."""
raise NotImplementedError
@abstractmethod
async def set_target_tags(self, target_type: str, target_id: str, tag_ids: list[str]) -> list[dict[str, Any]]:
"""Replace the full set of tags on a resource.
Every id in ``tag_ids`` must reference an existing tag whose
``tag_type`` equals ``target_type``; otherwise raises ``ValueError``.
"""
raise NotImplementedError
@abstractmethod
async def list_assignments(self, target_type: str, target_ids: list[str]) -> dict[str, list[dict[str, Any]]]:
"""Bulk-fetch tags for many resources at once (avoids N+1 on list pages).
Returns ``{target_id: [tag, ...]}`` for ids that have at least one tag.
"""
raise NotImplementedError
@abstractmethod
async def unassign_all(self, target_type: str, target_id: str) -> int:
"""Drop every tag assignment for a resource. Returns the count removed."""
raise NotImplementedError
@@ -0,0 +1,138 @@
"""In-memory ``TagStore`` implementation for tests."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.tags.base import TagStore
def _now() -> str:
return datetime.now(UTC).isoformat()
def _copy(record: dict[str, Any]) -> dict[str, Any]:
return copy.deepcopy(record)
class MemoryTagStore(TagStore):
def __init__(self) -> None:
self._tags: dict[str, dict[str, Any]] = {}
# assignments keyed by surrogate id
self._assignments: dict[str, dict[str, Any]] = {}
# ------------------------------------------------------------------ tags
def _usage_count(self, tag_id: str) -> int:
return sum(1 for a in self._assignments.values() if a["tag_id"] == tag_id)
def _with_count(self, record: dict[str, Any]) -> dict[str, Any]:
out = _copy(record)
out["usage_count"] = self._usage_count(record["id"])
return out
async def list_tags(self, tag_type: str | None = None) -> list[dict[str, Any]]:
rows = [r for r in self._tags.values() if tag_type is None or r["tag_type"] == tag_type]
rows.sort(key=lambda r: r["created_at"])
return [self._with_count(r) for r in rows]
async def get_tag(self, tag_id: str) -> dict[str, Any] | None:
record = self._tags.get(tag_id)
return self._with_count(record) if record is not None else None
async def create_tag(self, data: dict[str, Any]) -> dict[str, Any]:
tag_id = data["id"]
name = data["name"]
tag_type = data["tag_type"]
if any(r["tag_type"] == tag_type and r["name"] == name for r in self._tags.values()):
raise ValueError(f"Tag '{name}' already exists for type '{tag_type}'")
record = {
"id": tag_id,
"name": name,
"tag_type": tag_type,
"color": data.get("color") or "gray",
"description": data.get("description") or "",
"created_at": _now(),
"updated_at": _now(),
}
self._tags[tag_id] = record
return self._with_count(record)
async def update_tag(self, tag_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
record = self._tags.get(tag_id)
if record is None:
return None
new_name = data.get("name", record["name"])
if any(r["id"] != tag_id and r["tag_type"] == record["tag_type"] and r["name"] == new_name for r in self._tags.values()):
raise ValueError(f"Tag '{new_name}' already exists for type '{record['tag_type']}'")
if "name" in data:
record["name"] = data["name"]
if "color" in data:
record["color"] = data["color"]
if "description" in data:
record["description"] = data["description"]
record["updated_at"] = _now()
return self._with_count(record)
async def delete_tag(self, tag_id: str) -> bool:
if tag_id not in self._tags:
return False
del self._tags[tag_id]
for aid in [aid for aid, a in self._assignments.items() if a["tag_id"] == tag_id]:
del self._assignments[aid]
return True
# ----------------------------------------------------------- assignments
def _tags_for(self, target_type: str, target_id: str) -> list[dict[str, Any]]:
tag_ids = [a["tag_id"] for a in self._assignments.values() if a["target_type"] == target_type and a["target_id"] == target_id]
records = [self._tags[tid] for tid in tag_ids if tid in self._tags]
records.sort(key=lambda r: r["name"])
return [self._with_count(r) for r in records]
async def get_target_tags(self, target_type: str, target_id: str) -> list[dict[str, Any]]:
return self._tags_for(target_type, target_id)
async def set_target_tags(self, target_type: str, target_id: str, tag_ids: list[str]) -> list[dict[str, Any]]:
unique_ids = list(dict.fromkeys(tag_ids))
for tid in unique_ids:
tag = self._tags.get(tid)
if tag is None:
raise ValueError(f"Tag '{tid}' does not exist")
if tag["tag_type"] != target_type:
raise ValueError(f"Tag '{tid}' is type '{tag['tag_type']}', cannot tag a '{target_type}' resource")
# drop existing, then re-add
for aid in [aid for aid, a in self._assignments.items() if a["target_type"] == target_type and a["target_id"] == target_id]:
del self._assignments[aid]
for tid in unique_ids:
aid = f"{tid}:{target_type}:{target_id}"
self._assignments[aid] = {
"id": aid,
"tag_id": tid,
"target_type": target_type,
"target_id": target_id,
"created_at": _now(),
}
return self._tags_for(target_type, target_id)
async def list_assignments(self, target_type: str, target_ids: list[str]) -> dict[str, list[dict[str, Any]]]:
wanted = set(target_ids)
out: dict[str, list[dict[str, Any]]] = {}
for a in self._assignments.values():
if a["target_type"] != target_type or a["target_id"] not in wanted:
continue
tag = self._tags.get(a["tag_id"])
if tag is None:
continue
out.setdefault(a["target_id"], []).append(self._with_count(tag))
for tags in out.values():
tags.sort(key=lambda r: r["name"])
return out
async def unassign_all(self, target_type: str, target_id: str) -> int:
victims = [aid for aid, a in self._assignments.items() if a["target_type"] == target_type and a["target_id"] == target_id]
for aid in victims:
del self._assignments[aid]
return len(victims)
@@ -0,0 +1,47 @@
"""ORM models for tags and tag-to-resource assignments.
Two tables:
- ``tags`` — a tag belongs to exactly one ``tag_type`` (``agent`` / ``skill`` /
``scheduled_task``). Tag names are unique *within* a type.
- ``tag_assignments`` — many-to-many link between a tag and a target resource.
``target_type`` is denormalized from the tag so the table is queryable by
``(target_type, target_id)`` without a join. The API layer enforces the
invariant ``target_type == tag.tag_type``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
# Allowed tag/target types. Kept here so persistence + API share one source.
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task")
class TagRow(Base):
__tablename__ = "tags"
__table_args__ = (UniqueConstraint("tag_type", "name", name="uq_tags_type_name"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256), nullable=False)
tag_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
color: Mapped[str] = mapped_column(String(16), nullable=False, default="gray")
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class TagAssignmentRow(Base):
__tablename__ = "tag_assignments"
__table_args__ = (UniqueConstraint("tag_id", "target_type", "target_id", name="uq_tag_assignments_tag_target"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
tag_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
target_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,187 @@
"""SQLAlchemy-backed tag + tag-assignment repository."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.tags.base import TagStore
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
def _tag_to_dict(row: TagRow, usage_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["usage_count"] = usage_count
return data
class TagRepository(TagStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
async def _usage_counts(session: AsyncSession, tag_ids: list[str]) -> dict[str, int]:
if not tag_ids:
return {}
stmt = select(TagAssignmentRow.tag_id, func.count()).where(TagAssignmentRow.tag_id.in_(tag_ids)).group_by(TagAssignmentRow.tag_id)
result = await session.execute(stmt)
return {tag_id: count for tag_id, count in result.all()}
# ------------------------------------------------------------------ tags
async def list_tags(self, tag_type: str | None = None) -> list[dict[str, Any]]:
stmt = select(TagRow)
if tag_type is not None:
stmt = stmt.where(TagRow.tag_type == tag_type)
stmt = stmt.order_by(TagRow.created_at.asc())
async with self._sf() as session:
rows = list((await session.execute(stmt)).scalars())
counts = await self._usage_counts(session, [r.id for r in rows])
return [_tag_to_dict(r, counts.get(r.id, 0)) for r in rows]
async def get_tag(self, tag_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(TagRow, tag_id)
if row is None:
return None
counts = await self._usage_counts(session, [tag_id])
return _tag_to_dict(row, counts.get(tag_id, 0))
async def create_tag(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = TagRow(
id=data["id"],
name=data["name"],
tag_type=data["tag_type"],
color=data.get("color") or "gray",
description=data.get("description") or "",
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError(f"Tag '{data['name']}' already exists for type '{data['tag_type']}'") from exc
await session.refresh(row)
return _tag_to_dict(row, 0)
async def update_tag(self, tag_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(TagRow, tag_id)
if row is None:
return None
if "name" in data:
row.name = data["name"]
if "color" in data:
row.color = data["color"]
if "description" in data:
row.description = data["description"]
row.updated_at = datetime.now(UTC)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError(f"Tag '{data.get('name')}' already exists for type '{row.tag_type}'") from exc
await session.refresh(row)
counts = await self._usage_counts(session, [tag_id])
return _tag_to_dict(row, counts.get(tag_id, 0))
async def delete_tag(self, tag_id: str) -> bool:
async with self._sf() as session:
row = await session.get(TagRow, tag_id)
if row is None:
return False
await session.execute(delete(TagAssignmentRow).where(TagAssignmentRow.tag_id == tag_id))
await session.delete(row)
await session.commit()
return True
# ----------------------------------------------------------- assignments
async def _tags_for(self, session: AsyncSession, target_type: str, target_id: str) -> list[dict[str, Any]]:
stmt = (
select(TagRow)
.join(TagAssignmentRow, TagAssignmentRow.tag_id == TagRow.id)
.where(TagAssignmentRow.target_type == target_type, TagAssignmentRow.target_id == target_id)
.order_by(TagRow.name.asc())
)
rows = list((await session.execute(stmt)).scalars())
counts = await self._usage_counts(session, [r.id for r in rows])
return [_tag_to_dict(r, counts.get(r.id, 0)) for r in rows]
async def get_target_tags(self, target_type: str, target_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
return await self._tags_for(session, target_type, target_id)
async def set_target_tags(self, target_type: str, target_id: str, tag_ids: list[str]) -> list[dict[str, Any]]:
unique_ids = list(dict.fromkeys(tag_ids))
async with self._sf() as session:
if unique_ids:
found = list((await session.execute(select(TagRow).where(TagRow.id.in_(unique_ids)))).scalars())
by_id = {r.id: r for r in found}
for tid in unique_ids:
tag = by_id.get(tid)
if tag is None:
raise ValueError(f"Tag '{tid}' does not exist")
if tag.tag_type != target_type:
raise ValueError(f"Tag '{tid}' is type '{tag.tag_type}', cannot tag a '{target_type}' resource")
await session.execute(
delete(TagAssignmentRow).where(
TagAssignmentRow.target_type == target_type,
TagAssignmentRow.target_id == target_id,
)
)
now = datetime.now(UTC)
for tid in unique_ids:
session.add(
TagAssignmentRow(
id=uuid.uuid4().hex,
tag_id=tid,
target_type=target_type,
target_id=target_id,
created_at=now,
)
)
await session.commit()
return await self._tags_for(session, target_type, target_id)
async def list_assignments(self, target_type: str, target_ids: list[str]) -> dict[str, list[dict[str, Any]]]:
if not target_ids:
return {}
stmt = (
select(TagAssignmentRow.target_id, TagRow)
.join(TagRow, TagAssignmentRow.tag_id == TagRow.id)
.where(TagAssignmentRow.target_type == target_type, TagAssignmentRow.target_id.in_(target_ids))
.order_by(TagRow.name.asc())
)
async with self._sf() as session:
rows = (await session.execute(stmt)).all()
tag_rows = [tag for _, tag in rows]
counts = await self._usage_counts(session, [t.id for t in tag_rows])
out: dict[str, list[dict[str, Any]]] = {}
for target_id, tag in rows:
out.setdefault(target_id, []).append(_tag_to_dict(tag, counts.get(tag.id, 0)))
return out
async def unassign_all(self, target_type: str, target_id: str) -> int:
async with self._sf() as session:
result = await session.execute(
delete(TagAssignmentRow).where(
TagAssignmentRow.target_type == target_type,
TagAssignmentRow.target_id == target_id,
)
)
await session.commit()
return result.rowcount or 0
@@ -51,25 +51,27 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度"
def test_prepend_prefix_on_string_content() -> None:
def test_prepend_prefix_tags_first_human_without_touching_content() -> None:
graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]}
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
msg = out["messages"][0]
assert isinstance(msg, HumanMessage)
assert msg.content == "你需要站在中方角度\n\n评价一下中美贸易"
# Content stays clean so the frontend never shows the prefix.
assert msg.content == "评价一下中美贸易"
# The prefix rides along in additional_kwargs for the middleware to inject.
assert msg.additional_kwargs["prompt_prefix"] == "你需要站在中方角度"
def test_prepend_prefix_on_block_content() -> None:
def test_prepend_prefix_preserves_block_content() -> None:
graph_input = {
"messages": [
HumanMessage(content=[{"type": "text", "text": "评价一下中美贸易"}])
]
}
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
blocks = out["messages"][0].content
assert isinstance(blocks, list)
assert blocks[0] == {"type": "text", "text": "你需要站在中方角度"}
assert blocks[1] == {"type": "text", "text": "评价一下中美贸易"}
msg = out["messages"][0]
assert msg.content == [{"type": "text", "text": "评价一下中美贸易"}]
assert msg.additional_kwargs["prompt_prefix"] == "你需要站在中方角度"
def test_prepend_prefix_only_touches_first_human() -> None:
@@ -82,7 +84,9 @@ def test_prepend_prefix_only_touches_first_human() -> None:
}
out = _prepend_prompt_prefix(graph_input, "PREFIX")
# first human is index 1; index 2 must remain untouched
assert out["messages"][1].content == "PREFIX\n\nq1"
assert out["messages"][1].additional_kwargs.get("prompt_prefix") == "PREFIX"
assert out["messages"][1].content == "q1"
assert out["messages"][2].additional_kwargs.get("prompt_prefix") is None
assert out["messages"][2].content == "q2"
@@ -91,6 +95,43 @@ def test_prepend_prefix_no_messages_is_noop() -> None:
assert _prepend_prompt_prefix({}, "X") == {}
def test_prompt_prefix_middleware_injects_prefix_for_model_only() -> None:
"""The middleware prepends the prefix to the request copy, not to state."""
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
mw = PromptPrefixMiddleware()
tagged = HumanMessage(
content="评价一下中美贸易",
additional_kwargs={"prompt_prefix": "你需要站在中方角度"},
)
patched = PromptPrefixMiddleware._apply([tagged])
assert patched is not None
assert patched[0].content == "你需要站在中方角度\n\n评价一下中美贸易"
# The original message in state is untouched.
assert tagged.content == "评价一下中美贸易"
assert mw is not None
def test_prompt_prefix_middleware_noop_without_tag() -> None:
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
plain = HumanMessage(content="just a question")
assert PromptPrefixMiddleware._apply([plain]) is None
def test_prompt_prefix_middleware_block_content() -> None:
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
tagged = HumanMessage(
content=[{"type": "text", "text": "评价一下中美贸易"}],
additional_kwargs={"prompt_prefix": "你需要站在中方角度"},
)
patched = PromptPrefixMiddleware._apply([tagged])
assert patched is not None
assert patched[0].content[0] == {"type": "text", "text": "你需要站在中方角度"}
assert patched[0].content[1] == {"type": "text", "text": "评价一下中美贸易"}
def test_opted_in_via_context() -> None:
body = SimpleNamespace(context={"prompt_prefix_enabled": True}, metadata=None)
assert _prompt_prefix_opted_in(body) is True