feat: 标签管理功能 + 问答前缀对用户隐藏
标签管理: - 新增 tags/tag_assignments 持久化层、迁移、/api/tags 路由 - 标签分智能体/技能/定时任务三类, 仅同类型可互打, 仅 admin 可增删改与打标签 - 三类资源列表支持按名称模糊检索与按标签筛选(走后端查询参数) - 前端新增标签管理页、TagPickerDialog(点击即时绑定)、彩色标签 chip 问答前缀: - 前缀不再拼入消息 content, 改存 additional_kwargs 由 PromptPrefixMiddleware 在调模型前临时注入, 用户界面看不到前缀但模型仍收到 - 修正 thread_existed 闸门导致首轮前缀被跳过的问题 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
aeba15eb16
commit
646cb8caeb
47 files changed
+2180
-109
No files matched your search
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { TagChip } from "@/components/workspace/tags/tag-chip";
|
||||
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useCreateTag,
|
||||
useDeleteTag,
|
||||
useTags,
|
||||
useUpdateTag,
|
||||
type Tag,
|
||||
type TagType,
|
||||
} from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TagFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tagType,
|
||||
editing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tagType: TagType;
|
||||
editing: Tag | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const createTag = useCreateTag();
|
||||
const updateTag = useUpdateTag();
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("gray");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Sync form state whenever the dialog (re)opens.
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${editing?.id ?? "new"}`;
|
||||
if (open && syncedFor !== syncKey) {
|
||||
setName(editing?.name ?? "");
|
||||
setColor(editing?.color ?? "gray");
|
||||
setDescription(editing?.description ?? "");
|
||||
setSyncedFor(syncKey);
|
||||
}
|
||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||
|
||||
const pending = createTag.isPending || updateTag.isPending;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
if (editing) {
|
||||
await updateTag.mutateAsync({
|
||||
id: editing.id,
|
||||
input: { name: trimmed, color, description },
|
||||
});
|
||||
} else {
|
||||
await createTag.mutateAsync({
|
||||
name: trimmed,
|
||||
tag_type: tagType,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{t.adminTags.name}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.adminTags.namePlaceholder}
|
||||
maxLength={256}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{t.adminTags.color}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setColor(c.name)}
|
||||
aria-label={c.name}
|
||||
className={cn(
|
||||
"size-6 rounded-full border-2",
|
||||
c.swatch,
|
||||
color === c.name
|
||||
? "border-foreground"
|
||||
: "border-transparent",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t.adminTags.descriptionPlaceholder}
|
||||
maxLength={1024}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{name.trim() && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Preview:</span>
|
||||
<TagChip tag={{ name: name.trim(), color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t.adminTags.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending || !name.trim()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||
{t.adminTags.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeType, setActiveType] = useState<TagType>("agent");
|
||||
const { data: tags, isLoading, error } = useTags(activeType);
|
||||
const deleteTag = useDeleteTag();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Tag | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const openEdit = (tag: Tag) => {
|
||||
setEditing(tag);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const handleDelete = async (tag: Tag) => {
|
||||
if (!window.confirm(t.adminTags.deleteConfirm)) return;
|
||||
try {
|
||||
await deleteTag.mutateAsync(tag.id);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<SettingsSection
|
||||
title={t.adminTags.title}
|
||||
description={t.adminTags.description}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Tabs
|
||||
value={activeType}
|
||||
onValueChange={(v) => setActiveType(v as TagType)}
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
|
||||
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
|
||||
<TabsTrigger value="scheduled_task">
|
||||
{t.adminTags.typeScheduledTask}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
{t.adminTags.createButton}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive py-4 text-sm">{String(error)}</div>
|
||||
)}
|
||||
{!isLoading && !error && tags && tags.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t.adminTags.empty}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && tags && tags.length > 0 && (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.name}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.usageCount}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((tag, i) => (
|
||||
<tr
|
||||
key={tag.id}
|
||||
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<TagChip tag={tag} />
|
||||
</td>
|
||||
<td className="text-muted-foreground px-4 py-3">
|
||||
{tag.description || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{tag.usage_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(tag)}
|
||||
aria-label={t.adminTags.edit}
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(tag)}
|
||||
aria-label={t.adminTags.delete}
|
||||
>
|
||||
<Trash2Icon className="text-destructive size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<TagFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
tagType={activeType}
|
||||
editing={editing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EyeOffIcon,
|
||||
MessageSquareIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -242,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
{(canEdit || canDelete || isOwned) && (
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagId });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -83,10 +87,19 @@ export function AgentGallery() {
|
||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="mx-6 mt-4 w-auto self-start">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="mine">自己的</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="mine">自己的</TabsTrigger>
|
||||
</TabsList>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="w-[420px] max-w-[50%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
@@ -46,6 +47,9 @@ import {
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
@@ -66,36 +70,27 @@ import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const { skills, isLoading, error } = useSkills();
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
) : (
|
||||
<SkillSettingsList skills={skills} onClose={onClose} />
|
||||
)}
|
||||
<SkillSettingsList onClose={onClose} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSettingsList({
|
||||
skills,
|
||||
onClose,
|
||||
}: {
|
||||
skills: Skill[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagId });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -211,10 +206,22 @@ function SkillSettingsList({
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{filteredSkills.length === 0 && (
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="max-w-xl"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||
)}
|
||||
{filteredSkills.length > 0 &&
|
||||
{!isLoading && !error &&
|
||||
filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
@@ -258,6 +265,7 @@ function SkillRow({
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -392,8 +400,22 @@ function SkillRow({
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
{(skill.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -473,6 +495,15 @@ function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/** A read-only (or removable) coloured chip for a single tag. */
|
||||
export function TagChip({
|
||||
tag,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tag: Pick<Tag, "name" | "color">;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
color.chip,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="hover:opacity-70"
|
||||
aria-label={`remove ${tag.name}`}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
|
||||
export function TagChipList({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags: Pick<Tag, "name" | "color">[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
||||
{tags.map((tag) => (
|
||||
<TagChip key={tag.name} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Fixed tag color palette. The backend only stores the color *name*; the
|
||||
// Tailwind classes are resolved here so the class strings stay static and
|
||||
// survive the production purge.
|
||||
|
||||
export interface TagColorDef {
|
||||
name: string;
|
||||
/** Classes for a rendered chip (background + text + border). */
|
||||
chip: string;
|
||||
/** A solid swatch used in the colour picker. */
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const TAG_COLORS: TagColorDef[] = [
|
||||
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
|
||||
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
|
||||
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
|
||||
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
|
||||
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
|
||||
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
|
||||
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
|
||||
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
|
||||
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
|
||||
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
||||
|
||||
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
||||
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/**
|
||||
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
|
||||
* unbinds it immediately (no separate save step). Only tags of ``targetType``
|
||||
* are offered, enforcing "a tag may only tag its own type".
|
||||
*/
|
||||
export function TagPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetType,
|
||||
targetId,
|
||||
currentTags,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetType: TagType;
|
||||
targetId: string;
|
||||
currentTags: Tag[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: allTags, isLoading } = useTags(targetType);
|
||||
const setResourceTags = useSetResourceTags();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${targetId}`;
|
||||
if (open && syncedFor !== syncKey) {
|
||||
setSelected(new Set(currentTags.map((tg) => tg.id)));
|
||||
setSyncedFor(syncKey);
|
||||
}
|
||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||
|
||||
// Clicking a tag mutates immediately. Local state updates optimistically and
|
||||
// rolls back if the request fails.
|
||||
const toggle = (tagId: string) => {
|
||||
const prev = selected;
|
||||
const next = new Set(prev);
|
||||
if (next.has(tagId)) next.delete(tagId);
|
||||
else next.add(tagId);
|
||||
setSelected(next);
|
||||
setResourceTags.mutate(
|
||||
{ targetType, targetId, tagIds: [...next] },
|
||||
{
|
||||
onError: (e) => {
|
||||
setSelected(prev);
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
|
||||
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[120px]">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!allTags || allTags.length === 0) && (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t.adminTags.pickerEmpty}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && allTags && allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => {
|
||||
const checked = selected.has(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
|
||||
color.chip,
|
||||
checked
|
||||
? "ring-2 ring-offset-1 ring-foreground/40"
|
||||
: "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3.5" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{setResourceTags.isPending ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t.adminTags.saving}
|
||||
</span>
|
||||
) : (
|
||||
t.adminTags.pickerSelectedCount.replace(
|
||||
"{count}",
|
||||
String(selected.size),
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t.adminTags.done}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useTags, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
/**
|
||||
* Reusable name-search + tag-filter toolbar for resource list pages.
|
||||
*
|
||||
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
|
||||
* caller can feed the value straight into a server-side query without a
|
||||
* request per keystroke. Filtering is open to all users.
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagId,
|
||||
onTagIdChange,
|
||||
className,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagId: string | null;
|
||||
onTagIdChange: (value: string | null) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={tagId ?? ALL}
|
||||
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t.adminTags.filterByTag} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
|
||||
{(tags ?? []).map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -134,6 +135,14 @@ export function WorkspaceNavMenu() {
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<Agent[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
}
|
||||
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createAgent,
|
||||
@@ -9,10 +14,13 @@ import {
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export function useAgents() {
|
||||
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => listAgents(),
|
||||
queryKey: ["agents", search, tagId],
|
||||
queryFn: () => listAgents({ search, tagId }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -11,6 +13,7 @@ export interface Agent {
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
skills?: string[] | null;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
||||
@@ -625,6 +625,40 @@ export const enUS: Translations = {
|
||||
empty: "No users found",
|
||||
},
|
||||
|
||||
adminTags: {
|
||||
title: "Tag Management",
|
||||
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
|
||||
typeAgent: "Agent Tags",
|
||||
typeSkill: "Skill Tags",
|
||||
typeScheduledTask: "Scheduled Task Tags",
|
||||
name: "Name",
|
||||
color: "Color",
|
||||
tagDescription: "Description",
|
||||
usageCount: "Usage",
|
||||
actions: "Actions",
|
||||
empty: "No tags yet",
|
||||
createButton: "New Tag",
|
||||
createTitle: "Create Tag",
|
||||
editTitle: "Edit Tag",
|
||||
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
|
||||
namePlaceholder: "Tag name",
|
||||
descriptionPlaceholder: "Optional description",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
pickerTitle: "Edit Tags",
|
||||
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
|
||||
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
|
||||
pickerSelectedCount: "{count} selected",
|
||||
saving: "Saving…",
|
||||
done: "Done",
|
||||
manageTags: "Tags",
|
||||
searchPlaceholder: "Search by name",
|
||||
filterByTag: "Filter by tag",
|
||||
allTags: "All tags",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
bellAria: "Open notifications",
|
||||
|
||||
@@ -538,6 +538,43 @@ export interface Translations {
|
||||
empty: string;
|
||||
};
|
||||
|
||||
// Admin tag management page
|
||||
adminTags: {
|
||||
title: string;
|
||||
description: string;
|
||||
typeAgent: string;
|
||||
typeSkill: string;
|
||||
typeScheduledTask: string;
|
||||
name: string;
|
||||
color: string;
|
||||
tagDescription: string;
|
||||
usageCount: string;
|
||||
actions: string;
|
||||
empty: string;
|
||||
createButton: string;
|
||||
createTitle: string;
|
||||
editTitle: string;
|
||||
deleteConfirm: string;
|
||||
namePlaceholder: string;
|
||||
descriptionPlaceholder: string;
|
||||
save: string;
|
||||
cancel: string;
|
||||
edit: string;
|
||||
delete: string;
|
||||
// tag picker dialog
|
||||
pickerTitle: string;
|
||||
pickerHint: string;
|
||||
pickerEmpty: string;
|
||||
pickerSelectedCount: string;
|
||||
saving: string;
|
||||
done: string;
|
||||
manageTags: string;
|
||||
// search controls on resource lists
|
||||
searchPlaceholder: string;
|
||||
filterByTag: string;
|
||||
allTags: string;
|
||||
};
|
||||
|
||||
// In-app notification inbox (bell in the workspace sidebar)
|
||||
notifications: NotificationsTranslations;
|
||||
}
|
||||
@@ -594,6 +594,40 @@ export const zhCN: Translations = {
|
||||
empty: "暂无用户",
|
||||
},
|
||||
|
||||
adminTags: {
|
||||
title: "标签管理",
|
||||
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
|
||||
typeAgent: "智能体标签",
|
||||
typeSkill: "技能标签",
|
||||
typeScheduledTask: "定时任务标签",
|
||||
name: "名称",
|
||||
color: "颜色",
|
||||
tagDescription: "描述",
|
||||
usageCount: "使用数",
|
||||
actions: "操作",
|
||||
empty: "暂无标签",
|
||||
createButton: "新建标签",
|
||||
createTitle: "创建标签",
|
||||
editTitle: "编辑标签",
|
||||
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
|
||||
namePlaceholder: "标签名称",
|
||||
descriptionPlaceholder: "描述(可选)",
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
edit: "编辑",
|
||||
delete: "删除",
|
||||
pickerTitle: "编辑标签",
|
||||
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
|
||||
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
|
||||
pickerSelectedCount: "已选 {count} 个",
|
||||
saving: "保存中…",
|
||||
done: "完成",
|
||||
manageTags: "标签",
|
||||
searchPlaceholder: "按名称搜索",
|
||||
filterByTag: "按标签筛选",
|
||||
allTags: "全部标签",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "通知",
|
||||
bellAria: "打开通知",
|
||||
|
||||
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
|
||||
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
|
||||
}
|
||||
|
||||
export async function listScheduledTasks() {
|
||||
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
|
||||
export interface ListScheduledTasksParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
}
|
||||
|
||||
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createScheduledTask,
|
||||
@@ -30,11 +35,16 @@ export function useSchedulerThread() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTasks() {
|
||||
export function useScheduledTasks(
|
||||
params: { search?: string; tagId?: string | null } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks"],
|
||||
queryFn: listScheduledTasks,
|
||||
queryKey: ["scheduled-tasks", "list", search, tagId],
|
||||
queryFn: () => listScheduledTasks({ search, tagId }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export type ScheduledTaskRole = "owner" | "subscriber";
|
||||
|
||||
export type ScheduledTaskSubscription = {
|
||||
@@ -29,6 +31,7 @@ export type ScheduledTask = {
|
||||
_subscription?: ScheduledTaskSubscription | null;
|
||||
creator_user_id?: string | null;
|
||||
creator_email?: string | null;
|
||||
tags?: Tag[];
|
||||
};
|
||||
|
||||
export type PublishedScheduledTask = {
|
||||
|
||||
@@ -3,8 +3,19 @@ import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
export async function loadSkills() {
|
||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
);
|
||||
const json = await skills.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
@@ -13,10 +18,13 @@ import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
export function useSkills() {
|
||||
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills"],
|
||||
queryFn: () => loadSkills(),
|
||||
queryKey: ["skills", search, tagId],
|
||||
queryFn: () => loadSkills({ search, tagId }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
|
||||
@@ -23,4 +25,6 @@ export interface Skill {
|
||||
use_count?: number;
|
||||
view_count?: number;
|
||||
patch_count?: number;
|
||||
// Tags assigned to this skill (admin-managed).
|
||||
tags?: Tag[];
|
||||
}
|
||||
@@ -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(
|
||||
(message: PromptInputMessage) => {
|
||||
// Only the opening turn of a brand-new thread may carry the prefix.
|
||||
const shouldApplyPrefix =
|
||||
isNewThread &&
|
||||
!hasStartedConversation &&
|
||||
promptPrefixOn &&
|
||||
promptPrefixSettings?.enabled === true;
|
||||
@@ -101,19 +103,17 @@ export default function ChatPage() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
const messageToSend = shouldApplyPrefix
|
||||
? {
|
||||
...message,
|
||||
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
|
||||
}
|
||||
: message;
|
||||
// The prefix is intentionally NOT concatenated into the message text:
|
||||
// doing so would make the user see it. We only flag the request via
|
||||
// context; the backend tags the message and PromptPrefixMiddleware
|
||||
// injects the prefix into the LLM request only.
|
||||
void sendMessage(
|
||||
threadId,
|
||||
messageToSend,
|
||||
message,
|
||||
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
|
||||
);
|
||||
},
|
||||
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
|
||||
[sendMessage, threadId, isNewThread, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
@@ -129,13 +129,14 @@ export default function ChatPage() {
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
if (hasStartedConversation) return undefined;
|
||||
// The prefix toggle is offered only on the opening turn of a new thread.
|
||||
if (!isNewThread || hasStartedConversation) return undefined;
|
||||
return {
|
||||
label: promptPrefixSettings.switch_label || "前缀",
|
||||
on: promptPrefixOn,
|
||||
onToggle: () => setPromptPrefixOn((v) => !v),
|
||||
};
|
||||
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
|
||||
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
TagsIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
@@ -35,7 +36,11 @@ import {
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
type PublishedScheduledTask,
|
||||
@@ -283,7 +288,10 @@ const emptyForm = {
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks();
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
|
||||
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||
const { agents } = useAgents();
|
||||
const actions = useScheduledTaskActions();
|
||||
@@ -299,6 +307,8 @@ export default function ScheduledTasksPage() {
|
||||
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
||||
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
||||
const [subscribeNotify, setSubscribeNotify] = useState(false);
|
||||
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
|
||||
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
|
||||
|
||||
const sortedTasks = useMemo(
|
||||
() =>
|
||||
@@ -466,6 +476,14 @@ export default function ScheduledTasksPage() {
|
||||
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{activeTab === "mine" && (
|
||||
<TagSearchBar
|
||||
targetType="scheduled_task"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
/>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-0">
|
||||
<ScrollArea className="size-full">
|
||||
@@ -527,7 +545,23 @@ export default function ScheduledTasksPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(task.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={task.tags!} className="mt-3" />
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="编辑标签"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setTagPickerTask(task);
|
||||
}}
|
||||
>
|
||||
<TagsIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwner ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -934,6 +968,16 @@ export default function ScheduledTasksPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isAdmin && tagPickerTask && (
|
||||
<TagPickerDialog
|
||||
open={!!tagPickerTask}
|
||||
onOpenChange={(open) => !open && setTagPickerTask(null)}
|
||||
targetType="scheduled_task"
|
||||
targetId={tagPickerTask.task_id}
|
||||
currentTags={tagPickerTask.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TagManagementPage } from "@/components/workspace/admin/tag-management-page";
|
||||
|
||||
export default function TagsPage() {
|
||||
return <TagManagementPage />;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
export function WorkspaceLayout({
|
||||
@@ -183,6 +184,18 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<TagsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user