Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions api/apps/restful_apis/agent_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/db/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down
74 changes: 74 additions & 0 deletions api/db/services/canvas_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -149,6 +151,7 @@ def get_by_tenant_ids(
desc,
keywords,
canvas_category=None,
tags=None,
):
fields = [
cls.model.id,
Expand All @@ -161,6 +164,7 @@ def get_by_tenant_ids(
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(
Expand All @@ -173,6 +177,13 @@ def get_by_tenant_ids(
)
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:
Expand All @@ -199,6 +210,69 @@ def get_by_tenant_ids(

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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -523,6 +523,7 @@ def fake_get_by_tenant_ids(owner_ids, tenant_id, page, page_size, orderby, desc,
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)
Expand Down
3 changes: 3 additions & 0 deletions web/src/components/home-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface IProps {
icon?: React.ReactNode;
testId?: string;
showReleaseTime?: boolean;
extra?: ReactNode;
}

function Time({ time }: { time: string | number | undefined }) {
Expand All @@ -31,6 +32,7 @@ export function HomeCard({
icon,
testId,
showReleaseTime = false,
extra,
}: IProps) {
const { t } = useTranslation();

Expand Down Expand Up @@ -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">
Expand Down
61 changes: 61 additions & 0 deletions web/src/hooks/use-agent-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import agentService, {
fetchTrace,
fetchWebhookTrace,
updateAgent,
updateAgentTags,
uploadAgentFile,
} from '@/services/agent-service';
import { buildMessageListWithUuid } from '@/utils/chat';
Expand Down Expand Up @@ -73,6 +74,8 @@ export const enum AgentApiAction {
FetchSessionByIdManually = 'fetchSessionByIdManually',
FetchAgentLog = 'fetchAgentLog',
FetchSharedAgent = 'fetchSharedAgent',
FetchAgentTags = 'fetchAgentTags',
UpdateAgentTags = 'updateAgentTags',
}

export const useFetchAgentTemplates = () => {
Expand All @@ -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,
Expand All @@ -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;
};
Expand All @@ -129,13 +137,15 @@ export const useFetchAgentListByPage = () => {
? filterValue.canvasCategory
: [];
const owner = filterValue.owner;
const tags = Array.isArray(filterValue.tags) ? filterValue.tags : undefined;

const requestParams = buildAgentListParams({
page: pagination.current,
pageSize: pagination.pageSize,
keywords: debouncedSearchString,
canvasCategory: canvasCategory.length === 1 ? canvasCategory[0] : undefined,
ownerIds: Array.isArray(owner) ? owner : undefined,
tags,
});

const { data, isFetching: loading } = useQuery<{
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions web/src/interfaces/database/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions web/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1693,6 +1693,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.',
Expand Down
5 changes: 5 additions & 0 deletions web/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,11 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
author: '作者',
sectionTitle: '章节标题',
},
editTags: '编辑标签',
editTagsDescription: '添加标签以整理和筛选你的智能体。按回车或逗号添加。',
tagsPlaceholder: '输入标签后按回车',
tagSuggestionsLabel: '现有标签',
removeTagAriaLabel: '删除 {{tag}}',
includeHeadingContent: '分离上级标题正文',
includeHeadingContentTip:
'启用后,每个分块仅保留标题路径和自身内容,与上级标题紧挨着的内容将作为一个独立的块保留。',
Expand Down
Loading
Loading