From dd76653dc14921988196e64396c38795e3858538 Mon Sep 17 00:00:00 2001 From: plind <59729252+plind-junior@users.noreply.github.com> Date: Wed, 13 May 2026 06:41:32 -0700 Subject: [PATCH] feat: add tag management for Agents with filtering and sorting (#14774) (#14799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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//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//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//tags` returns `Agent not found or no permission.` --- api/apps/restful_apis/agent_api.py | 61 ++++++ api/db/db_models.py | 2 + api/db/services/canvas_service.py | 74 ++++++++ .../test_agents_webhook_unit.py | 3 +- web/src/components/home-card.tsx | 3 + web/src/hooks/use-agent-request.ts | 61 ++++++ web/src/interfaces/database/agent.ts | 1 + web/src/locales/en.ts | 6 + web/src/locales/zh.ts | 5 + web/src/pages/agents/agent-card.tsx | 19 ++ web/src/pages/agents/agent-dropdown.tsx | 83 +++++---- web/src/pages/agents/agent-tag-editor.tsx | 176 ++++++++++++++++++ .../pages/agents/hooks/use-selelct-filters.ts | 21 ++- web/src/services/agent-service.ts | 11 ++ web/src/utils/api.ts | 3 + 15 files changed, 494 insertions(+), 35 deletions(-) create mode 100644 web/src/pages/agents/agent-tag-editor.tsx diff --git a/api/apps/restful_apis/agent_api.py b/api/apps/restful_apis/agent_api.py index 054117d236..09f284e895 100644 --- a/api/apps/restful_apis/agent_api.py +++ b/api/apps/restful_apis/agent_api.py @@ -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//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 diff --git a/api/db/db_models.py b/api/db/db_models.py index 5fe64586c0..3ed32ed3f2 100644 --- a/api/db/db_models.py +++ b/api/db/db_models.py @@ -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)) diff --git a/api/db/services/canvas_service.py b/api/db/services/canvas_service.py index 1c1583e8f6..8c7fe4748f 100644 --- a/api/db/services/canvas_service.py +++ b/api/db/services/canvas_service.py @@ -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): diff --git a/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py b/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py index 1022a9b45a..e93c48249a 100644 --- a/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py +++ b/test/testcases/test_web_api/test_agent_app/test_agents_webhook_unit.py @@ -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) diff --git a/web/src/components/home-card.tsx b/web/src/components/home-card.tsx index cefd9434b6..d5abbf6851 100644 --- a/web/src/components/home-card.tsx +++ b/web/src/components/home-card.tsx @@ -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({
{data.description}
+ {extra}
{showReleaseTime ? (
diff --git a/web/src/hooks/use-agent-request.ts b/web/src/hooks/use-agent-request.ts index b524ccbc31..b286e853f4 100644 --- a/web/src/hooks/use-agent-request.ts +++ b/web/src/hooks/use-agent-request.ts @@ -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 = { 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({ + 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; diff --git a/web/src/interfaces/database/agent.ts b/web/src/interfaces/database/agent.ts index f548bd6a44..e82d605014 100644 --- a/web/src/interfaces/database/agent.ts +++ b/web/src/interfaces/database/agent.ts @@ -82,6 +82,7 @@ export declare interface IFlow { release_time?: number; last_publish_time?: number; datasets?: Pick[]; + tags?: string; } export interface IFlowTemplate { diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 407c1f960f..9bfd656017 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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.', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index f9ebe517eb..dcd8f5871d 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1425,6 +1425,11 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 author: '作者', sectionTitle: '章节标题', }, + editTags: '编辑标签', + editTagsDescription: '添加标签以整理和筛选你的智能体。按回车或逗号添加。', + tagsPlaceholder: '输入标签后按回车', + tagSuggestionsLabel: '现有标签', + removeTagAriaLabel: '删除 {{tag}}', includeHeadingContent: '分离上级标题正文', includeHeadingContentTip: '启用后,每个分块仅保留标题路径和自身内容,与上级标题紧挨着的内容将作为一个独立的块保留。', diff --git a/web/src/pages/agents/agent-card.tsx b/web/src/pages/agents/agent-card.tsx index 2126475b23..67304c8550 100644 --- a/web/src/pages/agents/agent-card.tsx +++ b/web/src/pages/agents/agent-card.tsx @@ -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, 'showAgentRenameModal'>; +function AgentTags({ tags }: { tags?: string }) { + const list = (tags || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + if (list.length === 0) return null; + return ( +
+ {list.map((tag) => ( + + {tag} + + ))} +
+ ); +} + export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) { const { navigateToAgent } = useNavigatePage(); @@ -44,6 +62,7 @@ export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) { ) } + extra={} showReleaseTime /> ); diff --git a/web/src/pages/agents/agent-dropdown.tsx b/web/src/pages/agents/agent-dropdown.tsx index 5370f2a39d..8c3e569245 100644 --- a/web/src/pages/agents/agent-dropdown.tsx +++ b/web/src/pages/agents/agent-dropdown.tsx @@ -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 = useCallback( @@ -36,43 +38,58 @@ export function AgentDropdown({ [agent, showAgentRenameModal], ); + const handleEditTags: MouseEventHandler = useCallback((e) => { + e.stopPropagation(); + setTagEditorOpen(true); + }, []); + const handleDelete: MouseEventHandler = useCallback(() => { deleteAgent(agent.id); }, [agent.id, deleteAgent]); return ( - - {children} - - - {t('common.rename')} - - - - ), - }} - > - { - e.preventDefault(); - }} - onClick={(e) => { - e.stopPropagation(); + <> + + {children} + + + {t('common.rename')} + + + {t('flow.editTags')} + + + + ), }} > - {t('common.delete')} - - - - + { + e.preventDefault(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + {t('common.delete')} + + + + + + ); } diff --git a/web/src/pages/agents/agent-tag-editor.tsx b/web/src/pages/agents/agent-tag-editor.tsx new file mode 100644 index 0000000000..9ff8d43a0a --- /dev/null +++ b/web/src/pages/agents/agent-tag-editor.tsx @@ -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(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) => { + 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 ( + + e.stopPropagation()}> + + {t('flow.editTags')} + + {t('flow.editTagsDescription')} + + + +
+ {tags.map((tag) => ( + + {tag} + + + ))} +
+ + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={commitDraft} + placeholder={t('flow.tagsPlaceholder')} + /> + + {suggestions.length > 0 && ( +
+
+ {t('flow.tagSuggestionsLabel')} +
+
+ {suggestions.map((tag) => ( + + ))} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/web/src/pages/agents/hooks/use-selelct-filters.ts b/web/src/pages/agents/hooks/use-selelct-filters.ts index aa4f4f4ddb..2f6fb95d71 100644 --- a/web/src/pages/agents/hooks/use-selelct-filters.ts +++ b/web/src/pages/agents/hooks/use-selelct-filters.ts @@ -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; diff --git a/web/src/services/agent-service.ts b/web/src/services/agent-service.ts index 4a4f59daaf..92dcfeaf65 100644 --- a/web/src/services/agent-service.ts +++ b/web/src/services/agent-service.ts @@ -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({ diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index f1e67986d3..5dbfc8e369 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -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}`,