feat: add tag management for Agents with filtering and sorting (#14774) (#14799)

## Summary
Closes #14774.

Adds free-form tags on agents (UserCanvas) with full UI + API:
- Stored as comma-separated `tags` column on `UserCanvas` with online
migration.
- New endpoints: `GET /v1/agents/tags` (aggregate counts) and `PUT
/v1/agent/<id>/tags` (write). `GET /v1/agents` accepts a `tags=` query.
- "Edit tags" item in agent dropdown opens a chip-style editor dialog;
tags render as badges on each agent card.
- New "Tags" facet in the agents filter bar, with counts.

## Implementation notes
- **Tag matching is exact-token**: the SQL filter wraps stored tags as
`,…,` and matches `,ml,` so `ml` doesn't match `ml-ops`.
- **Server-side normalization** in `UserCanvasService.update_tags`:
dedup (case-insensitive), per-tag cap of 64 chars, total length capped
at 512 chars to fit the column, commas inside tag values are replaced
with spaces.
- **Tenant authorization**: `PUT /v1/agent/<id>/tags` gates on
`UserCanvasService.accessible(canvas_id, tenant_id)`.
- **Tag listing scope**: `UserCanvasService.list_tags` follows the same
own + team-shared rule as `get_by_tenant_ids`.
- **i18n**: keys added to `en.ts` and `zh.ts` only (per project
convention; other locales fall back).
- **`HomeCard`** gets a non-breaking `extra?: ReactNode` slot for the
chip row; no `src/components/ui/` files modified.

## Test plan
- [ ] Backend boot runs `migrate_db` → confirm `user_canvas.tags` column
exists (`DESCRIBE user_canvas`).
- [ ] Agents page renders cards normally (no console error from missing
field).
- [ ] `⋯ → Edit tags` opens a dialog that stays open (regression: dialog
was unmounting with the dropdown).
- [ ] Typing a tag without pressing Enter and clicking Save persists it
(regression: last typed tag was being dropped).
- [ ] Chip input supports Enter/comma to commit, Backspace on empty to
remove, `×` to remove individual chip.
- [ ] Tag containing a comma sent via API is stored with the comma
replaced by a space.
- [ ] 20 long tags sent via API does not error (length cap silently
truncates).
- [ ] "Tags" filter in the filter bar shows counts and narrows the list.
- [ ] Filtering by `ml` does **not** return agents tagged `ml-ops`.
- [ ] UI in Chinese shows 编辑标签 / 添加标签以整理和筛选你的智能体 etc.
- [ ] `PUT /v1/agent/<other-tenant-id>/tags` returns `Agent not found or
no permission.`
This commit is contained in:
plind
2026-05-13 06:41:32 -07:00
committed by GitHub
parent cb49f47c38
commit dd76653dc1
15 changed files with 494 additions and 35 deletions

View File

@@ -316,6 +316,7 @@ def list_agents(tenant_id):
keywords = request.args.get("keywords", "")
canvas_category = request.args.get("canvas_category")
owner_ids = [item for item in request.args.get("owner_ids", "").strip().split(",") if item]
tags = [item for item in request.args.get("tags", "").strip().split(",") if item]
page_number = int(request.args.get("page", 0))
items_per_page = int(request.args.get("page_size", 0))
@@ -347,11 +348,71 @@ def list_agents(tenant_id):
desc,
keywords,
canvas_category,
tags,
)
return get_json_result(data={"canvas": canvas, "total": total})
@manager.route("/agents/tags", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
def list_agent_tags(tenant_id):
"""Aggregate tag usage counts across agents visible to the caller."""
canvas_category = request.args.get("canvas_category")
tenants = TenantService.get_joined_tenants_by_user_id(tenant_id)
joined_ids = list({member["tenant_id"] for member in tenants} | {tenant_id})
counts = UserCanvasService.list_tags(joined_ids, tenant_id, canvas_category)
logging.info(
"list_agent_tags tenant=%s canvas_category=%s tags_count=%d",
tenant_id,
canvas_category,
len(counts),
)
return get_json_result(data=[{"tag": k, "count": v} for k, v in sorted(counts.items(), key=lambda x: (-x[1], x[0]))])
@manager.route("/agents/<canvas_id>/tags", methods=["PUT"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def update_agent_tags(tenant_id, canvas_id):
if not UserCanvasService.accessible(canvas_id, tenant_id):
logging.info(
"update_agent_tags denied tenant=%s canvas_id=%s reason=no_permission",
tenant_id,
canvas_id,
)
return get_json_result(
data=False,
message="Agent not found or no permission.",
code=RetCode.OPERATING_ERROR,
)
req = await get_request_json()
tags = req.get("tags", "")
incoming = tags if isinstance(tags, (list, tuple)) else [t for t in str(tags).split(",") if t.strip()]
rows_affected = UserCanvasService.update_tags(canvas_id, tags)
if rows_affected == 0:
logging.info(
"update_agent_tags miss tenant=%s canvas_id=%s incoming_count=%d rows=0",
tenant_id,
canvas_id,
len(incoming),
)
return get_json_result(
data=False,
message="Agent not found or no permission.",
code=RetCode.OPERATING_ERROR,
)
logging.info(
"update_agent_tags ok tenant=%s canvas_id=%s incoming_count=%d rows=%d",
tenant_id,
canvas_id,
len(incoming),
rows_affected,
)
return get_json_result(data=True)
@manager.route("/agents", methods=["POST"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs

View File

@@ -1051,6 +1051,7 @@ class UserCanvas(DataBaseModel):
description = TextField(null=True, help_text="Canvas description")
canvas_type = CharField(max_length=32, null=True, help_text="Canvas type", index=True)
canvas_category = CharField(max_length=32, null=False, default="agent_canvas", help_text="Canvas category: agent_canvas|dataflow_canvas", index=True)
tags = CharField(max_length=512, null=False, default="", help_text="Comma-separated tags for organizing agents", index=True)
dsl = JSONField(null=True, default={})
class Meta:
@@ -1647,6 +1648,7 @@ def migrate_db():
alter_db_add_column(migrator, "memory", "tenant_embd_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "memory", "tenant_llm_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "user_canvas_version", "release", BooleanField(null=False, help_text="is released", default=False, index=True))
alter_db_add_column(migrator, "user_canvas", "tags", CharField(max_length=512, null=False, default="", help_text="Comma-separated tags for organizing agents", index=True))
alter_db_add_column(migrator, "api_4_conversation", "version_title", CharField(max_length=255, null=True, help_text="canvas version title when session created", index=False))
alter_db_column_type(migrator, "document", "size", BigIntegerField(default=0, index=True))
alter_db_column_type(migrator, "file", "size", BigIntegerField(default=0, index=True))

View File

@@ -16,6 +16,8 @@
import json
import logging
import time
from functools import reduce
from operator import or_
from uuid import uuid4
from agent.canvas import Canvas
from api.db import CanvasCategory, TenantPermission
@@ -149,6 +151,7 @@ class UserCanvasService(CommonService):
desc,
keywords,
canvas_category=None,
tags=None,
):
fields = [
cls.model.id,
@@ -161,6 +164,7 @@ class UserCanvasService(CommonService):
User.avatar.alias('tenant_avatar'),
cls.model.update_time,
cls.model.canvas_category,
cls.model.tags,
]
if keywords:
agents = cls.model.select(*fields).join(User, on=(cls.model.user_id == User.id)).where(
@@ -173,6 +177,13 @@ class UserCanvasService(CommonService):
)
if canvas_category:
agents = agents.where(cls.model.canvas_category == canvas_category)
if tags:
tag_list = [t.strip() for t in tags if t and t.strip()] if isinstance(tags, (list, tuple)) else [t.strip() for t in str(tags).split(",") if t.strip()]
if tag_list:
# Wrap value with commas so 'ml' doesn't match 'ml-ops'.
wrapped = fn.CONCAT(",", cls.model.tags, ",")
clauses = [wrapped.contains(f",{t},") for t in tag_list]
agents = agents.where(reduce(or_, clauses))
if desc:
agents = agents.order_by(cls.model.getter_by(orderby).desc())
else:
@@ -199,6 +210,69 @@ class UserCanvasService(CommonService):
return agents_list, count
@classmethod
@DB.connection_context()
def list_tags(cls, joined_tenant_ids, user_id, canvas_category=None):
"""Return {tag: agent_count} aggregated across agents visible to the user."""
query = cls.model.select(cls.model.tags).where(
((cls.model.user_id.in_(joined_tenant_ids)) & (cls.model.permission == TenantPermission.TEAM.value)) | (cls.model.user_id == user_id)
)
if canvas_category:
query = query.where(cls.model.canvas_category == canvas_category)
counts: dict[str, int] = {}
for row in query.dicts():
for t in (row.get("tags") or "").split(","):
t = t.strip()
if t:
counts[t] = counts.get(t, 0) + 1
logging.info(
"UserCanvasService.list_tags user=%s canvas_category=%s tags_count=%d",
user_id,
canvas_category,
len(counts),
)
return counts
# Tag storage is a single comma-separated CharField(max_length=512);
# commas inside a tag would corrupt the encoding, so strip them on write.
TAGS_FIELD_MAX = 512
TAG_MAX_LEN = 64
@classmethod
@DB.connection_context()
def update_tags(cls, canvas_id, tags):
"""Persist a normalized comma-separated tag string for the given canvas."""
if isinstance(tags, (list, tuple)):
cleaned = [str(t).replace(",", " ").strip() for t in tags if t and str(t).strip()]
else:
cleaned = [t.strip() for t in str(tags or "").split(",") if t.strip()]
# Dedupe (case-insensitive, preserve order), cap individual tag length,
# then truncate the joined value so it always fits the column.
seen = set()
normalized = []
used = 0
for t in cleaned:
t = t[: cls.TAG_MAX_LEN]
key = t.lower()
if key in seen:
continue
extra = len(t) + (1 if normalized else 0)
if used + extra > cls.TAGS_FIELD_MAX:
break
seen.add(key)
normalized.append(t)
used += extra
value = ",".join(normalized)
rows_affected = cls.model.update(tags=value).where(cls.model.id == canvas_id).execute()
logging.info(
"UserCanvasService.update_tags canvas_id=%s tags_count=%d rows=%d",
canvas_id,
len(normalized),
rows_affected,
)
return rows_affected
@classmethod
@DB.connection_context()
def accessible(cls, canvas_id, tenant_id):

View File

@@ -514,7 +514,7 @@ def test_agents_crud_unit_branches(monkeypatch):
captured = {}
def fake_get_by_tenant_ids(owner_ids, tenant_id, page, page_size, orderby, desc, keywords, canvas_category):
def fake_get_by_tenant_ids(owner_ids, tenant_id, page, page_size, orderby, desc, keywords, canvas_category, tags):
captured["owner_ids"] = owner_ids
captured["tenant_id"] = tenant_id
captured["page"] = page
@@ -523,6 +523,7 @@ def test_agents_crud_unit_branches(monkeypatch):
captured["desc"] = desc
captured["keywords"] = keywords
captured["canvas_category"] = canvas_category
captured["tags"] = tags
return [{"id": "agent-1"}], 1
monkeypatch.setattr(module.UserCanvasService, "get_by_tenant_ids", fake_get_by_tenant_ids)

View File

@@ -18,6 +18,7 @@ interface IProps {
icon?: React.ReactNode;
testId?: string;
showReleaseTime?: boolean;
extra?: ReactNode;
}
function Time({ time }: { time: string | number | undefined }) {
@@ -31,6 +32,7 @@ export function HomeCard({
icon,
testId,
showReleaseTime = false,
extra,
}: IProps) {
const { t } = useTranslation();
@@ -81,6 +83,7 @@ export function HomeCard({
<div className="whitespace-nowrap overflow-hidden text-ellipsis">
{data.description}
</div>
{extra}
<div className="flex justify-between items-center">
{showReleaseTime ? (
<section className="text-sm text-text-secondary space-y-1">

View File

@@ -29,6 +29,7 @@ import agentService, {
fetchTrace,
fetchWebhookTrace,
updateAgent,
updateAgentTags,
uploadAgentFile,
} from '@/services/agent-service';
import { buildMessageListWithUuid } from '@/utils/chat';
@@ -73,6 +74,8 @@ export const enum AgentApiAction {
FetchSessionByIdManually = 'fetchSessionByIdManually',
FetchAgentLog = 'fetchAgentLog',
FetchSharedAgent = 'fetchSharedAgent',
FetchAgentTags = 'fetchAgentTags',
UpdateAgentTags = 'updateAgentTags',
}
export const useFetchAgentTemplates = () => {
@@ -95,12 +98,14 @@ const buildAgentListParams = ({
keywords,
canvasCategory,
ownerIds,
tags,
}: {
page: number;
pageSize: number;
keywords?: string;
canvasCategory?: string;
ownerIds?: string[];
tags?: string[];
}) => {
const params: Record<string, unknown> = {
page,
@@ -116,6 +121,9 @@ const buildAgentListParams = ({
if (Array.isArray(ownerIds) && ownerIds.length > 0) {
params.owner_ids = ownerIds.join(',');
}
if (Array.isArray(tags) && tags.length > 0) {
params.tags = tags.join(',');
}
return params;
};
@@ -129,6 +137,7 @@ export const useFetchAgentListByPage = () => {
? filterValue.canvasCategory
: [];
const owner = filterValue.owner;
const tags = Array.isArray(filterValue.tags) ? filterValue.tags : undefined;
const requestParams = buildAgentListParams({
page: pagination.current,
@@ -136,6 +145,7 @@ export const useFetchAgentListByPage = () => {
keywords: debouncedSearchString,
canvasCategory: canvasCategory.length === 1 ? canvasCategory[0] : undefined,
ownerIds: Array.isArray(owner) ? owner : undefined,
tags,
});
const { data, isFetching: loading } = useQuery<{
@@ -264,6 +274,57 @@ export const useDeleteAgent = () => {
return { data, loading, deleteAgent: mutateAsync };
};
export interface IAgentTagCount {
tag: string;
count: number;
}
export const useFetchAgentTags = (canvasCategory?: string) => {
const { data, isFetching: loading } = useQuery<IAgentTagCount[]>({
queryKey: [AgentApiAction.FetchAgentTags, canvasCategory],
initialData: [],
gcTime: 0,
queryFn: async () => {
const { data } = await agentService.listAgentTags(
{
params: canvasCategory ? { canvas_category: canvasCategory } : {},
},
true,
);
return data?.data ?? [];
},
});
return { data, loading };
};
export const useUpdateAgentTags = () => {
const queryClient = useQueryClient();
const { isPending: loading, mutateAsync } = useMutation({
mutationKey: [AgentApiAction.UpdateAgentTags],
mutationFn: async ({
agentId,
tags,
}: {
agentId: string;
tags: string[];
}) => {
const { data } = await updateAgentTags(agentId, tags);
if (data?.code === 0) {
queryClient.invalidateQueries({
queryKey: [AgentApiAction.FetchAgentListByPage],
});
queryClient.invalidateQueries({
queryKey: [AgentApiAction.FetchAgentTags],
});
} else {
message.error(data?.message || 'Update failed');
}
return data?.code === 0;
},
});
return { loading, updateAgentTags: mutateAsync };
};
export const useFetchAgent = (): {
data: IFlow;
loading: boolean;

View File

@@ -82,6 +82,7 @@ export declare interface IFlow {
release_time?: number;
last_publish_time?: number;
datasets?: Pick<IDataset, 'id' | 'name' | 'avatar'>[];
tags?: string;
}
export interface IFlowTemplate {

View File

@@ -1735,6 +1735,12 @@ Example: Virtual Hosted Style`,
author: 'Author',
sectionTitle: 'Section title',
},
editTags: 'Edit tags',
editTagsDescription:
'Add tags to organize and filter your agents. Press Enter or comma to add.',
tagsPlaceholder: 'Add a tag and press Enter',
tagSuggestionsLabel: 'Existing tags',
removeTagAriaLabel: 'Remove {{tag}}',
includeHeadingContent: 'Separate parent-heading content',
includeHeadingContentTip:
'When enabled, chunks include only their heading path and content; content immediately following a parent heading is kept as a separate chunk.',

View File

@@ -1425,6 +1425,11 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
author: '作者',
sectionTitle: '章节标题',
},
editTags: '编辑标签',
editTagsDescription: '添加标签以整理和筛选你的智能体。按回车或逗号添加。',
tagsPlaceholder: '输入标签后按回车',
tagSuggestionsLabel: '现有标签',
removeTagAriaLabel: '删除 {{tag}}',
includeHeadingContent: '分离上级标题正文',
includeHeadingContentTip:
'启用后,每个分块仅保留标题路径和自身内容,与上级标题紧挨着的内容将作为一个独立的块保留。',

View File

@@ -1,6 +1,7 @@
import { HomeCard } from '@/components/home-card';
import { MoreButton } from '@/components/more-button';
import { SharedBadge } from '@/components/shared-badge';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AgentCategory } from '@/constants/agent';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
@@ -13,6 +14,23 @@ export type DatasetCardProps = {
data: IFlow;
} & Pick<ReturnType<typeof useRenameAgent>, 'showAgentRenameModal'>;
function AgentTags({ tags }: { tags?: string }) {
const list = (tags || '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
if (list.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 mt-1">
{list.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs font-normal">
{tag}
</Badge>
))}
</div>
);
}
export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) {
const { navigateToAgent } = useNavigatePage();
@@ -44,6 +62,7 @@ export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) {
</Button>
)
}
extra={<AgentTags tags={data.tags} />}
showReleaseTime
/>
);

View File

@@ -11,9 +11,10 @@ import {
} from '@/components/ui/dropdown-menu';
import { useDeleteAgent } from '@/hooks/use-agent-request';
import { IFlow } from '@/interfaces/database/agent';
import { PenLine, Trash2 } from 'lucide-react';
import { MouseEventHandler, PropsWithChildren, useCallback } from 'react';
import { PenLine, Tag, Trash2 } from 'lucide-react';
import { MouseEventHandler, PropsWithChildren, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentTagEditor } from './agent-tag-editor';
import { useRenameAgent } from './use-rename-agent';
export function AgentDropdown({
@@ -26,6 +27,7 @@ export function AgentDropdown({
}) {
const { t } = useTranslation();
const { deleteAgent } = useDeleteAgent();
const [tagEditorOpen, setTagEditorOpen] = useState(false);
const handleShowAgentRenameModal: MouseEventHandler<HTMLDivElement> =
useCallback(
@@ -36,43 +38,58 @@ export function AgentDropdown({
[agent, showAgentRenameModal],
);
const handleEditTags: MouseEventHandler<HTMLDivElement> = useCallback((e) => {
e.stopPropagation();
setTagEditorOpen(true);
}, []);
const handleDelete: MouseEventHandler<HTMLDivElement> = useCallback(() => {
deleteAgent(agent.id);
}, [agent.id, deleteAgent]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleShowAgentRenameModal}>
{t('common.rename')} <PenLine />
</DropdownMenuItem>
<DropdownMenuSeparator />
<ConfirmDeleteDialog
onOk={handleDelete}
title={t('deleteModal.delAgent')}
content={{
node: (
<ConfirmDeleteDialogNode
avatar={{ avatar: agent.avatar, name: agent.title }}
name={agent.title}
/>
),
}}
>
<DropdownMenuItem
className="text-state-error"
onSelect={(e) => {
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleShowAgentRenameModal}>
{t('common.rename')} <PenLine />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleEditTags}>
{t('flow.editTags')} <Tag />
</DropdownMenuItem>
<DropdownMenuSeparator />
<ConfirmDeleteDialog
onOk={handleDelete}
title={t('deleteModal.delAgent')}
content={{
node: (
<ConfirmDeleteDialogNode
avatar={{ avatar: agent.avatar, name: agent.title }}
name={agent.title}
/>
),
}}
>
{t('common.delete')} <Trash2 />
</DropdownMenuItem>
</ConfirmDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem
className="text-state-error"
onSelect={(e) => {
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
}}
>
{t('common.delete')} <Trash2 />
</DropdownMenuItem>
</ConfirmDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
<AgentTagEditor
agent={agent}
open={tagEditorOpen}
onOpenChange={setTagEditorOpen}
/>
</>
);
}

View File

@@ -0,0 +1,176 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
useFetchAgentTags,
useUpdateAgentTags,
} from '@/hooks/use-agent-request';
import { IFlow } from '@/interfaces/database/agent';
import { X } from 'lucide-react';
import { KeyboardEvent, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface IProps {
agent: IFlow;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const splitTags = (raw?: string) =>
(raw || '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
export function AgentTagEditor({ agent, open, onOpenChange }: IProps) {
const { t } = useTranslation();
const { loading, updateAgentTags } = useUpdateAgentTags();
const { data: allTags } = useFetchAgentTags();
const initial = useMemo(() => splitTags(agent.tags), [agent.tags]);
const [tags, setTags] = useState<string[]>(initial);
const [draft, setDraft] = useState('');
useEffect(() => {
if (open) {
setTags(initial);
setDraft('');
}
}, [open, initial]);
const suggestions = useMemo(() => {
const taken = new Set(tags.map((t) => t.toLowerCase()));
const needle = draft.trim().toLowerCase();
return (allTags ?? [])
.map((entry) => entry.tag)
.filter((tag) => !taken.has(tag.toLowerCase()))
.filter((tag) => !needle || tag.toLowerCase().startsWith(needle))
.slice(0, 20);
}, [allTags, tags, draft]);
const addTag = (tag: string) => {
const next = tag.trim();
if (!next) return;
if (!tags.some((existing) => existing.toLowerCase() === next.toLowerCase())) {
setTags([...tags, next]);
}
setDraft('');
};
const commitDraft = () => addTag(draft);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !draft && tags.length > 0) {
setTags(tags.slice(0, -1));
}
};
const removeTag = (tag: string) =>
setTags(tags.filter((existing) => existing !== tag));
const handleSave = async () => {
const pending = draft.trim();
const alreadyPresent = tags.some(
(existing) => existing.toLowerCase() === pending.toLowerCase(),
);
const finalTags = pending && !alreadyPresent ? [...tags, pending] : tags;
setTags(finalTags);
setDraft('');
const success = await updateAgentTags({
agentId: agent.id,
tags: finalTags,
});
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t('flow.editTags')}</DialogTitle>
<DialogDescription>
{t('flow.editTagsDescription')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-1 min-h-8">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs font-normal gap-1"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
aria-label={t('flow.removeTagAriaLabel', { tag })}
className="hover:text-state-error"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitDraft}
placeholder={t('flow.tagsPlaceholder')}
/>
{suggestions.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-text-secondary">
{t('flow.tagSuggestionsLabel')}
</div>
<div className="flex flex-wrap gap-1">
{suggestions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => addTag(tag)}
className="text-xs"
>
<Badge
variant="outline"
className="text-xs font-normal cursor-pointer hover:bg-accent"
>
+ {tag}
</Badge>
</button>
))}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={loading}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,10 +1,14 @@
import { FilterCollection } from '@/components/list-filter-bar/interface';
import { useFetchAgentList } from '@/hooks/use-agent-request';
import {
useFetchAgentList,
useFetchAgentTags,
} from '@/hooks/use-agent-request';
import { buildOwnersFilter, groupListByType } from '@/utils/list-filter-util';
import { useMemo } from 'react';
export function useSelectFilters() {
const { data } = useFetchAgentList({});
const { data: tagCounts } = useFetchAgentTags();
const canvasCategory = useMemo(() => {
return groupListByType(
@@ -14,6 +18,16 @@ export function useSelectFilters() {
);
}, [data?.canvas]);
const tagList = useMemo(
() =>
(tagCounts ?? []).map((t) => ({
id: t.tag,
label: t.tag,
count: t.count,
})),
[tagCounts],
);
const filters: FilterCollection[] = [
buildOwnersFilter(data?.canvas ?? []),
{
@@ -21,6 +35,11 @@ export function useSelectFilters() {
list: canvasCategory,
label: 'Canvas category',
},
{
field: 'tags',
list: tagList,
label: 'Tags',
},
];
return filters;

View File

@@ -50,6 +50,10 @@ const methods = {
url: listAgents,
method: 'get',
},
listAgentTags: {
url: api.listAgentTags,
method: 'get',
},
resetAgent: {
url: resetAgent,
method: 'post',
@@ -135,6 +139,13 @@ export const updateAgent = (
return request(updateAgentApi(agentId), { method: 'put', data: params });
};
export const updateAgentTags = (agentId: string, tags: string[]) => {
return request(api.updateAgentTags(agentId), {
method: 'put',
data: { tags: tags.join(',') },
});
};
export const fetchTrace = (data: { canvas_id: string; message_id: string }) => {
return request.get(
methods.trace.url({

View File

@@ -189,6 +189,9 @@ export default {
// flow
listAgentTemplate: `${restAPIv1}/agents/templates`,
listAgents: `${restAPIv1}/agents`,
listAgentTags: `${restAPIv1}/agents/tags`,
updateAgentTags: (agentId: string) =>
`${restAPIv1}/agents/${agentId}/tags`,
createAgent: `${restAPIv1}/agents`,
updateAgent: (agentId: string) => `${restAPIv1}/agents/${agentId}`,
deleteAgent: (agentId: string) => `${restAPIv1}/agents/${agentId}`,