feat(i18n): 全局将「专家」文案改为「智能体」
统一智能体功能的 UI 文案:侧边栏、智能体页、新建/保存流程、 审批卡片、登录提示及后端审批消息中的「专家」均改为「智能体」。 保留 prompt 人设、QA 子应用等非该功能语义的「专家」表述。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
a5c76591ac
48 files changed
+2190
-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,
|
EyeOffIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
|
TagsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||||
const [editName, setEditName] = useState(agent.name);
|
const [editName, setEditName] = useState(agent.name);
|
||||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||||
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</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 && (
|
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
||||||
<CardContent className="pt-0 pb-3">
|
<CardContent className="pt-0 pb-3">
|
||||||
<div className="flex flex-wrap gap-1">
|
<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" />
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{t.agents.chat}
|
{t.agents.chat}
|
||||||
</Button>
|
</Button>
|
||||||
{(canEdit || canDelete || isOwned) && (
|
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||||
<div className="flex gap-1">
|
<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 && (
|
{isOwned && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
import { useAgents } from "@/core/agents";
|
import { useAgents } from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
|
|||||||
|
|
||||||
export function AgentGallery() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { agents, isLoading } = useAgents();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||||
|
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 storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
@@ -83,10 +87,19 @@ export function AgentGallery() {
|
|||||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||||
className="flex flex-1 flex-col overflow-hidden"
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<TabsList className="mx-6 mt-4 w-auto self-start">
|
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<TabsList className="w-auto self-start">
|
||||||
<TabsTrigger value="all">广场</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="all">广场</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">
|
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PinIcon,
|
PinIcon,
|
||||||
PinOffIcon,
|
PinOffIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
|
TagsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -46,6 +47,9 @@ import {
|
|||||||
ItemDescription,
|
ItemDescription,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||||
@@ -67,36 +71,27 @@ import { SkillStateSheet } from "./skill-state-sheet";
|
|||||||
|
|
||||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { skills, isLoading, error } = useSkills();
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.skills.title}
|
title={t.settings.skills.title}
|
||||||
description={t.settings.skills.description}
|
description={t.settings.skills.description}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<SkillSettingsList onClose={onClose} />
|
||||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
|
||||||
) : error ? (
|
|
||||||
<div>Error: {error.message}</div>
|
|
||||||
) : (
|
|
||||||
<SkillSettingsList skills={skills} onClose={onClose} />
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkillSettingsList({
|
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||||
skills,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
skills: Skill[];
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("mine");
|
const [filter, setFilter] = useState<string>("mine");
|
||||||
const [installOpen, setInstallOpen] = useState(false);
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||||
const [archiveSheetOpen, setArchiveSheetOpen] = 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 storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
@@ -210,10 +205,22 @@ function SkillSettingsList({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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} />
|
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||||
)}
|
)}
|
||||||
{filteredSkills.length > 0 &&
|
{!isLoading && !error &&
|
||||||
|
filteredSkills.length > 0 &&
|
||||||
filteredSkills.map((skill) => (
|
filteredSkills.map((skill) => (
|
||||||
<SkillRow
|
<SkillRow
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
@@ -257,6 +264,7 @@ function SkillRow({
|
|||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
@@ -395,8 +403,22 @@ function SkillRow({
|
|||||||
: t.settings.skills.useCountInlineZero}
|
: t.settings.skills.useCountInlineZero}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(skill.tags?.length ?? 0) > 0 && (
|
||||||
|
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||||
|
)}
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<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 && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -476,6 +498,15 @@ function SkillRow({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{isAdmin && (
|
||||||
|
<TagPickerDialog
|
||||||
|
open={tagPickerOpen}
|
||||||
|
onOpenChange={setTagPickerOpen}
|
||||||
|
targetType="skill"
|
||||||
|
targetId={skill.name}
|
||||||
|
currentTags={skill.tags ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SkillDetailDialog
|
<SkillDetailDialog
|
||||||
skill={skill}
|
skill={skill}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MessageSquarePlusIcon,
|
MessageSquarePlusIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
TagsIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -148,6 +149,14 @@ export function WorkspaceNavMenu() {
|
|||||||
大模型调用统计
|
大模型调用统计
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||||
|
>
|
||||||
|
<TagsIcon />
|
||||||
|
{t.adminTags.title}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAgents(): Promise<Agent[]> {
|
export interface ListAgentsParams {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
|
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}`);
|
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||||
const data = (await res.json()) as { agents: Agent[] };
|
const data = (await res.json()) as { agents: Agent[] };
|
||||||
return data.agents;
|
return data.agents;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAgent,
|
createAgent,
|
||||||
@@ -9,10 +14,13 @@ import {
|
|||||||
} from "./api";
|
} from "./api";
|
||||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["agents"],
|
queryKey: ["agents", search, tagId],
|
||||||
queryFn: () => listAgents(),
|
queryFn: () => listAgents({ search, tagId }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
return { agents: data ?? [], isLoading, error };
|
return { agents: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Tag } from "@/core/tags";
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,6 +13,7 @@ export interface Agent {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
|
tags?: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAgentRequest {
|
export interface CreateAgentRequest {
|
||||||
|
|||||||
@@ -625,6 +625,40 @@ export const enUS: Translations = {
|
|||||||
empty: "No users found",
|
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: {
|
notifications: {
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
bellAria: "Open notifications",
|
bellAria: "Open notifications",
|
||||||
|
|||||||
@@ -541,6 +541,43 @@ export interface Translations {
|
|||||||
empty: string;
|
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)
|
// In-app notification inbox (bell in the workspace sidebar)
|
||||||
notifications: NotificationsTranslations;
|
notifications: NotificationsTranslations;
|
||||||
}
|
}
|
||||||
@@ -594,6 +594,40 @@ export const zhCN: Translations = {
|
|||||||
empty: "暂无用户",
|
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: {
|
notifications: {
|
||||||
title: "通知",
|
title: "通知",
|
||||||
bellAria: "打开通知",
|
bellAria: "打开通知",
|
||||||
|
|||||||
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
|
|||||||
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
|
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScheduledTasks() {
|
export interface ListScheduledTasksParams {
|
||||||
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
|
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: {
|
export async function createScheduledTask(data: {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createScheduledTask,
|
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({
|
return useQuery({
|
||||||
queryKey: ["scheduled-tasks"],
|
queryKey: ["scheduled-tasks", "list", search, tagId],
|
||||||
queryFn: listScheduledTasks,
|
queryFn: () => listScheduledTasks({ search, tagId }),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Tag } from "@/core/tags";
|
||||||
|
|
||||||
export type ScheduledTaskRole = "owner" | "subscriber";
|
export type ScheduledTaskRole = "owner" | "subscriber";
|
||||||
|
|
||||||
export type ScheduledTaskSubscription = {
|
export type ScheduledTaskSubscription = {
|
||||||
@@ -29,6 +31,7 @@ export type ScheduledTask = {
|
|||||||
_subscription?: ScheduledTaskSubscription | null;
|
_subscription?: ScheduledTaskSubscription | null;
|
||||||
creator_user_id?: string | null;
|
creator_user_id?: string | null;
|
||||||
creator_email?: string | null;
|
creator_email?: string | null;
|
||||||
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublishedScheduledTask = {
|
export type PublishedScheduledTask = {
|
||||||
|
|||||||
@@ -3,8 +3,19 @@ import { apiFetch } from "@/core/api";
|
|||||||
|
|
||||||
import type { Skill, SkillState } from "./type";
|
import type { Skill, SkillState } from "./type";
|
||||||
|
|
||||||
export async function loadSkills() {
|
export interface ListSkillsParams {
|
||||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
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();
|
const json = await skills.json();
|
||||||
return json.skills as Skill[];
|
return json.skills as Skill[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
@@ -14,10 +19,13 @@ import type { SkillState } from "./type";
|
|||||||
|
|
||||||
import { loadSkills } from ".";
|
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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["skills"],
|
queryKey: ["skills", search, tagId],
|
||||||
queryFn: () => loadSkills(),
|
queryFn: () => loadSkills({ search, tagId }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
return { skills: data ?? [], isLoading, error };
|
return { skills: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Tag } from "@/core/tags";
|
||||||
|
|
||||||
export type SkillState = "active" | "stale" | "archived";
|
export type SkillState = "active" | "stale" | "archived";
|
||||||
export type SkillSource = "agent" | "upload";
|
export type SkillSource = "agent" | "upload";
|
||||||
|
|
||||||
@@ -23,4 +25,6 @@ export interface Skill {
|
|||||||
use_count?: number;
|
use_count?: number;
|
||||||
view_count?: number;
|
view_count?: number;
|
||||||
patch_count?: number;
|
patch_count?: number;
|
||||||
|
// Tags assigned to this skill (admin-managed).
|
||||||
|
tags?: Tag[];
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./api";
|
||||||
|
export * from "./hooks";
|
||||||
|
export * from "./types";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -93,7 +93,9 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
|
// Only the opening turn of a brand-new thread may carry the prefix.
|
||||||
const shouldApplyPrefix =
|
const shouldApplyPrefix =
|
||||||
|
isNewThread &&
|
||||||
!hasStartedConversation &&
|
!hasStartedConversation &&
|
||||||
promptPrefixOn &&
|
promptPrefixOn &&
|
||||||
promptPrefixSettings?.enabled === true;
|
promptPrefixSettings?.enabled === true;
|
||||||
@@ -101,19 +103,17 @@ export default function ChatPage() {
|
|||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||||
);
|
);
|
||||||
const messageToSend = shouldApplyPrefix
|
// The prefix is intentionally NOT concatenated into the message text:
|
||||||
? {
|
// doing so would make the user see it. We only flag the request via
|
||||||
...message,
|
// context; the backend tags the message and PromptPrefixMiddleware
|
||||||
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
|
// injects the prefix into the LLM request only.
|
||||||
}
|
|
||||||
: message;
|
|
||||||
void sendMessage(
|
void sendMessage(
|
||||||
threadId,
|
threadId,
|
||||||
messageToSend,
|
message,
|
||||||
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
|
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
|
[sendMessage, threadId, isNewThread, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClarificationSubmit = useCallback(
|
const handleClarificationSubmit = useCallback(
|
||||||
@@ -129,13 +129,14 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const promptPrefixProp = useMemo(() => {
|
const promptPrefixProp = useMemo(() => {
|
||||||
if (!promptPrefixSettings?.enabled) return undefined;
|
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 {
|
return {
|
||||||
label: promptPrefixSettings.switch_label || "前缀",
|
label: promptPrefixSettings.switch_label || "前缀",
|
||||||
on: promptPrefixOn,
|
on: promptPrefixOn,
|
||||||
onToggle: () => setPromptPrefixOn((v) => !v),
|
onToggle: () => setPromptPrefixOn((v) => !v),
|
||||||
};
|
};
|
||||||
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
|
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
|
||||||
|
|
||||||
const messageListPaddingBottom = showFollowups
|
const messageListPaddingBottom = showFollowups
|
||||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
TagsIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
@@ -35,7 +36,11 @@ import {
|
|||||||
WorkspaceContainer,
|
WorkspaceContainer,
|
||||||
WorkspaceHeader,
|
WorkspaceHeader,
|
||||||
} from "@/components/workspace/workspace-container";
|
} 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 { useAgents } from "@/core/agents";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import {
|
import {
|
||||||
downloadScheduledTaskRun,
|
downloadScheduledTaskRun,
|
||||||
type PublishedScheduledTask,
|
type PublishedScheduledTask,
|
||||||
@@ -283,7 +288,10 @@ const emptyForm = {
|
|||||||
|
|
||||||
export default function ScheduledTasksPage() {
|
export default function ScheduledTasksPage() {
|
||||||
const navigate = useNavigate();
|
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 { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||||
const { agents } = useAgents();
|
const { agents } = useAgents();
|
||||||
const actions = useScheduledTaskActions();
|
const actions = useScheduledTaskActions();
|
||||||
@@ -299,6 +307,8 @@ export default function ScheduledTasksPage() {
|
|||||||
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
||||||
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
||||||
const [subscribeNotify, setSubscribeNotify] = useState(false);
|
const [subscribeNotify, setSubscribeNotify] = useState(false);
|
||||||
|
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
|
||||||
|
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
|
||||||
|
|
||||||
const sortedTasks = useMemo(
|
const sortedTasks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -466,6 +476,14 @@ export default function ScheduledTasksPage() {
|
|||||||
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
|
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
{activeTab === "mine" && (
|
||||||
|
<TagSearchBar
|
||||||
|
targetType="scheduled_task"
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
tagId={tagId}
|
||||||
|
onTagIdChange={setTagId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="min-h-0 flex-1 p-0">
|
<CardContent className="min-h-0 flex-1 p-0">
|
||||||
<ScrollArea className="size-full">
|
<ScrollArea className="size-full">
|
||||||
@@ -527,7 +545,23 @@ export default function ScheduledTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(task.tags?.length ?? 0) > 0 && (
|
||||||
|
<TagChipList tags={task.tags!} className="mt-3" />
|
||||||
|
)}
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<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 ? (
|
{isOwner ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -934,6 +968,16 @@ export default function ScheduledTasksPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{isAdmin && tagPickerTask && (
|
||||||
|
<TagPickerDialog
|
||||||
|
open={!!tagPickerTask}
|
||||||
|
onOpenChange={(open) => !open && setTagPickerTask(null)}
|
||||||
|
targetType="scheduled_task"
|
||||||
|
targetId={tagPickerTask.task_id}
|
||||||
|
currentTags={tagPickerTask.tags ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</WorkspaceContainer>
|
</WorkspaceContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
import SkillsPage from "./SkillsPage";
|
import SkillsPage from "./SkillsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
|
import TagsPage from "./TagsPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
|
||||||
export function WorkspaceLayout({
|
export function WorkspaceLayout({
|
||||||
@@ -183,6 +184,18 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/tags"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<TagsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/recommended-questions"
|
path="settings/recommended-questions"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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.
|
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
||||||
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
||||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
||||||
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
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. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||||
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
||||||
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||||
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||||
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
|
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
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
|
### 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 |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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,
|
skills,
|
||||||
suggestions,
|
suggestions,
|
||||||
system_settings,
|
system_settings,
|
||||||
|
tags,
|
||||||
thread_runs,
|
thread_runs,
|
||||||
threads,
|
threads,
|
||||||
uploads,
|
uploads,
|
||||||
@@ -595,6 +596,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Notifications API is mounted at /api/notifications
|
# Notifications API is mounted at /api/notifications
|
||||||
app.include_router(notifications.router)
|
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)
|
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||||
app.include_router(thread_runs.router)
|
app.include_router(thread_runs.router)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
|||||||
from deerflow.persistence.notifications import NotificationStore
|
from deerflow.persistence.notifications import NotificationStore
|
||||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||||
from deerflow.persistence.skills import SkillStore
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
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)
|
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 event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
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
|
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:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
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_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.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -42,6 +43,8 @@ class AgentResponse(BaseModel):
|
|||||||
tool_groups: list[str] | None = None
|
tool_groups: list[str] | None = None
|
||||||
skills: 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):
|
class AgentsListResponse(BaseModel):
|
||||||
"""Response model for listing all visible agents."""
|
"""Response model for listing all visible agents."""
|
||||||
@@ -238,15 +241,30 @@ async def _get_editable_or_raise(
|
|||||||
summary="List Agents",
|
summary="List Agents",
|
||||||
description="List built-in agents, the current user's agents, and published 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()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
|
tag_store: TagStore = get_tag_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
agents = await store.list_visible(user_id)
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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)
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
if agent_dir.exists():
|
if agent_dir.exists():
|
||||||
shutil.rmtree(agent_dir)
|
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)
|
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
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 fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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
|
from deerflow.runtime.scheduler import get_scheduled_task_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||||
@@ -174,11 +174,25 @@ async def get_scheduler_thread(request: Request) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
user_id = await _user_id(request)
|
||||||
tasks = await service.list_tasks(user_id)
|
tasks = await service.list_tasks(user_id)
|
||||||
await _enrich_with_creator(tasks)
|
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
|
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)
|
deleted = await service.delete_task(user_id, task_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
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}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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 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 app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||||
from deerflow.persistence.skills import SkillStore
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.skills import Skill
|
from deerflow.skills import Skill
|
||||||
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
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)")
|
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")
|
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)")
|
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):
|
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}
|
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(
|
@router.get(
|
||||||
"/skills",
|
"/skills",
|
||||||
response_model=SkillsListResponse,
|
response_model=SkillsListResponse,
|
||||||
@@ -218,6 +238,9 @@ async def list_skills(
|
|||||||
request: Request,
|
request: Request,
|
||||||
config: AppConfig = Depends(get_config),
|
config: AppConfig = Depends(get_config),
|
||||||
skill_store: SkillStore = Depends(get_skill_store),
|
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:
|
) -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
@@ -234,6 +257,7 @@ async def list_skills(
|
|||||||
if record is None:
|
if record is None:
|
||||||
continue
|
continue
|
||||||
responses.append(_skill_to_response(skill, record))
|
responses.append(_skill_to_response(skill, record))
|
||||||
|
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_id)
|
||||||
return SkillsListResponse(skills=responses)
|
return SkillsListResponse(skills=responses)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||||
@@ -553,6 +577,11 @@ async def delete_custom_skill(
|
|||||||
else:
|
else:
|
||||||
await skill_store.delete(skill_name, user_id)
|
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,
|
# Strip references from every agent that pulled this skill in,
|
||||||
# and notify their owners (no-op for self).
|
# and notify their owners (no-op for self).
|
||||||
await cascade_skill_unpublished_or_deleted(
|
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]:
|
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
|
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
|
is not a human turn.
|
||||||
human message, so re-running this with the same prefix would double-prepend
|
|
||||||
— callers must gate it on first-turn detection.
|
|
||||||
"""
|
"""
|
||||||
if not prefix:
|
if not prefix:
|
||||||
return graph_input
|
return graph_input
|
||||||
@@ -115,14 +118,9 @@ def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str
|
|||||||
new_messages = list(messages)
|
new_messages = list(messages)
|
||||||
for idx, msg in enumerate(new_messages):
|
for idx, msg in enumerate(new_messages):
|
||||||
if isinstance(msg, HumanMessage):
|
if isinstance(msg, HumanMessage):
|
||||||
existing = msg.content
|
extra = dict(getattr(msg, "additional_kwargs", None) or {})
|
||||||
if isinstance(existing, str):
|
extra["prompt_prefix"] = prefix
|
||||||
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing}".strip("\n"))
|
new_messages[idx] = msg.model_copy(update={"additional_kwargs": extra})
|
||||||
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}")
|
|
||||||
break
|
break
|
||||||
return {**graph_input, "messages": new_messages}
|
return {**graph_input, "messages": new_messages}
|
||||||
|
|
||||||
@@ -380,7 +378,6 @@ async def start_run(
|
|||||||
# Upsert thread metadata so the thread appears in /threads/search,
|
# Upsert thread metadata so the thread appears in /threads/search,
|
||||||
# even for threads that were never explicitly created via POST /threads
|
# even for threads that were never explicitly created via POST /threads
|
||||||
# (e.g. stateless runs).
|
# (e.g. stateless runs).
|
||||||
thread_existed = False
|
|
||||||
try:
|
try:
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
existing = await run_ctx.thread_store.get(thread_id)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
@@ -390,7 +387,6 @@ async def start_run(
|
|||||||
metadata=body.metadata,
|
metadata=body.metadata,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
thread_existed = True
|
|
||||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
await run_ctx.thread_store.update_status(thread_id, "running")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
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)
|
agent_factory = resolve_agent_factory(body.assistant_id)
|
||||||
graph_input = normalize_input(body.input)
|
graph_input = normalize_input(body.input)
|
||||||
|
|
||||||
# Server-side safety net for the admin-configurable prompt prefix: when the
|
# Admin-configurable prompt prefix: when the caller opted in via
|
||||||
# caller opted in via context.prompt_prefix_enabled AND this is the very
|
# context.prompt_prefix_enabled, tag the human message with the configured
|
||||||
# first turn of the thread, prepend the configured prefix to the human
|
# prefix (stored in additional_kwargs, not concatenated into content, so the
|
||||||
# message. We deliberately skip already-running threads so the prefix is
|
# user never sees it — PromptPrefixMiddleware injects it for the LLM only).
|
||||||
# never inserted twice — the frontend toggle is a per-message hint, but
|
# The frontend only sends the flag on the opening turn, so this naturally
|
||||||
# this check is what makes the feature safe against client bugs / replays.
|
# applies once. We intentionally do NOT gate on thread-row existence: the
|
||||||
if not thread_existed and _prompt_prefix_opted_in(body):
|
# 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
|
prefix_settings = load_system_settings().prompt_prefix
|
||||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||||
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
||||||
|
|||||||
+12
-7
@@ -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.memory.summarization_hook import memory_flush_hook
|
||||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
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.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||||
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||||||
@@ -257,6 +258,10 @@ def _build_middlewares(
|
|||||||
resolved_app_config = app_config or get_app_config()
|
resolved_app_config = app_config or get_app_config()
|
||||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
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
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||||
if summarization_middleware is not None:
|
if summarization_middleware is not None:
|
||||||
@@ -304,10 +309,10 @@ def _build_middlewares(
|
|||||||
if custom_middlewares:
|
if custom_middlewares:
|
||||||
middlewares.extend(custom_middlewares)
|
middlewares.extend(custom_middlewares)
|
||||||
|
|
||||||
middlewares.append(SetupAgentApprovalMiddleware())
|
middlewares.append(SetupAgentApprovalMiddleware())
|
||||||
|
|
||||||
# ClarificationMiddleware should always be last
|
# ClarificationMiddleware should always be last
|
||||||
middlewares.append(ClarificationMiddleware())
|
middlewares.append(ClarificationMiddleware())
|
||||||
return middlewares
|
return middlewares
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+78
@@ -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)
|
||||||
+60
@@ -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")
|
||||||
+3
@@ -30,6 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
SchedulerThreadRow,
|
SchedulerThreadRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.skills.model import SkillRow
|
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.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ __all__ = [
|
|||||||
"ScheduledTaskSubscriptionRow",
|
"ScheduledTaskSubscriptionRow",
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"SkillRow",
|
"SkillRow",
|
||||||
|
"TagAssignmentRow",
|
||||||
|
"TagRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"UserRow",
|
"UserRow",
|
||||||
]
|
]
|
||||||
+14
@@ -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
|
||||||
+138
@@ -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 == "你需要站在中方角度"
|
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="评价一下中美贸易")]}
|
graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]}
|
||||||
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
|
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
|
||||||
msg = out["messages"][0]
|
msg = out["messages"][0]
|
||||||
assert isinstance(msg, HumanMessage)
|
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 = {
|
graph_input = {
|
||||||
"messages": [
|
"messages": [
|
||||||
HumanMessage(content=[{"type": "text", "text": "评价一下中美贸易"}])
|
HumanMessage(content=[{"type": "text", "text": "评价一下中美贸易"}])
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
|
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
|
||||||
blocks = out["messages"][0].content
|
msg = out["messages"][0]
|
||||||
assert isinstance(blocks, list)
|
assert msg.content == [{"type": "text", "text": "评价一下中美贸易"}]
|
||||||
assert blocks[0] == {"type": "text", "text": "你需要站在中方角度"}
|
assert msg.additional_kwargs["prompt_prefix"] == "你需要站在中方角度"
|
||||||
assert blocks[1] == {"type": "text", "text": "评价一下中美贸易"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepend_prefix_only_touches_first_human() -> None:
|
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")
|
out = _prepend_prompt_prefix(graph_input, "PREFIX")
|
||||||
# first human is index 1; index 2 must remain untouched
|
# 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"
|
assert out["messages"][2].content == "q2"
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +95,43 @@ def test_prepend_prefix_no_messages_is_noop() -> None:
|
|||||||
assert _prepend_prompt_prefix({}, "X") == {}
|
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:
|
def test_opted_in_via_context() -> None:
|
||||||
body = SimpleNamespace(context={"prompt_prefix_enabled": True}, metadata=None)
|
body = SimpleNamespace(context={"prompt_prefix_enabled": True}, metadata=None)
|
||||||
assert _prompt_prefix_opted_in(body) is True
|
assert _prompt_prefix_opted_in(body) is True
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"moderation_model_name": null,
|
||||||
|
"curator": {
|
||||||
|
"enabled": false,
|
||||||
|
"interval_hours": 168,
|
||||||
|
"stale_after_days": 30,
|
||||||
|
"archive_after_days": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user