mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-12 14:45:42 +08:00
## 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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -1425,6 +1425,11 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
author: '作者',
|
||||
sectionTitle: '章节标题',
|
||||
},
|
||||
editTags: '编辑标签',
|
||||
editTagsDescription: '添加标签以整理和筛选你的智能体。按回车或逗号添加。',
|
||||
tagsPlaceholder: '输入标签后按回车',
|
||||
tagSuggestionsLabel: '现有标签',
|
||||
removeTagAriaLabel: '删除 {{tag}}',
|
||||
includeHeadingContent: '分离上级标题正文',
|
||||
includeHeadingContentTip:
|
||||
'启用后,每个分块仅保留标题路径和自身内容,与上级标题紧挨着的内容将作为一个独立的块保留。',
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
176
web/src/pages/agents/agent-tag-editor.tsx
Normal file
176
web/src/pages/agents/agent-tag-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user