From 80b81f492494abbbea4007dd20dc7f37e4e2f3bb Mon Sep 17 00:00:00 2001 From: biansimeng Date: Tue, 10 Feb 2026 14:30:08 +0800 Subject: [PATCH 01/58] Modify conversation title generation logic to use question only --- backend/apps/conversation_management_app.py | 10 +- backend/consts/model.py | 2 +- backend/prompts/utils/generate_title_en.yaml | 22 +-- backend/prompts/utils/generate_title_zh.yaml | 22 +-- .../conversation_management_service.py | 47 ++--- .../chat/streaming/chatStreamHandler.tsx | 71 ++++---- frontend/services/conversationService.ts | 4 +- .../app/test_conversation_management_app.py | 10 +- .../test_conversation_management_service.py | 168 +++--------------- 9 files changed, 114 insertions(+), 242 deletions(-) diff --git a/backend/apps/conversation_management_app.py b/backend/apps/conversation_management_app.py index e108fcb4c..9beeedf2e 100644 --- a/backend/apps/conversation_management_app.py +++ b/backend/apps/conversation_management_app.py @@ -175,12 +175,15 @@ async def generate_conversation_title_endpoint( authorization: Optional[str] = Header(None) ): """ - Generate conversation title + Generate conversation title from user question + + This endpoint generates title immediately after user sends a message, + using only the question content instead of waiting for full conversation. Args: request: GenerateTitleRequest object containing: - conversation_id: Conversation ID - - history: Conversation history list + - question: User's question content http_request: http request containing language info authorization: Authorization header @@ -190,7 +193,8 @@ async def generate_conversation_title_endpoint( try: user_id, tenant_id, language = get_current_user_info( authorization=authorization, request=http_request) - title = await generate_conversation_title_service(request.conversation_id, request.history, user_id, tenant_id=tenant_id, language=language) + title = await generate_conversation_title_service( + request.conversation_id, request.question, user_id, tenant_id=tenant_id, language=language) return ConversationResponse(code=0, message="success", data=title) except Exception as e: logging.error(f"Failed to generate conversation title: {str(e)}") diff --git a/backend/consts/model.py b/backend/consts/model.py index d9ff86c91..1aa82be56 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -255,7 +255,7 @@ class GeneratePromptRequest(BaseModel): class GenerateTitleRequest(BaseModel): conversation_id: int - history: List[Dict[str, str]] + question: str # used in agent/search agent/update for save agent info diff --git a/backend/prompts/utils/generate_title_en.yaml b/backend/prompts/utils/generate_title_en.yaml index 12f4a7a74..f1825f71d 100644 --- a/backend/prompts/utils/generate_title_en.yaml +++ b/backend/prompts/utils/generate_title_en.yaml @@ -1,22 +1,22 @@ SYSTEM_PROMPT: |- - Please generate a concise and accurate title (no more than 12 characters) for the following conversation, highlighting the core theme or key information. The title should be natural and fluent, avoiding forced keyword stacking. + Please generate a concise and accurate title (no more than 12 characters) for the following user question, highlighting the core theme or key information. The title should be natural and fluent, avoiding forced keyword stacking. **Title Generation Requirements:** - 1. If the conversation involves specific questions, the title should summarize the essence of the question (e.g., 'How to solve XX issue?'); - 2. If the conversation is about knowledge sharing, the title should highlight the core knowledge point (e.g., 'Key Steps of Photosynthesis'); - 3. Avoid using generic terms like 'Q&A' or 'Consultation', prioritize domain specificity; - 4. The title language should match the conversation language. + 1. The title should summarize the essence of the question (e.g., 'How to solve XX issue?'); + 2. Avoid using generic terms like 'Q&A' or 'Consultation', prioritize domain specificity; + 3. The title language should match the question language; + 4. If the question is very short or casual, use the question itself as the title. **Examples:** - - Conversation: User asking about multiple methods for Python list deduplication → Title: Python List Deduplication Techniques - - Conversation: Discussion about factors affecting new energy vehicle battery life → Title: Factors Affecting EV Battery Life - - Conversation: hello → Title: hello + - Question: What are the common methods for Python list deduplication? → Title: Python List Deduplication Techniques + - Question: What are the factors affecting the battery life of new energy vehicles? → Title: Factors Affecting EV Battery Life + - Question: hello → Title: hello Please output only the generated title without additional explanation. USER_PROMPT: |- - Please generate a concise title (no more than 12 characters) based on the following conversation: - {{ content }} - + Please generate a concise title (no more than 12 characters) based on the following user question: + {{ question }} + Title: \ No newline at end of file diff --git a/backend/prompts/utils/generate_title_zh.yaml b/backend/prompts/utils/generate_title_zh.yaml index 8aa251bd1..cd394ae03 100644 --- a/backend/prompts/utils/generate_title_zh.yaml +++ b/backend/prompts/utils/generate_title_zh.yaml @@ -1,22 +1,22 @@ SYSTEM_PROMPT: |- - 请为以下对话生成一个简洁准确的标题(不超过12个字符),突出核心主题或关键信息。标题应该自然流畅,避免强制堆砌关键词。 + 请为以下用户问题生成一个简洁准确的标题(不超过12个字符),突出核心主题或关键信息。标题应该自然流畅,避免强制堆砌关键词。 **标题生成要求:** - 1. 如果对话涉及具体问题,标题应该概括问题的本质(例如:'如何解决XX问题?'); - 2. 如果对话是关于知识分享,标题应该突出核心知识点(例如:'光合作用关键步骤'); - 3. 避免使用'问答'或'咨询'等通用术语,优先考虑领域特异性; - 4. 标题语言应与对话语言保持一致。 + 1. 标题应该概括问题的本质(例如:'如何解决XX问题?'); + 2. 避免使用'问答'或'咨询'等通用术语,优先考虑领域特异性; + 3. 标题语言应与问题语言保持一致; + 4. 如果问题很短或很随意,直接使用问题本身作为标题。 **示例:** - - 对话:用户询问Python列表去重的多种方法 → 标题:Python列表去重技巧 - - 对话:讨论影响新能源汽车电池寿命的因素 → 标题:影响电动车电池寿命的因素 - - 对话:hello → 标题:hello + - 问题:Python列表去重有哪些常用的方法 → 标题:Python列表去重技巧 + - 问题:影响新能源汽车电池寿命的因素有哪些? → 标题:影响电动车电池寿命的因素 + - 问题:你好 → 标题:你好 请只输出生成的标题,不要添加额外解释。 USER_PROMPT: |- - 请根据以下对话生成一个简洁的标题(不超过12个字符): - {{ content }} - + 请根据以下用户问题生成一个简洁的标题(不超过12个字符): + {{ question }} + 标题: diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index e84257e87..b98e79897 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -226,31 +226,12 @@ def save_conversation_assistant(request: AgentRequest, messages: List[str], user save_message(conversation_req, user_id=user_id, tenant_id=tenant_id) -def extract_user_messages(history: List[Dict[str, str]]) -> str: +def call_llm_for_title(question: str, tenant_id: str, language: str = LANGUAGE["ZH"]) -> str: """ - Extract user message content from conversation history + Call LLM to generate a title from a user question Args: - history: List of conversation history records - - Returns: - str: Concatenated user message content - """ - content = "" - for message in history: - if message.get("role") == MESSAGE_ROLE["USER"] and message.get("content"): - content += f"\n### User Question:\n{message['content']}\n" - if message.get("role") == MESSAGE_ROLE["ASSISTANT"] and message.get("content"): - content += f"\n### Response Content:\n{message['content']}\n" - return content - - -def call_llm_for_title(content: str, tenant_id: str, language: str = LANGUAGE["ZH"]) -> str: - """ - Call LLM to generate a title - - Args: - content: Conversation content + question: User's question content tenant_id: Tenant ID language: Language code ('zh' for Chinese, 'en' for English) @@ -273,16 +254,16 @@ def call_llm_for_title(content: str, tenant_id: str, language: str = LANGUAGE["Z ssl_verify=model_config.get("ssl_verify", True) ) - # Build messages + # Build messages - use new template variable 'question' instead of 'content' user_prompt = Template(prompt_template["USER_PROMPT"], undefined=StrictUndefined).render({ - "content": content + "question": question }) messages = [{"role": MESSAGE_ROLE["SYSTEM"], "content": prompt_template["SYSTEM_PROMPT"]}, {"role": MESSAGE_ROLE["USER"], "content": user_prompt}] - # ModelEngine 只接受 role/content 的简单结构,确保提前扁平化 + # ModelEngine accepts role/content in a simple structure, ensure flattening before passing if model_config.get("model_factory", "").lower() == "modelengine": messages = [{"role": msg["role"], "content": str(msg.get("content", ""))} for msg in messages] @@ -649,13 +630,16 @@ def get_sources_service(conversation_id: Optional[int], message_id: Optional[int } -async def generate_conversation_title_service(conversation_id: int, history: List[Dict[str, str]], user_id: str, tenant_id: str, language: str = LANGUAGE["ZH"]) -> str: +async def generate_conversation_title_service(conversation_id: int, question: str, user_id: str, tenant_id: str, language: str = LANGUAGE["ZH"]) -> str: """ - Generate conversation title + Generate conversation title from user question + + This function is called immediately after user sends a message, + generating title from the question instead of waiting for full conversation. Args: conversation_id: Conversation ID - history: Conversation history list + question: User's question content user_id: User ID tenant_id: Tenant ID language: Language code ('zh' for Chinese, 'en' for English) @@ -664,11 +648,8 @@ async def generate_conversation_title_service(conversation_id: int, history: Lis str: Generated title """ try: - # Extract user messages - content = extract_user_messages(history) - - # Call LLM to generate title in a separate thread to avoid blocking - title = await asyncio.to_thread(call_llm_for_title, content, tenant_id, language) + # Call LLM to generate title from question in a separate thread to avoid blocking + title = await asyncio.to_thread(call_llm_for_title, question, tenant_id, language) # Update conversation title update_conversation_title(conversation_id, title, user_id) diff --git a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx index 628c4f5ca..815e9ee4d 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx @@ -65,6 +65,46 @@ export const handleStreamResponse = async ( output: { content: "", expanded: true }, }; + // Generate conversation title immediately when stream starts (for new conversations) + // This runs in parallel with the streaming response + if (isNewConversation) { + // Use setTimeout to ensure the user message has been added to state + setTimeout(async () => { + try { + // Get the current messages to find the user's question + setMessages((prevMessages) => { + const firstUserMessage = prevMessages.find( + (msg) => msg.role === MESSAGE_ROLES.USER + ); + if (firstUserMessage?.content) { + // Call the generate title from question interface + conversationService + .generateTitle({ + conversation_id: currentConversationId, + question: firstUserMessage.content, + }) + .then((title: string) => { + if (title) { + setConversationTitle(title); + } + // Update the conversation list + fetchConversationList(); + }) + .catch((error: Error) => { + log.error( + t("chatStreamHandler.generateTitleFailed"), + error + ); + }); + } + return prevMessages; + }); + } catch (error) { + log.error(t("chatStreamHandler.generateTitleFailed"), error); + } + }, 0); + } + let lastContentType: | typeof chatConfig.contentTypes.MODEL_OUTPUT | typeof chatConfig.contentTypes.MODEL_OUTPUT_CODE @@ -893,37 +933,6 @@ export const handleStreamResponse = async ( // Update to the deduplicated step list lastMsg.steps = uniqueSteps; } - - // If it is the first answer of a new conversation, generate a title - if (isNewConversation && newMessages.length >= 2) { - // Use setTimeout to ensure the state has been updated - setTimeout(async () => { - try { - // Prepare conversation history - const history = newMessages.map((msg) => ({ - role: msg.role, - content: - msg.role === MESSAGE_ROLES.ASSISTANT - ? msg.finalAnswer || msg.content || "" - : msg.content || "", - })); - - // Call the generate title interface - const title = await conversationService.generateTitle({ - conversation_id: currentConversationId, - history, - }); - // Update the title above the conversation - if (title) { - setConversationTitle(title); - } - // Update the list - await fetchConversationList(); - } catch (error) { - log.error(t("chatStreamHandler.generateTitleFailed"), error); - } - }, 100); // Add a delay to ensure the state has been updated - } } return newMessages; diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index 7537a803c..04908e615 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -788,10 +788,10 @@ export const conversationService = { } }, - // Generate conversation title + // Generate conversation title from user question async generateTitle(params: { conversation_id: number; - history: Array<{ role: 'user' | 'assistant'; content: string; }>; + question: string; }) { const response = await fetch(API_ENDPOINTS.conversation.generateTitle, { method: 'POST', diff --git a/test/backend/app/test_conversation_management_app.py b/test/backend/app/test_conversation_management_app.py index d07c67ec3..b5db691aa 100644 --- a/test/backend/app/test_conversation_management_app.py +++ b/test/backend/app/test_conversation_management_app.py @@ -367,8 +367,8 @@ async def test_get_sources_failure(conversation_mocks): async def test_generate_title_success(conversation_mocks): mock_auth_header = "Bearer test-token" conversation_id = 1 - history = ["Hello"] - dummy_title = "Chat title" + question = "How to use Python effectively?" + dummy_title = "Python Tips" # get_current_user_info returns (user_id, tenant_id, language) conversation_mocks['get_user_info'].return_value = ( @@ -377,7 +377,7 @@ async def test_generate_title_success(conversation_mocks): request_obj = MagicMock() request_obj.conversation_id = conversation_id - request_obj.history = history + request_obj.question = question http_request = MagicMock() @@ -385,7 +385,7 @@ async def test_generate_title_success(conversation_mocks): assert result.code == 0 and result.data == dummy_title conversation_mocks['generate_title_service'].assert_called_once_with( - conversation_id, history, "user_id", tenant_id="tenant_id", language="en") + conversation_id, question, "user_id", tenant_id="tenant_id", language="en") @pytest.mark.asyncio @@ -393,7 +393,7 @@ async def test_generate_title_failure(conversation_mocks): mock_auth_header = "Bearer test-token" request_obj = MagicMock() request_obj.conversation_id = 1 - request_obj.history = [] + request_obj.question = "Test question" http_request = MagicMock() conversation_mocks['get_user_info'].side_effect = Exception("auth fail") diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 584fde756..e939c2885 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -129,23 +129,10 @@ def __exit__(self, exc_type, exc, tb): # Stub utils.prompt_template_utils to avoid requiring PyYAML prompt_mod = types.ModuleType("utils.prompt_template_utils") -prompt_mod.get_generate_title_prompt_template = lambda language="zh": {"USER_PROMPT":"{{content}}", "SYSTEM_PROMPT":"SYS"} +prompt_mod.get_generate_title_prompt_template = lambda language="zh": {"USER_PROMPT":"{{question}}", "SYSTEM_PROMPT":"SYS"} sys.modules["utils.prompt_template_utils"] = prompt_mod - -def test_call_llm_for_title_flattening(monkeypatch): - # Patch tenant_config_manager.get_model_config and prompt template - - monkeypatch.setattr("backend.services.conversation_management_service.tenant_config_manager", types.SimpleNamespace(get_model_config=lambda *a, **k: {"base_url":"u","api_key":"k","model_factory":"modelengine","model_name":"m"})) - monkeypatch.setattr("backend.services.conversation_management_service.get_generate_title_prompt_template", lambda language="zh": {"USER_PROMPT":"{{content}}", "SYSTEM_PROMPT":"SYS"}) - # Stub get_model_name_from_config to avoid dependency on config utils - monkeypatch.setattr("backend.services.conversation_management_service.get_model_name_from_config", lambda cfg: cfg.get("model_name", "") if cfg else "") - - # Call with some content; expect OpenAIModel.generate to receive flattened messages - title = call_llm_for_title("some conversation content", tenant_id="t", language="zh") - assert title == "The Title" - from backend.consts.model import MessageRequest, AgentRequest, MessageUnit import unittest import json @@ -161,7 +148,6 @@ def test_call_llm_for_title_flattening(monkeypatch): save_message, save_conversation_user, save_conversation_assistant, - extract_user_messages, call_llm_for_title, update_conversation_title, create_new_conversation, @@ -209,21 +195,6 @@ def test_get_sources_service_no_id(self): self.assertEqual(result['code'], 400) self.assertEqual(result['message'], "Must provide conversation_id or message_id parameter") - @patch('backend.services.conversation_management_service.extract_user_messages') - @patch('backend.services.conversation_management_service.call_llm_for_title') - @patch('backend.services.conversation_management_service.update_conversation_title') - @patch('backend.services.conversation_management_service.tenant_config_manager.get_model_config') - def test_generate_conversation_title_service_no_title( - self, mock_get_config, mock_update, mock_call_llm, mock_extract - ): - mock_get_config.return_value = {"model_name": "gpt-4", "api_key": "fake"} - mock_extract.return_value = "content" - mock_call_llm.return_value = None - result = asyncio.run(generate_conversation_title_service( - 123, [], self.user_id, self.tenant_id)) - self.assertIsNone(result) - mock_update.assert_called_once_with(123, None, self.user_id) - @patch('backend.services.conversation_management_service.create_conversation_message') @patch('backend.services.conversation_management_service.create_source_search') @patch('backend.services.conversation_management_service.create_source_image') @@ -444,22 +415,6 @@ def test_save_conversation_assistant(self, mock_save_message): unit_content = getattr(first_unit, "content", None) or (first_unit.get("content") if isinstance(first_unit, dict) else None) self.assertEqual(unit_content, "Machine learning is a field of AI") - def test_extract_user_messages(self): - # Setup - history = [ - {"role": "user", "content": "What is AI?"}, - {"role": "assistant", "content": "AI stands for Artificial Intelligence."}, - {"role": "user", "content": "Give me examples of AI applications"} - ] - - # Execute - result = extract_user_messages(history) - - # Assert - self.assertIn("What is AI?", result) - self.assertIn("Give me examples of AI applications", result) - self.assertIn("AI stands for Artificial Intelligence.", result) - @patch('backend.services.conversation_management_service.OpenAIModel') @patch('backend.services.conversation_management_service.get_generate_title_prompt_template') @patch('backend.services.conversation_management_service.tenant_config_manager.get_model_config') @@ -474,7 +429,7 @@ def test_call_llm_for_title(self, mock_get_model_config, mock_get_prompt_templat mock_prompt_template = { "SYSTEM_PROMPT": "Generate a short title", - "USER_PROMPT": "Generate a title for: {{content}}" + "USER_PROMPT": "Generate a title for: {{question}}" } mock_get_prompt_template.return_value = mock_prompt_template @@ -493,70 +448,6 @@ def test_call_llm_for_title(self, mock_get_model_config, mock_get_prompt_templat mock_llm_instance.generate.assert_called_once() mock_get_prompt_template.assert_called_once_with(language='zh') - @patch('backend.services.conversation_management_service.OpenAIModel') - @patch('backend.services.conversation_management_service.get_generate_title_prompt_template') - @patch('backend.services.conversation_management_service.tenant_config_manager.get_model_config') - def test_call_llm_for_title_response_none_zh(self, mock_get_model_config, mock_get_prompt_template, mock_openai): - """Test call_llm_for_title returns default ZH title when response is None.""" - # Setup - mock_get_model_config.return_value = { - "model_name": "gpt-4", - "model_repo": "openai", - "base_url": "http://example.com", - "api_key": "fake-key" - } - - mock_prompt_template = { - "SYSTEM_PROMPT": "Generate a short title", - "USER_PROMPT": "Generate a title for: {{content}}" - } - mock_get_prompt_template.return_value = mock_prompt_template - - mock_llm_instance = mock_openai.return_value - mock_llm_instance.generate.return_value = None - - # Execute - result = call_llm_for_title( - "What is AI?", tenant_id=self.tenant_id, language="zh") - - # Assert - self.assertEqual(result, "新对话") - mock_openai.assert_called_once() - mock_llm_instance.generate.assert_called_once() - mock_get_prompt_template.assert_called_once_with(language='zh') - - @patch('backend.services.conversation_management_service.OpenAIModel') - @patch('backend.services.conversation_management_service.get_generate_title_prompt_template') - @patch('backend.services.conversation_management_service.tenant_config_manager.get_model_config') - def test_call_llm_for_title_response_none_en(self, mock_get_model_config, mock_get_prompt_template, mock_openai): - """Test call_llm_for_title returns default EN title when response is None.""" - # Setup - mock_get_model_config.return_value = { - "model_name": "gpt-4", - "model_repo": "openai", - "base_url": "http://example.com", - "api_key": "fake-key" - } - - mock_prompt_template = { - "SYSTEM_PROMPT": "Generate a short title", - "USER_PROMPT": "Generate a title for: {{content}}" - } - mock_get_prompt_template.return_value = mock_prompt_template - - mock_llm_instance = mock_openai.return_value - mock_llm_instance.generate.return_value = None - - # Execute - result = call_llm_for_title( - "What is AI?", tenant_id=self.tenant_id, language="en") - - # Assert - self.assertEqual(result, "New Conversation") - mock_openai.assert_called_once() - mock_llm_instance.generate.assert_called_once() - mock_get_prompt_template.assert_called_once_with(language='en') - @patch('backend.services.conversation_management_service.rename_conversation') def test_update_conversation_title(self, mock_rename_conversation): # Setup @@ -725,40 +616,6 @@ def test_get_sources_service_by_message(self, mock_get_images, mock_get_searches self.assertEqual(result["data"]["images"][0], "https://example.com/image.jpg") - @patch('backend.services.conversation_management_service.extract_user_messages') - @patch('backend.services.conversation_management_service.call_llm_for_title') - @patch('backend.services.conversation_management_service.update_conversation_title') - @patch('backend.services.conversation_management_service.tenant_config_manager.get_model_config') - def test_generate_conversation_title_service(self, mock_get_model_config, mock_update_title, mock_call_llm, mock_extract_messages): - # Setup - mock_get_model_config.return_value = { - "model_name": "gpt-4", - "model_repo": "openai", - "base_url": "http://example.com", - "api_key": "fake-key" - } - - mock_extract_messages.return_value = "What is AI? AI stands for Artificial Intelligence." - mock_call_llm.return_value = "AI Discussion" - mock_update_title.return_value = True - - history = [ - {"role": "user", "content": "What is AI?"}, - {"role": "assistant", "content": "AI stands for Artificial Intelligence."} - ] - - # Execute - import asyncio - result = asyncio.run(generate_conversation_title_service( - 123, history, self.user_id, self.tenant_id, "en")) - - # Assert - self.assertEqual(result, "AI Discussion") - mock_extract_messages.assert_called_once_with(history) - mock_call_llm.assert_called_once() - mock_update_title.assert_called_once_with( - 123, "AI Discussion", self.user_id) - @patch('backend.services.conversation_management_service.update_message_opinion') def test_update_message_opinion_service(self, mock_update_opinion): # Setup @@ -801,6 +658,27 @@ def test_get_message_id_by_index_impl_not_found(self, mock_get_message): self.assertIn("Message not found", str(ctx.exception)) mock_get_message.assert_called_once_with(123, 2) + # Tests for generate_conversation_title_service + @patch('backend.services.conversation_management_service.call_llm_for_title') + @patch('backend.services.conversation_management_service.update_conversation_title') + def test_generate_conversation_title_service(self, mock_update_title, mock_call_llm): + """Test generate_conversation_title_service generates title from question.""" + # Setup + mock_call_llm.return_value = "Python Tips" + mock_update_title.return_value = True + + # Execute + import asyncio + result = asyncio.run(generate_conversation_title_service( + 123, "How to use Python effectively?", self.user_id, self.tenant_id, "en")) + + # Assert + self.assertEqual(result, "Python Tips") + mock_call_llm.assert_called_once_with( + "How to use Python effectively?", self.tenant_id, "en") + mock_update_title.assert_called_once_with( + 123, "Python Tips", self.user_id) + if __name__ == '__main__': unittest.main() From eefdadc8b41e06b6cc4bc339cb9728002ee36c51 Mon Sep 17 00:00:00 2001 From: panyehong <2655992392@qq.com> Date: Fri, 13 Feb 2026 10:56:54 +0800 Subject: [PATCH 02/58] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Support=20starting?= =?UTF-8?q?=20MCP=20services=20using=20uvx=20#2241=20[Specification=20Deta?= =?UTF-8?q?ils]=201.=20Install=20UV=20dependencies=20during=20image=20buil?= =?UTF-8?q?d.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- make/mcp/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/make/mcp/Dockerfile b/make/mcp/Dockerfile index 4061c84cb..03cb3f1c3 100644 --- a/make/mcp/Dockerfile +++ b/make/mcp/Dockerfile @@ -22,6 +22,9 @@ RUN if [ "$APT_MIRROR" = "tsinghua" ]; then \ # Optional pip mirror for Python packages RUN if [ -n "$MIRROR" ]; then pip config set global.index-url "$MIRROR"; fi +# Install uv (fast Python package installer) +RUN pip install --no-cache-dir uv + ARG MCP_PROXY_VERSION WORKDIR /opt From 53cbacc0bbf745f3afd1d4707339631ec0e0e0cc Mon Sep 17 00:00:00 2001 From: Jasonxia007 Date: Sat, 14 Feb 2026 09:49:14 +0800 Subject: [PATCH 03/58] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20add=20empty=20te?= =?UTF-8?q?nant=5Fid=20judging=20logic=20in=20user=20sync=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/scripts/sync_user_supabase2pg.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/scripts/sync_user_supabase2pg.py b/docker/scripts/sync_user_supabase2pg.py index 43c3b2e15..ecf8259f9 100644 --- a/docker/scripts/sync_user_supabase2pg.py +++ b/docker/scripts/sync_user_supabase2pg.py @@ -317,6 +317,10 @@ def determine_user_role(user_id, tenant_id, user_email): if user_email and user_email.lower() == LEGACY_ADMIN_EMAIL.lower(): return "ADMIN" + # Rule 3: If tenant_id is empty, set it to SU + if not tenant_id: + return "SU" + # Default: USER return "USER" From c2b474263b794b82e4a7cd43bfe09ef7e3371b93 Mon Sep 17 00:00:00 2001 From: feria-tu <57471904+feria-tu@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:47:22 +0800 Subject: [PATCH 04/58] Update opensource-memorial-wall.md --- doc/docs/zh/opensource-memorial-wall.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/docs/zh/opensource-memorial-wall.md b/doc/docs/zh/opensource-memorial-wall.md index b3727b902..d63cab96d 100644 --- a/doc/docs/zh/opensource-memorial-wall.md +++ b/doc/docs/zh/opensource-memorial-wall.md @@ -655,3 +655,7 @@ Nexent开发者加油 ::: info haruhikage1 - 2025-1-22 做个人知识管理系统时发现了Nexent,实时文件导入和自动摘要功能直接解决了我整理笔记的痛点!用自然语言就能调整智能体逻辑,不用写复杂的代码,对我这种非AI专业的开发者太友好了。已经推荐给身边的同行,希望项目越做越好! ::: + +::: info feria-tu - 2026-2-21 +一直在寻找企业级简单好用的智能体平台,Nexent是个非常值得一试的好产品,祝Nexent发展越来越好! +::: From f9d6a5b091823c4bb5ff272d9554dd6c37360846 Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:01:55 +0800 Subject: [PATCH 05/58] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20Tenant=20adminis?= =?UTF-8?q?trator=20account=20delete=20error=20#2522?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/components/UserProfileComp.tsx | 43 ++--------- .../components/auth/DeleteAccountModal.tsx | 75 +++++++++++++++++++ frontend/components/auth/avatarDropdown.tsx | 33 ++++---- frontend/components/auth/index.ts | 1 + frontend/public/locales/en/common.json | 2 +- frontend/public/locales/zh/common.json | 2 +- 6 files changed, 100 insertions(+), 56 deletions(-) create mode 100644 frontend/components/auth/DeleteAccountModal.tsx diff --git a/frontend/app/[locale]/users/components/UserProfileComp.tsx b/frontend/app/[locale]/users/components/UserProfileComp.tsx index 4dce6731e..c00f4f915 100644 --- a/frontend/app/[locale]/users/components/UserProfileComp.tsx +++ b/frontend/app/[locale]/users/components/UserProfileComp.tsx @@ -24,7 +24,6 @@ import { Mail, Edit, Key, - AlertTriangle, ChevronRight, } from "lucide-react"; import { USER_ROLES } from "@/const/modelConfig"; @@ -32,8 +31,7 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; import { useGroupList } from "@/hooks/group/useGroupList"; import { useMemo } from "react"; - -const { Text, Paragraph } = Typography; +import { DeleteAccountModal } from "@/components/auth/DeleteAccountModal"; /** * UserProfileComp - User profile and account settings component @@ -83,7 +81,8 @@ export default function UserProfileComp() { const [editForm] = Form.useForm(); const [passwordForm] = Form.useForm(); - // Get role display name + // Check if user is admin or super admin (cannot delete account) + const isAdminOrSuperAdmin = user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.SU; const getRoleDisplayName = (role: string) => { switch (role) { case USER_ROLES.SPEED: @@ -434,41 +433,13 @@ export default function UserProfileComp() { {/* Delete Account Confirmation Modal */} - - - {t("auth.confirmRevoke") || "Confirm Account Deletion"} - - } + setIsDeleteModalOpen(false)} - okText={t("auth.confirmRevokeOk") || "Delete Anyway"} - okButtonProps={{ danger: true, loading: isLoading }} - cancelText={t("auth.cancel") || "Cancel"} - width={500} - > - -
  • {t("profile.deleteWarning1") || "Your account will be permanently deleted"}
  • -
  • {t("profile.deleteWarning2") || "All your conversations and data will be removed"}
  • -
  • {t("profile.deleteWarning3") || "This action cannot be reversed"}
  • - - } - /> -
    - {t("profile.adminRestrictionTitle") || "Administrator Restriction"} - - {t("auth.refuseRevokePrompt") || "Your role is tenant administrator. Account deletion for admin is not yet supported."} - -
    -
    + loading={isLoading} + disabled={isAdminOrSuperAdmin} + /> ); } diff --git a/frontend/components/auth/DeleteAccountModal.tsx b/frontend/components/auth/DeleteAccountModal.tsx new file mode 100644 index 000000000..a45245bdc --- /dev/null +++ b/frontend/components/auth/DeleteAccountModal.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Modal, Alert, Space, Typography } from "antd"; +import { AlertTriangle } from "lucide-react"; + +const { Text, Paragraph } = Typography; + +interface DeleteAccountModalProps { + open: boolean; + onOk: () => void; + onCancel: () => void; + loading?: boolean; + disabled?: boolean; +} + +/** + * DeleteAccountModal - Shared component for account deletion confirmation + * + * Features: + * - Warning message about permanent deletion + * - Disabled confirm button for admin/super_admin roles + * - Consistent styling across the application + */ +export function DeleteAccountModal({ + open, + onOk, + onCancel, + loading = false, + disabled = false, +}: DeleteAccountModalProps) { + const { t } = useTranslation("common"); + + return ( + + + {t("auth.confirmRevoke") || "Confirm Account Deletion"} + + } + open={open} + onOk={onOk} + onCancel={onCancel} + okText={t("auth.confirmRevokeOk") || "Delete Anyway"} + okButtonProps={{ danger: true, loading, disabled }} + cancelText={t("auth.cancel") || "Cancel"} + width={500} + > + +
  • {t("profile.deleteWarning1") || "Your account will be permanently deleted"}
  • +
  • {t("profile.deleteWarning2") || "All your conversations and data will be removed"}
  • +
  • {t("profile.deleteWarning3") || "This action cannot be reversed"}
  • + + } + /> + {disabled && ( +
    + {t("profile.adminRestrictionTitle") || "Administrator Restriction"} + + {t("auth.refuseRevokePrompt") || "Your role is administrator. Account deletion for admin is not yet supported."} + +
    + )} +
    + ); +} + diff --git a/frontend/components/auth/avatarDropdown.tsx b/frontend/components/auth/avatarDropdown.tsx index b41309dfd..e1c234c75 100644 --- a/frontend/components/auth/avatarDropdown.tsx +++ b/frontend/components/auth/avatarDropdown.tsx @@ -6,21 +6,21 @@ import { Dropdown, Avatar, Spin, Button, Tag, ConfigProvider } from "antd"; import { UserRound, LogOut, LogIn, UserRoundPlus, UserCircle, Power } from "lucide-react"; import type { ItemType } from "antd/es/menu/interface"; import Link from "next/link"; -import { App } from "antd"; import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useConfirmModal } from "@/hooks/useConfirmModal"; import { getRoleColor } from "@/lib/auth"; import { USER_ROLES } from "@/const/auth"; +import { DeleteAccountModal } from "./DeleteAccountModal"; export function AvatarDropdown() { const { user, isAuthzReady } = useAuthorizationContext(); const { isLoading, logout, revoke, openLoginModal, openRegisterModal } = useAuthenticationContext(); const [dropdownOpen, setDropdownOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { t } = useTranslation("common"); - const { modal } = App.useApp(); const { confirm } = useConfirmModal(); // Show loading while authentication is in progress @@ -142,22 +142,7 @@ export function AvatarDropdown() { // danger: true, className: "hover:!bg-red-100 focus:!bg-red-400 focus:!text-white", onClick: () => { - if (user.role === USER_ROLES.ADMIN) { - modal.error({ - title: t("auth.refuseRevoke"), - content: t("auth.refuseRevokePrompt"), - okText: t("auth.confirm"), - }); - } else { - confirm({ - title: t("auth.confirmRevoke"), - content: t("auth.confirmRevokePrompt"), - okText: t("auth.confirmRevokeOk"), - onOk: () => { - revoke(); - }, - }); - } + setIsDeleteModalOpen(true); }, }, ]; @@ -181,6 +166,18 @@ export function AvatarDropdown() { icon={} /> + + {/* Delete Account Confirmation Modal */} + { + revoke(); + setIsDeleteModalOpen(false); + }} + onCancel={() => setIsDeleteModalOpen(false)} + loading={isLoading} + disabled={user.role === USER_ROLES.ADMIN || user.role === USER_ROLES.SU} + /> ); } diff --git a/frontend/components/auth/index.ts b/frontend/components/auth/index.ts index be67fe0a7..b54ca123a 100644 --- a/frontend/components/auth/index.ts +++ b/frontend/components/auth/index.ts @@ -5,3 +5,4 @@ export * from "./avatarDropdown"; export * from "./loginModal"; export * from "./registerModal"; +export * from "./DeleteAccountModal"; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 0c374735e..e47612d90 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -900,7 +900,7 @@ "auth.revokeSuccess": "Account deleted successfully", "auth.revokeFailed": "Account deletion failed, please try again later", "auth.refuseRevoke": "Delete Account", - "auth.refuseRevokePrompt": "Your role is tenant administrator. Account deletion for admin is not yet supported.", + "auth.refuseRevokePrompt": "Your role is administrator. Account deletion for admin is not yet supported.", "auth.adminAccount": "Administrator Account", "auth.adminAccountDescription": "Administrators have more privileges to configure models, create Agents, etc.", "auth.admin": "Admin", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 90b616b75..8268a23dc 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -902,7 +902,7 @@ "auth.revokeSuccess": "账号删除成功", "auth.revokeFailed": "账号删除失败,请稍后重试", "auth.refuseRevoke": "删除账号", - "auth.refuseRevokePrompt": "您的身份为租户管理员,账号暂不支持删除。", + "auth.refuseRevokePrompt": "您的身份为管理员,账号暂不支持删除。", "auth.adminAccount": "管理员账号", "auth.adminAccountDescription": "管理员拥有更多权限,可以配置模型、创建智能体等", "auth.admin": "管理员", From cb1f4d42039f4a086919b816e84f3a1c7d280d93 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Tue, 24 Feb 2026 16:23:25 +0800 Subject: [PATCH 06/58] =?UTF-8?q?=F0=9F=90=9B=20Enhance=20model=20configur?= =?UTF-8?q?ation=20checks=20and=20tool=20management=20for=20VLM=20and=20em?= =?UTF-8?q?bedding=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added checks for VLM and embedding model availability in ToolManagement component. - Updated the image service to return None if the VLM model configuration is missing. - Introduced tool disabling logic based on model availability. - Added internationalization support for error messages related to model configuration. - Updated styles and tooltips for better user experience in the frontend. --- backend/services/image_service.py | 2 + .../agents/components/AgentConfigComp.tsx | 2 - .../components/agentConfig/ToolManagement.tsx | 211 ++++++++++++++++-- frontend/hooks/model/useModelList.ts | 10 + frontend/public/locales/en/common.json | 3 + frontend/public/locales/zh/common.json | 3 + frontend/styles/globals.css | 5 + sdk/nexent/core/tools/analyze_image_tool.py | 28 ++- 8 files changed, 241 insertions(+), 23 deletions(-) diff --git a/backend/services/image_service.py b/backend/services/image_service.py index 5c5f85f9b..8bf03ed44 100644 --- a/backend/services/image_service.py +++ b/backend/services/image_service.py @@ -33,6 +33,8 @@ def get_vlm_model(tenant_id: str): # Get the tenant config vlm_model_config = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["vlm"], tenant_id=tenant_id) + if not vlm_model_config: + return None return OpenAIVLModel( observer=MessageObserver(), model_id=get_model_name_from_config( diff --git a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx index dc87104b5..cb321f32c 100644 --- a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx @@ -91,12 +91,10 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { styles={{ root: { backgroundColor: "#ffffff", - color: "#374151", border: "1px solid #e5e7eb", borderRadius: "6px", boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", - padding: "12px", maxWidth: "800px", minWidth: "700px", width: "fit-content", diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx index e9ba86212..91ce11d33 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -1,17 +1,19 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import ToolConfigModal from "./tool/ToolConfigModal"; import { ToolGroup, Tool, ToolParam } from "@/types/agentConfig"; -import { Tabs, Collapse, message } from "antd"; +import { Tabs, Collapse, message, Tooltip } from "antd"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; import { useToolList } from "@/hooks/agent/useToolList"; +import { useModelList } from "@/hooks/model/useModelList"; import { usePrefetchKnowledgeBases } from "@/hooks/useKnowledgeBaseSelector"; +import { ConfigStore } from "@/lib/config"; import { updateToolConfig } from "@/services/agentConfigService"; import { useQueryClient } from "@tanstack/react-query"; -import { Settings } from "lucide-react"; +import { Settings, AlertTriangle } from "lucide-react"; interface ToolManagementProps { toolGroups: ToolGroup[]; @@ -26,6 +28,16 @@ const TOOLS_REQUIRING_KB_SELECTION = [ "datamate_search", ]; +// Tool types that require Embedding model +const TOOLS_REQUIRING_EMBEDDING = [ + "knowledge_base_search", +]; + +// Tool types that require VLM model +const TOOLS_REQUIRING_VLM = [ + "analyze_image", +]; + function getToolKbType( toolName: string ): "knowledge_base_search" | "dify_search" | "datamate_search" | null { @@ -35,6 +47,22 @@ function getToolKbType( return "knowledge_base_search"; } +/** + * Check if a tool requires VLM model but VLM is not available + */ +function isToolDisabledDueToVlm(toolName: string, vlmAvailable: boolean): boolean { + if (!TOOLS_REQUIRING_VLM.includes(toolName)) return false; + return !vlmAvailable; +} + +/** + * Check if a tool requires Embedding model but Embedding is not available + */ +function isToolDisabledDueToEmbedding(toolName: string, embeddingAvailable: boolean): boolean { + if (!TOOLS_REQUIRING_EMBEDDING.includes(toolName)) return false; + return !embeddingAvailable; +} + /** * ToolManagement - Component for displaying tools in tabs * Provides a tabbed interface for tool organization @@ -62,6 +90,75 @@ export default function ToolManagement({ // Use tool list hook for data management const { availableTools } = useToolList(); + // Get VLM models to check availability + const { availableVlmModels, models } = useModelList(); + + // Check if VLM is properly configured: + // 1. Must have at least one VLM model that passed health check (available) + // 2. Must have a VLM model selected in tenant configuration + const isVlmConfigured = useMemo(() => { + // Check if there's any available VLM model + if (!availableVlmModels || availableVlmModels.length === 0) { + return false; + } + + // Check if tenant configuration has selected a VLM model + try { + const configStore = ConfigStore.getInstance(); + const modelConfig = configStore.getModelConfig(); + const selectedVlmModelName = modelConfig.vlm?.modelName || modelConfig.vlm?.displayName; + + if (!selectedVlmModelName) { + return false; + } + + // Check if the selected VLM model exists in available models + const isSelectedModelAvailable = availableVlmModels.some( + (model) => model.name === selectedVlmModelName || model.displayName === selectedVlmModelName + ); + + return isSelectedModelAvailable; + } catch (error) { + return false; + } + }, [availableVlmModels, models]); + + // Get Embedding models to check availability + const { availableEmbeddingModels } = useModelList(); + + // Check if Embedding is properly configured: + // 1. Must have at least one Embedding model that passed health check (available) + // 2. Must have an Embedding model selected in tenant configuration + const isEmbeddingConfigured = useMemo(() => { + // Check if there's any available Embedding model + if (!availableEmbeddingModels || availableEmbeddingModels.length === 0) { + return false; + } + + // Check if tenant configuration has selected an Embedding model + try { + const configStore = ConfigStore.getInstance(); + const modelConfig = configStore.getModelConfig(); + const selectedEmbeddingModelName = + modelConfig.embedding?.modelName || modelConfig.embedding?.displayName; + + if (!selectedEmbeddingModelName) { + return false; + } + + // Check if the selected Embedding model exists in available models + const isSelectedModelAvailable = availableEmbeddingModels.some( + (model) => + model.name === selectedEmbeddingModelName || + model.displayName === selectedEmbeddingModelName + ); + + return isSelectedModelAvailable; + } catch (error) { + return false; + } + }, [availableEmbeddingModels, models]); + // Prefetch knowledge bases for KB tools const { prefetchKnowledgeBases } = usePrefetchKnowledgeBases(); @@ -324,6 +421,9 @@ export default function ToolManagement({ const isSelected = originalSelectedToolIdsSet.has( tool.id ); + const isDisabledDueToVlm = isToolDisabledDueToVlm(tool.name, isVlmConfigured); + const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingConfigured); + const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding; return (
    handleToolClick(tool.id) : undefined } > - {tool.name} +
    + {tool.name} + {isDisabledDueToVlm && ( + + + + )} + {isDisabledDueToEmbedding && ( + + + + )} +
    { e.stopPropagation(); handleToolSettingsClick(tool); @@ -373,24 +511,65 @@ export default function ToolManagement({ > {group.tools.map((tool) => { const isSelected = originalSelectedToolIdsSet.has(tool.id); + const isDisabledDueToVlm = isToolDisabledDueToVlm(tool.name, isVlmConfigured); + const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingConfigured); + const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding; return (
    handleToolClick(tool.id) : undefined + editable && !isDisabled ? () => handleToolClick(tool.id) : undefined } > - {tool.name} +
    + {tool.name} + {isDisabledDueToVlm && ( + + + + )} + {isDisabledDueToEmbedding && ( + + + + )} +
    { e.stopPropagation(); handleToolSettingsClick(tool); diff --git a/frontend/hooks/model/useModelList.ts b/frontend/hooks/model/useModelList.ts index ac4d72a7a..e7260b940 100644 --- a/frontend/hooks/model/useModelList.ts +++ b/frontend/hooks/model/useModelList.ts @@ -40,6 +40,14 @@ export function useModelList(options?: { enabled?: boolean; staleTime?: number } return models.filter((model) => model.type === "embedding" && model.connect_status === "available"); }, [models]); + const vlmModels = useMemo(() => { + return models.filter((model) => model.type === "vlm"); + }, [models]); + + const availableVlmModels = useMemo(() => { + return models.filter((model) => model.type === "vlm" && model.connect_status === "available"); + }, [models]); + // Get default LLM model from tenant configuration const defaultLlmModel = useMemo(() => { try { @@ -84,6 +92,8 @@ export function useModelList(options?: { enabled?: boolean; staleTime?: number } availableLlmModels, embeddingModels, availableEmbeddingModels, + vlmModels, + availableVlmModels, defaultLlmModel, invalidate: () => queryClient.invalidateQueries({ queryKey: ["models"] }), }; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 0c374735e..2805880eb 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -433,6 +433,9 @@ "toolPool.category.other": "other", "toolPool.noTools": "No tools available", "toolPool.error.requiredFields": "The following required fields are not filled: {{fields}}", + "toolPool.vlmRequired": "VLM model required", + "toolPool.vlmDisabledTooltip": "Please contact your administrator to configure an available Vision Language Model", + "toolPool.embeddingDisabledTooltip": "Please contact your administrator to configure an available Embedding model", "toolPool.tooltip.functionGuide": "1. For local knowledge base search functionality, please enable the knowledge_base_search tool;\n2. For text file parsing functionality, please enable the analyze_text_file tool;\n3. For image parsing functionality, please enable the analyze_image tool.", "tool.message.unavailable": "This tool is currently unavailable and cannot be selected", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 90b616b75..300804d39 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -436,6 +436,9 @@ "toolPool.category.other": "其他", "toolPool.noTools": "暂无可用工具", "toolPool.error.requiredFields": "以下必填字段未填写: {{fields}}", + "toolPool.vlmRequired": "需要配置视觉语言模型", + "toolPool.vlmDisabledTooltip": "请联系管理员配置可用的视觉语言模型", + "toolPool.embeddingDisabledTooltip": "请联系管理员配置可用的向量模型", "toolPool.tooltip.functionGuide": "1. 本地知识库检索功能,请启用knowledge_base_search工具;\n2. 文本文件解析功能,请启用analyze_text_file工具;\n3. 图片解析功能,请启用analyze_image工具。", "tool.message.unavailable": "该工具当前不可用,无法选择", diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 78eb70594..acfb6e84c 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -332,4 +332,9 @@ tr.selected-row > td:first-child::before { z-index: 5; pointer-events: none; border-radius: 0 4px 4px 0; +} + +/* Override antd Tooltip inner border to prevent double borders */ +.ant-tooltip .ant-tooltip-inner { + border: none !important; } \ No newline at end of file diff --git a/sdk/nexent/core/tools/analyze_image_tool.py b/sdk/nexent/core/tools/analyze_image_tool.py index 445134f22..bb5cb4191 100644 --- a/sdk/nexent/core/tools/analyze_image_tool.py +++ b/sdk/nexent/core/tools/analyze_image_tool.py @@ -66,11 +66,16 @@ def __init__( self.observer = observer self.vlm_model = vlm_model self.storage_client = storage_client + + # Determine if the language is Chinese for internationalization + self._is_chinese = bool(observer and observer.lang == "zh") + # Create LoadSaveObjectManager with the storage client self.mm = LoadSaveObjectManager(storage_client=self.storage_client) # Dynamically apply the load_object decorator to forward method - self.forward = self.mm.load_object(input_names=["image_urls_list"])(self._forward_impl) + self.forward = self.mm.load_object( + input_names=["image_urls_list"])(self._forward_impl) self.running_prompt_zh = "正在分析图片..." self.running_prompt_en = "Analyzing image..." @@ -94,9 +99,17 @@ def _forward_impl(self, image_urls_list: List[bytes], query: str) -> List[str]: Raises: Exception: If the image cannot be downloaded or analyzed. """ + # Check if VLM model is available + if self.vlm_model is None: + error_msg_zh = "视觉语言模型(VLM)未配置,请联系管理员配置VLM模型后重试" + error_msg_en = "Vision Language Model (VLM) is not configured. Please contact your administrator to configure the VLM model and try again." + error_msg = error_msg_zh if self._is_chinese else error_msg_en + logger.error(error_msg) + raise Exception(error_msg) + # Send tool run message if self.observer: - running_prompt = self.running_prompt_zh if self.observer.lang == "zh" else self.running_prompt_en + running_prompt = self.running_prompt_zh if self._is_chinese else self.running_prompt_en self.observer.add_message("", ProcessType.TOOL, running_prompt) if image_urls_list is None: @@ -110,8 +123,10 @@ def _forward_impl(self, image_urls_list: List[bytes], query: str) -> List[str]: # Load prompts from yaml file language = self.observer.lang if self.observer else "en" - prompts = get_prompt_template(template_type='analyze_image', language=language) - system_prompt = Template(prompts['system_prompt'], undefined=StrictUndefined).render({'query': query}) + prompts = get_prompt_template( + template_type='analyze_image', language=language) + system_prompt = Template( + prompts['system_prompt'], undefined=StrictUndefined).render({'query': query}) try: analysis_results: List[str] = [] @@ -124,7 +139,10 @@ def _forward_impl(self, image_urls_list: List[bytes], query: str) -> List[str]: system_prompt=system_prompt ) except Exception as e: - raise Exception(f"Error understanding image {index}: {str(e)}") + error_msg_zh = f"图片{index}分析失败: {str(e)}。请检查VLM模型配置是否正确。" + error_msg_en = f"Failed to analyze image {index}: {str(e)}. Please check if the VLM model is configured correctly." + error_msg = error_msg_zh if self._is_chinese else error_msg_en + raise Exception(error_msg) analysis_results.append(response.content) From 90195015b4e1bcf49ff3b81bb29903b229dace63 Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:30:40 +0800 Subject: [PATCH 07/58] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20permission=20err?= =?UTF-8?q?or=20on=20super=20admin=20#2530?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/init.sql b/docker/init.sql index 73c35ac77..58cebd14b 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -996,7 +996,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (184, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), (185, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), (186, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'); -- Insert SPEED role user into user_tenant_t table if not exists INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) From bb38a3ccdf9f75ad0f4cba4bdff14eea77cfcdab Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 24 Feb 2026 17:00:48 +0800 Subject: [PATCH 08/58] bugfix agent info tablebase definition --- backend/database/db_models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 8649b6ae8..6785d7b40 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -201,8 +201,7 @@ class AgentInfo(TableBase): __tablename__ = "ag_tenant_agent_t" __table_args__ = {"schema": SCHEMA} - agent_id = Column(Integer, Sequence( - "ag_tenant_agent_t_agent_id_seq", schema=SCHEMA), nullable=False, primary_key=True, autoincrement=True, doc="ID") + agent_id = Column(Integer, nullable=False, primary_key=True, autoincrement=True, doc="ID") version_no = Column(Integer, default=0, nullable=False, primary_key=True, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") name = Column(String(100), doc="Agent name") display_name = Column(String(100), doc="Agent display name") From 0ae6ed2542080de1ec1b4b301de9766889a96a40 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Tue, 24 Feb 2026 16:38:07 +0800 Subject: [PATCH 09/58] =?UTF-8?q?=F0=9F=90=9B=20Enhance=20model=20configur?= =?UTF-8?q?ation=20checks=20and=20tool=20management=20for=20VLM=20and=20em?= =?UTF-8?q?bedding=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added checks for VLM and embedding model availability in ToolManagement component. - Updated the image service to return None if the VLM model configuration is missing. - Introduced tool disabling logic based on model availability. - Added internationalization support for error messages related to model configuration. - Updated styles and tooltips for better user experience in the frontend. --- backend/services/image_service.py | 23 +- test/backend/services/test_image_service.py | 25 +-- .../sdk/core/tools/test_analyze_image_tool.py | 198 +++++++++++++++++- 3 files changed, 215 insertions(+), 31 deletions(-) diff --git a/backend/services/image_service.py b/backend/services/image_service.py index 8bf03ed44..8decbd541 100644 --- a/backend/services/image_service.py +++ b/backend/services/image_service.py @@ -29,6 +29,7 @@ async def proxy_image_impl(decoded_url: str): result = await response.json() return result + def get_vlm_model(tenant_id: str): # Get the tenant config vlm_model_config = tenant_config_manager.get_model_config( @@ -36,14 +37,14 @@ def get_vlm_model(tenant_id: str): if not vlm_model_config: return None return OpenAIVLModel( - observer=MessageObserver(), - model_id=get_model_name_from_config( - vlm_model_config) if vlm_model_config else "", - api_base=vlm_model_config.get("base_url", ""), - api_key=vlm_model_config.get("api_key", ""), - temperature=0.7, - top_p=0.7, - frequency_penalty=0.5, - max_tokens=512, - ssl_verify=vlm_model_config.get("ssl_verify", True), - ) + observer=MessageObserver(), + model_id=get_model_name_from_config( + vlm_model_config) if vlm_model_config else "", + api_base=vlm_model_config.get("base_url", ""), + api_key=vlm_model_config.get("api_key", ""), + temperature=0.7, + top_p=0.7, + frequency_penalty=0.5, + max_tokens=512, + ssl_verify=vlm_model_config.get("ssl_verify", True), + ) diff --git a/test/backend/services/test_image_service.py b/test/backend/services/test_image_service.py index ad7e105e6..1de8d49fd 100644 --- a/test/backend/services/test_image_service.py +++ b/test/backend/services/test_image_service.py @@ -346,25 +346,16 @@ def test_get_vlm_model_success(mock_tenant_config_manager, mock_get_model_name, @patch('services.image_service.MessageObserver') @patch('services.image_service.get_model_name_from_config') @patch('services.image_service.tenant_config_manager') -def test_get_vlm_model_with_empty_config(mock_tenant_config_manager, mock_get_model_name, mock_message_observer, mock_openai_vl_model): - """Fallback to empty values when tenant config is empty.""" - mock_tenant_config_manager.get_model_config.return_value = {} +def test_get_vlm_model_with_none_config(mock_tenant_config_manager, mock_get_model_name, mock_message_observer, mock_openai_vl_model): + """Return None when tenant config is None.""" + mock_tenant_config_manager.get_model_config.return_value = None mock_model_instance = MagicMock() mock_openai_vl_model.return_value = mock_model_instance - result = get_vlm_model("tenant-2") + result = get_vlm_model("tenant-3") - # get_model_name_from_config should not be called because config is falsy + # get_model_name_from_config should not be called because config is None mock_get_model_name.assert_not_called() - mock_openai_vl_model.assert_called_once_with( - observer=mock_message_observer.return_value, - model_id="", - api_base="", - api_key="", - temperature=0.7, - top_p=0.7, - frequency_penalty=0.5, - max_tokens=512, - ssl_verify=True - ) - assert result == mock_model_instance + # OpenAIVLModel should not be called when config is None + mock_openai_vl_model.assert_not_called() + assert result is None diff --git a/test/sdk/core/tools/test_analyze_image_tool.py b/test/sdk/core/tools/test_analyze_image_tool.py index dd2219de5..c83f99fa0 100644 --- a/test/sdk/core/tools/test_analyze_image_tool.py +++ b/test/sdk/core/tools/test_analyze_image_tool.py @@ -1,6 +1,6 @@ import json from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -86,7 +86,8 @@ def test_forward_impl_zh_observer_messages( vlm_model=mock_vlm_model, storage_client=mock_storage_client, ) - mock_vlm_model.analyze_image.return_value = SimpleNamespace(content="描述") + mock_vlm_model.analyze_image.return_value = SimpleNamespace( + content="描述") result = tool._forward_impl([b"img"], "问题") @@ -114,9 +115,200 @@ def test_forward_impl_wraps_model_errors( with pytest.raises( Exception, - match="Error analyzing image: Error understanding image 1: model failed", + match="Error analyzing image: Failed to analyze image 1: model failed", ): tool._forward_impl([b"img"], "question") mock_vlm_model.analyze_image.assert_called_once() + +class TestAnalyzeImageToolEdgeCases: + """Test edge cases and additional scenarios for AnalyzeImageTool.""" + + def test_forward_impl_vlm_model_none(self, observer_en, mock_storage_client): + """Test that exception is raised when VLM model is None.""" + tool = AnalyzeImageTool( + observer=observer_en, + vlm_model=None, + storage_client=mock_storage_client, + ) + + with pytest.raises(Exception) as exc_info: + tool._forward_impl([b"img"], "question") + + assert "Vision Language Model (VLM) is not configured" in str( + exc_info.value) + + def test_forward_impl_vlm_model_none_chinese(self, observer_zh, mock_storage_client): + """Test that exception is raised in Chinese when VLM model is None and observer is Chinese.""" + tool = AnalyzeImageTool( + observer=observer_zh, + vlm_model=None, + storage_client=mock_storage_client, + ) + + with pytest.raises(Exception) as exc_info: + tool._forward_impl([b"img"], "问题") + + assert "视觉语言模型(VLM)未配置" in str(exc_info.value) + + def test_forward_impl_observer_none_uses_english(self, mock_vlm_model, mock_storage_client): + """Test that English is used when observer is None.""" + tool = AnalyzeImageTool( + observer=None, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + mock_vlm_model.analyze_image.return_value = SimpleNamespace( + content="Analysis result") + + result = tool._forward_impl([b"img"], "question") + + assert result == ["Analysis result"] + + def test_forward_impl_single_image_success(self, tool, mock_vlm_model, mock_prompt_loader): + """Test successful analysis with a single image.""" + mock_vlm_model.analyze_image.return_value = SimpleNamespace( + content="Single image description") + + result = tool._forward_impl( + [b"single_image"], "What is in this image?") + + assert result == ["Single image description"] + mock_vlm_model.analyze_image.assert_called_once() + + def test_is_chinese_property_english(self, observer_en, mock_vlm_model, mock_storage_client): + """Test that _is_chinese is False when observer lang is English.""" + tool = AnalyzeImageTool( + observer=observer_en, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + + assert tool._is_chinese is False + + def test_is_chinese_property_chinese(self, observer_zh, mock_vlm_model, mock_storage_client): + """Test that _is_chinese is True when observer lang is Chinese.""" + tool = AnalyzeImageTool( + observer=observer_zh, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + + assert tool._is_chinese is True + + def test_is_chinese_property_no_observer(self, mock_vlm_model, mock_storage_client): + """Test that _is_chinese is False when observer is None.""" + tool = AnalyzeImageTool( + observer=None, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + + assert tool._is_chinese is False + + def test_running_prompt_properties(self, observer_en, observer_zh, mock_vlm_model, mock_storage_client): + """Test that running prompt properties are set correctly.""" + tool_en = AnalyzeImageTool( + observer=observer_en, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + tool_zh = AnalyzeImageTool( + observer=observer_zh, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + + assert tool_en.running_prompt_en == "Analyzing image..." + assert tool_en.running_prompt_zh == "正在分析图片..." + assert tool_zh.running_prompt_en == "Analyzing image..." + assert tool_zh.running_prompt_zh == "正在分析图片..." + + def test_load_save_object_manager_created(self, mock_vlm_model, mock_storage_client): + """Test that LoadSaveObjectManager is created with storage client.""" + with patch('sdk.nexent.core.tools.analyze_image_tool.LoadSaveObjectManager') as mock_manager_class: + mock_manager_instance = MagicMock() + mock_manager_class.return_value = mock_manager_instance + mock_manager_instance.load_object.return_value = lambda x: x + + tool = AnalyzeImageTool( + observer=MagicMock(), + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + + mock_manager_class.assert_called_once_with( + storage_client=mock_storage_client) + + def test_observer_add_message_called(self, tool, mock_vlm_model, mock_prompt_loader): + """Test that observer.add_message is called with running prompt.""" + mock_vlm_model.analyze_image.return_value = SimpleNamespace( + content="Result") + + tool._forward_impl([b"img"], "question") + + tool.observer.add_message.assert_called_once() + call_args = tool.observer.add_message.call_args + assert call_args[0][0] == "" # first arg is empty string + assert call_args[0][1] == ProcessType.TOOL + assert call_args[0][2] == "Analyzing image..." + + def test_observer_add_message_not_called_when_none(self, mock_vlm_model, mock_storage_client): + """Test that observer.add_message is not called when observer is None.""" + tool = AnalyzeImageTool( + observer=None, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + mock_vlm_model.analyze_image.return_value = SimpleNamespace( + content="Result") + + # Should not raise any exception + result = tool._forward_impl([b"img"], "question") + + assert result == ["Result"] + mock_vlm_model.analyze_image.assert_called_once() + + def test_tool_name_and_description(self, tool): + """Test that tool name and description are set correctly.""" + assert tool.name == "analyze_image" + assert "visual language model" in tool.description.lower() + assert "image" in tool.description.lower() + + def test_tool_inputs_schema(self, tool): + """Test that tool inputs schema is correctly defined.""" + assert "image_urls_list" in tool.inputs + assert "query" in tool.inputs + assert tool.inputs["image_urls_list"]["type"] == "array" + assert tool.inputs["query"]["type"] == "string" + assert tool.output_type == "array" + + def test_tool_category_and_sign(self, tool): + """Test that tool category and sign are set correctly.""" + from sdk.nexent.core.utils.tools_common_message import ToolCategory, ToolSign + assert tool.category == ToolCategory.MULTIMODAL.value + assert tool.tool_sign == ToolSign.MULTIMODAL_OPERATION.value + + @pytest.mark.parametrize("lang,expected_prompt", [ + ("en", "Analyzing image..."), + ("zh", "正在分析图片..."), + ]) + def test_running_prompt_by_language(self, mock_vlm_model, mock_storage_client, lang, expected_prompt): + """Test that running prompt is correctly selected based on language.""" + observer = MagicMock(spec=MessageObserver) + observer.lang = lang + + tool = AnalyzeImageTool( + observer=observer, + vlm_model=mock_vlm_model, + storage_client=mock_storage_client, + ) + + mock_vlm_model.analyze_image.return_value = SimpleNamespace( + content="result") + tool._forward_impl([b"img"], "question") + + # Get the actual prompt passed to add_message + call_args = tool.observer.add_message.call_args[0] + assert call_args[2] == expected_prompt From 36db1086e66b4b0b81c23191830265bf7432042f Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 24 Feb 2026 18:04:38 +0800 Subject: [PATCH 10/58] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20Bugfix:=20agent?= =?UTF-8?q?=20info=20tablebase=20definition=20error=20#2535"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2a519f1f8264d2aacc36f27cdd25d19e38895b24, reversing changes made to 80ad615262b510516fec24ab0fb1ecc3a76409a9. --- backend/database/db_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 6785d7b40..8649b6ae8 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -201,7 +201,8 @@ class AgentInfo(TableBase): __tablename__ = "ag_tenant_agent_t" __table_args__ = {"schema": SCHEMA} - agent_id = Column(Integer, nullable=False, primary_key=True, autoincrement=True, doc="ID") + agent_id = Column(Integer, Sequence( + "ag_tenant_agent_t_agent_id_seq", schema=SCHEMA), nullable=False, primary_key=True, autoincrement=True, doc="ID") version_no = Column(Integer, default=0, nullable=False, primary_key=True, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") name = Column(String(100), doc="Agent name") display_name = Column(String(100), doc="Agent display name") From f62e1e6c275abc8e5b070193ee5fea72ade8fe55 Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Tue, 24 Feb 2026 18:06:50 +0800 Subject: [PATCH 11/58] add sql to fix v1.8.0 user cannot create agent --- docker/init.sql | 2 +- docker/sql/v1.8.1_0224_init_agent_id_seq.sql | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 docker/sql/v1.8.1_0224_init_agent_id_seq.sql diff --git a/docker/init.sql b/docker/init.sql index 73c35ac77..829bd551c 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -296,7 +296,7 @@ COMMENT ON COLUMN nexent.ag_tool_info_t.delete_flag IS 'Whether it is deleted. O -- Create the ag_tenant_agent_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_t ( - agent_id INTEGER NOT NULL, + agent_id SERIAL INTEGER NOT NULL, name VARCHAR(100), display_name VARCHAR(100), description VARCHAR, diff --git a/docker/sql/v1.8.1_0224_init_agent_id_seq.sql b/docker/sql/v1.8.1_0224_init_agent_id_seq.sql new file mode 100644 index 000000000..67b6bd091 --- /dev/null +++ b/docker/sql/v1.8.1_0224_init_agent_id_seq.sql @@ -0,0 +1,6 @@ +CREATE SEQUENCE IF NOT EXISTS "nexent"."ag_tenant_agent_t_agent_id_seq" +INCREMENT 1 +MINVALUE 1 +MAXVALUE 2147483647 +START 1 +CACHE 1; \ No newline at end of file From 2b2fbe3406d0b1e64c0823fb7cd800704b6f6cea Mon Sep 17 00:00:00 2001 From: xuyaqist Date: Wed, 25 Feb 2026 09:35:54 +0800 Subject: [PATCH 12/58] bugfix sql field cannot be both integer and serial --- docker/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/init.sql b/docker/init.sql index dad69e555..cea4ca076 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -296,7 +296,7 @@ COMMENT ON COLUMN nexent.ag_tool_info_t.delete_flag IS 'Whether it is deleted. O -- Create the ag_tenant_agent_t table in the nexent schema CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_t ( - agent_id SERIAL INTEGER NOT NULL, + agent_id SERIAL NOT NULL, name VARCHAR(100), display_name VARCHAR(100), description VARCHAR, From 6bd4d9a1ea7e8535d17ace143dd4473b6d51566c Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:05:38 +0800 Subject: [PATCH 13/58] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20When=20deleting?= =?UTF-8?q?=20an=20Agent,=20the=20display=20name=20should=20be=20used=20#2?= =?UTF-8?q?067?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/[locale]/agents/components/agentManage/AgentList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx b/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx index 9b8ecdf55..0e278459b 100644 --- a/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx +++ b/frontend/app/[locale]/agents/components/agentManage/AgentList.tsx @@ -274,7 +274,7 @@ export default function AgentList({ onSuccess: () => { message.success( t("businessLogic.config.error.agentDeleteSuccess", { - name: agent.name, + name: agent.display_name || agent.name || "", }) ); @@ -300,7 +300,7 @@ export default function AgentList({ confirm.confirm({ title: t("businessLogic.config.modal.deleteTitle"), content: t("businessLogic.config.modal.deleteContent", { - name: agent.name, + name: agent.display_name || agent.name || "", }), onOk: () => handleDeleteAgent(agent), }); From 8b5dc033933e800a8b518374fd5e0f2beb450ba0 Mon Sep 17 00:00:00 2001 From: "XUYAQIDE\\xuyaq" Date: Wed, 25 Feb 2026 10:47:45 +0800 Subject: [PATCH 14/58] Bugfix: fix default modal_id is 0 when enter create mode (cherry picked from commit 303a8e571e5d6f289e57db425f9d676dc36e8cdf) --- .../agents/components/AgentInfoComp.tsx | 25 +------ .../agentInfo/AgentGenerateDetail.tsx | 70 ++++++++++--------- 2 files changed, 38 insertions(+), 57 deletions(-) diff --git a/frontend/app/[locale]/agents/components/AgentInfoComp.tsx b/frontend/app/[locale]/agents/components/AgentInfoComp.tsx index 0114fd5d0..7ae8548b0 100644 --- a/frontend/app/[locale]/agents/components/AgentInfoComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentInfoComp.tsx @@ -30,15 +30,8 @@ export default function AgentInfoComp({ }: AgentInfoCompProps) { const { t } = useTranslation("common"); - // Get data from store - const { - editedAgent, - updateBusinessInfo, - updateProfileInfo, - isCreatingMode, - currentAgentPermission, - } = useAgentConfigStore(); - + const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); + const currentAgentPermission = useAgentConfigStore((state) => state.currentAgentPermission); const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); const isPanelActive = (currentAgentId != null && currentAgentId != undefined) || isCreatingMode; @@ -62,16 +55,6 @@ export default function AgentInfoComp({ // Generation state shared with AgentGenerateDetail const [isGenerating, setIsGenerating] = useState(false); - // Handle business info updates - const handleUpdateBusinessInfo = (updates: AgentBusinessInfo) => { - updateBusinessInfo(updates); - }; - - // Handle profile info updates - const handleUpdateProfile = (updates: AgentProfileInfo) => { - updateProfileInfo(updates); - }; - return ( <> { @@ -131,10 +114,6 @@ export default function AgentInfoComp({ diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index 6f52f52a0..f3e61897d 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -40,26 +40,20 @@ import { useTenantList } from "@/hooks/tenant/useTenantList"; import { useGroupList } from "@/hooks/group/useGroupList"; import { USER_ROLES } from "@/const/auth"; import { Can } from "@/components/permission/Can"; +import { useAgentConfigStore } from "@/stores/agentConfigStore"; import ExpandEditModal from "./ExpandEditModal"; const { TextArea } = Input; export interface AgentGenerateDetailProps { editable: boolean; - editedAgent: EditableAgent; currentAgentId?: number | null; - onUpdateProfile: (updates: AgentProfileInfo) => void; - onUpdateBusinessInfo: (updates: AgentBusinessInfo) => void; isGenerating: boolean; setIsGenerating: (value: boolean) => void; } export default function AgentGenerateDetail({ editable = false, - editedAgent, - currentAgentId, - onUpdateProfile, - onUpdateBusinessInfo, isGenerating, setIsGenerating, }: AgentGenerateDetailProps) { @@ -69,6 +63,12 @@ export default function AgentGenerateDetail({ const { isSpeedMode } = useDeployment(); const [form] = Form.useForm(); + const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); + const editedAgent = useAgentConfigStore((state) => state.editedAgent); + const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); + const updateBusinessInfo = useAgentConfigStore((state) => state.updateBusinessInfo); + const updateProfileInfo = useAgentConfigStore((state) => state.updateProfileInfo); + // Model data from React Query const { availableLlmModels, defaultLlmModel, isLoading: loadingModels } = useModelList(); @@ -203,12 +203,8 @@ export default function AgentGenerateDetail({ // Initialize form values when component mounts or currentAgentId changes useEffect(() => { - const isCreateMode = editable && (currentAgentId === null || currentAgentId === undefined); + console.log("avaliableLlmModels", isCreatingMode, availableLlmModels); - // Note: - // In create mode, do not set group_ids here. Otherwise, when switching from an existing - // agent to create mode (currentAgentId changes to null), this initializer can overwrite - // the default-group selection effect and leave group_ids empty. const initialAgentInfo: Record = { agentName: editedAgent.name || "", agentDisplayName: editedAgent.display_name || "", @@ -223,7 +219,7 @@ export default function AgentGenerateDetail({ fewShotsPrompt: editedAgent.few_shots_prompt || "", }; - if (isCreateMode) { + if (isCreatingMode) { delete initialAgentInfo.group_ids; } @@ -241,8 +237,9 @@ export default function AgentGenerateDetail({ form.setFieldsValue(initialAgentInfo); // Sync model to store if not already set (e.g., in create mode with default model) - if ((isCreateMode || !editedAgent.model) && defaultLlmModel) { - onUpdateProfile({ + if (isCreatingMode && defaultLlmModel) { + console.log("111defaultLlmModel", defaultLlmModel); + updateProfileInfo({ model: defaultLlmModel.displayName || "", model_id: defaultLlmModel.id || 0, }); @@ -250,7 +247,7 @@ export default function AgentGenerateDetail({ // We intentionally initialize the form only when switching agent (or when default model becomes available), // otherwise it can create update loops with Form-controlled fields updating the store. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentAgentId, defaultLlmModel?.id]); + }, [currentAgentId, defaultLlmModel?.id, isCreatingMode]); // Default to selecting all groups when creating a new agent. // Only applies when groups are loaded and no group is selected yet. @@ -275,13 +272,14 @@ export default function AgentGenerateDetail({ if (allGroupIds.length === 0) return; form.setFieldsValue({ group_ids: allGroupIds }); - onUpdateProfile({ group_ids: allGroupIds }); + updateProfileInfo + ({ group_ids: allGroupIds }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [editable, currentAgentId, groups, allowedGroupIds, user?.role]); // Handle business description change const handleBusinessDescriptionChange = (value: string) => { - onUpdateBusinessInfo({ + updateBusinessInfo({ business_description: value, business_logic_model_id: businessInfo.businessLogicModelId, business_logic_model_name: businessInfo.businessLogicModelName, @@ -299,7 +297,7 @@ export default function AgentGenerateDetail({ businessLogicModelName: modelName, businessLogicModelId: selectedModel?.id || 0, })); - onUpdateBusinessInfo({ + updateBusinessInfo({ business_description: businessInfo.businessDescription || "", business_logic_model_id: selectedModel?.id || 0, business_logic_model_name: modelName, @@ -372,15 +370,15 @@ export default function AgentGenerateDetail({ switch (expandModalType) { case 'duty': form.setFieldsValue({ dutyPrompt: content }); - onUpdateProfile({ duty_prompt: content }); + updateProfileInfo({ duty_prompt: content }); break; case 'constraint': form.setFieldsValue({ constraintPrompt: content }); - onUpdateProfile({ constraint_prompt: content }); + updateProfileInfo({ constraint_prompt: content }); break; case 'few-shots': form.setFieldsValue({ fewShotsPrompt: content }); - onUpdateProfile({ few_shots_prompt: content }); + updateProfileInfo({ few_shots_prompt: content }); break; } handleCloseExpandModal(); @@ -538,8 +536,8 @@ export default function AgentGenerateDetail({ few_shots_prompt: formValues.fewShotsPrompt, }; - // Update parent component state - onUpdateProfile(profileUpdates); + // Update profile info in global agent config store + updateProfileInfo(profileUpdates); message.success(t("businessLogic.config.message.generateSuccess")); setIsGenerating(false); @@ -590,7 +588,7 @@ export default function AgentGenerateDetail({ - onUpdateProfile({ display_name: e.target.value }) + updateProfileInfo({ display_name: e.target.value }) } /> @@ -614,7 +612,9 @@ export default function AgentGenerateDetail({ > onUpdateProfile({ name: e.target.value })} + onChange={(e) => + updateProfileInfo({ name: e.target.value }) + } /> @@ -640,7 +640,7 @@ export default function AgentGenerateDetail({ ) { return; } - onUpdateProfile({ group_ids: nextGroupIds }); + updateProfileInfo({ group_ids: nextGroupIds }); }} /> @@ -659,7 +659,9 @@ export default function AgentGenerateDetail({ > onUpdateProfile({ author: e.target.value })} + onBlur={(e) => + updateProfileInfo({ author: e.target.value }) + } /> @@ -684,7 +686,7 @@ export default function AgentGenerateDetail({ const selectedModel = availableLlmModels.find( (m) => m.displayName === value ); - onUpdateProfile({ + updateProfileInfo({ model: value, model_id: selectedModel?.id || 0, }); @@ -725,7 +727,7 @@ export default function AgentGenerateDetail({ style={{ width: "100%" }} onBlur={() => { const value = form.getFieldValue("mainAgentMaxStep"); - onUpdateProfile({ max_step: value || 1 }); + updateProfileInfo({ max_step: value || 1 }); }} /> @@ -740,7 +742,7 @@ export default function AgentGenerateDetail({ rows={6} style={{ minHeight: "150px" }} onBlur={(e) => - onUpdateProfile({ description: e.target.value }) + updateProfileInfo({ description: e.target.value }) } /> @@ -767,7 +769,7 @@ export default function AgentGenerateDetail({ {renderPromptEditor( "dutyPrompt", t("systemPrompt.card.duty.title"), - (value) => onUpdateProfile({ duty_prompt: value }) + (value) => updateProfileInfo({ duty_prompt: value }) )}
    @@ -789,7 +791,7 @@ export default function AgentGenerateDetail({ {renderPromptEditor( "constraintPrompt", t("systemPrompt.card.constraint.title"), - (value) => onUpdateProfile({ constraint_prompt: value }) + (value) => updateProfileInfo({ constraint_prompt: value }) )}
    @@ -811,7 +813,7 @@ export default function AgentGenerateDetail({ {renderPromptEditor( "fewShotsPrompt", t("systemPrompt.card.fewShots.title"), - (value) => onUpdateProfile({ few_shots_prompt: value }) + (value) => updateProfileInfo({ few_shots_prompt: value }) )} From b0e8331811a2dbec1bf8671f5528f32d56d90538 Mon Sep 17 00:00:00 2001 From: "XUYAQIDE\\xuyaq" Date: Wed, 25 Feb 2026 10:53:25 +0800 Subject: [PATCH 15/58] delete log --- .../agents/components/agentInfo/AgentGenerateDetail.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index f3e61897d..ac7c1824d 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -203,7 +203,6 @@ export default function AgentGenerateDetail({ // Initialize form values when component mounts or currentAgentId changes useEffect(() => { - console.log("avaliableLlmModels", isCreatingMode, availableLlmModels); const initialAgentInfo: Record = { agentName: editedAgent.name || "", @@ -238,15 +237,12 @@ export default function AgentGenerateDetail({ form.setFieldsValue(initialAgentInfo); // Sync model to store if not already set (e.g., in create mode with default model) if (isCreatingMode && defaultLlmModel) { - console.log("111defaultLlmModel", defaultLlmModel); updateProfileInfo({ model: defaultLlmModel.displayName || "", model_id: defaultLlmModel.id || 0, }); } - // We intentionally initialize the form only when switching agent (or when default model becomes available), - // otherwise it can create update loops with Form-controlled fields updating the store. - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentAgentId, defaultLlmModel?.id, isCreatingMode]); // Default to selecting all groups when creating a new agent. From 233a5fa2fadb0a7dbf6b8eb9f3e34ed643ed135e Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:52:05 +0800 Subject: [PATCH 16/58] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20the=20tenant=20l?= =?UTF-8?q?ist=20query=20interface=20need=20to=20be=20paginated=20#2521?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/tenant_app.py | 33 +++-- backend/consts/model.py | 11 +- backend/services/tenant_service.py | 35 ++++-- .../agentInfo/AgentGenerateDetail.tsx | 2 +- .../components/UserManageComp.tsx | 85 ++++++++++--- frontend/hooks/tenant/useTenantList.ts | 22 +++- frontend/services/api.ts | 2 +- frontend/services/tenantService.ts | 35 +++++- test/backend/app/test_tenant_app.py | 63 ++++++++-- test/backend/services/test_tenant_service.py | 118 +++++++++++++----- 10 files changed, 316 insertions(+), 90 deletions(-) diff --git a/backend/apps/tenant_app.py b/backend/apps/tenant_app.py index 07215b5b2..25c11413d 100644 --- a/backend/apps/tenant_app.py +++ b/backend/apps/tenant_app.py @@ -4,13 +4,17 @@ import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Header +from fastapi import APIRouter, HTTPException, Header, Body from http import HTTPStatus from starlette.responses import JSONResponse -from consts.model import TenantCreateRequest, TenantUpdateRequest +from consts.model import ( + PaginationRequest, + TenantCreateRequest, + TenantUpdateRequest, +) from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError -from services.tenant_service import create_tenant, get_tenant_info, get_all_tenants, update_tenant_info +from services.tenant_service import create_tenant, get_tenant_info, get_tenants_paginated, update_tenant_info from utils.auth_utils import get_current_user_id logger = logging.getLogger(__name__) @@ -109,23 +113,32 @@ async def get_tenant_endpoint(tenant_id: str) -> JSONResponse: ) -@router.get("") -async def get_all_tenants_endpoint() -> JSONResponse: +@router.post("/tenant-list") +async def get_all_tenants_endpoint( + pagination: PaginationRequest = Body(...) +) -> JSONResponse: """ - Get all tenants + Get all tenants with pagination support + + Args: + pagination: Pagination parameters (page, page_size) Returns: - JSONResponse: List of all tenants + JSONResponse: Paginated list of tenants with total count """ try: - # Get all tenants - tenants = get_all_tenants() + # Get paginated tenants + result = get_tenants_paginated(page=pagination.page, page_size=pagination.page_size) return JSONResponse( status_code=HTTPStatus.OK, content={ "message": "Tenants retrieved successfully", - "data": tenants + "data": result["data"], + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + "total_pages": result["total_pages"] } ) diff --git a/backend/consts/model.py b/backend/consts/model.py index a4862cd59..d45f948d1 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -507,12 +507,11 @@ class TenantUpdateRequest(BaseModel): description="New tenant display name") -class TenantResponse(BaseModel): - """Response model for tenant information""" - tenant_id: str = Field(..., description="Tenant identifier") - tenant_name: str = Field(..., description="Tenant display name") - default_group_id: Optional[int] = Field( - None, description="Default group ID for the tenant") +# Pagination request model +class PaginationRequest(BaseModel): + """Request model for pagination parameters""" + page: int = Field(1, ge=1, description="Page number") + page_size: int = Field(20, ge=1, le=100, description="Items per page") # Group Management Data Models diff --git a/backend/services/tenant_service.py b/backend/services/tenant_service.py index 0da209b76..afc38c28e 100644 --- a/backend/services/tenant_service.py +++ b/backend/services/tenant_service.py @@ -112,22 +112,35 @@ def check_tenant_name_exists(tenant_name: str, exclude_tenant_id: Optional[str] return False -def get_all_tenants() -> List[Dict[str, Any]]: +def get_tenants_paginated(page: int = 1, page_size: int = 20) -> Dict[str, Any]: """ - Get all tenants + Get tenants with pagination support + + Args: + page (int): Page number (starting from 1) + page_size (int): Number of items per page Returns: - List[Dict[str, Any]]: List of all tenant information + Dict[str, Any]: Dictionary containing paginated tenant data and pagination info """ - tenant_ids = get_all_tenant_ids() - tenants = [] + # Get all tenant IDs first + all_tenant_ids = get_all_tenant_ids() + total = len(all_tenant_ids) - for tenant_id in tenant_ids: + # Calculate pagination + total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + + # Get tenant IDs for current page + page_tenant_ids = all_tenant_ids[start_idx:end_idx] + + tenants = [] + for tenant_id in page_tenant_ids: try: tenant_info = get_tenant_info(tenant_id) tenants.append(tenant_info) except NotFoundException: - # Return tenant with basic info but empty name for frontend to show as "unnamed tenant" logging.warning(f"Tenant info of {tenant_id} not found. Returning basic tenant structure.") tenant_info = { "tenant_id": tenant_id, @@ -136,7 +149,13 @@ def get_all_tenants() -> List[Dict[str, Any]]: } tenants.append(tenant_info) - return tenants + return { + "data": tenants, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages + } def create_tenant(tenant_name: str, created_by: Optional[str] = None) -> Dict[str, Any]: diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index 6f52f52a0..715c295a1 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -74,7 +74,7 @@ export default function AgentGenerateDetail({ // Tenant & group data for group selection const { data: tenantData } = useTenantList(); - const tenantId = user?.tenantId ?? tenantData?.[0]?.tenant_id ?? null; + const tenantId = user?.tenantId ?? tenantData?.data?.[0]?.tenant_id ?? null; const { data: groupData } = useGroupList(tenantId, 1, 100); const groups = groupData?.groups || []; diff --git a/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx b/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx index a8ba0035f..e4ae9edce 100644 --- a/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx +++ b/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx @@ -12,6 +12,8 @@ import { Input, Popconfirm, message, + Spin, + Pagination, } from "antd"; import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; @@ -34,13 +36,20 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { USER_ROLES } from "@/const/auth"; import { Can } from "@/components/permission/Can"; +// Default page size for pagination +const DEFAULT_PAGE_SIZE = 20; + // Removed mockTenants - now using real data from API function TenantList({ selected, onSelect, tenants, - onTenantsChange, + total, + page, + pageSize, + totalPages, + onPageChange, onTenantsRefetch, loading, t, @@ -48,7 +57,11 @@ function TenantList({ selected: string | null; onSelect: (id: string) => void; tenants: Tenant[]; - onTenantsChange: (tenants: Tenant[]) => void; + total?: number; + page?: number; + pageSize?: number; + totalPages?: number; + onPageChange?: (page: number) => void; onTenantsRefetch: () => void; loading?: boolean; t: (key: string, options?: any) => string; @@ -57,6 +70,7 @@ function TenantList({ const [modalVisible, setModalVisible] = useState(false); const [form] = Form.useForm(); + // Handle scroll event for infinite loading const openCreate = () => { setEditingTenant(null); form.resetFields(); @@ -136,17 +150,21 @@ function TenantList({ className="p-1 hover:bg-gray-100 rounded" /> -
    - {loading ? ( +
    + {loading && tenants.length === 0 ? (
    - Loading tenants... + Loading tenants...
    ) : tenants.length === 0 ? (
    No tenants found
    ) : ( - tenants.map((tenant) => ( + <> + {tenants.map((tenant, index) => (
    - )) + ))} + )}
    + {/* Pagination */} + {total !== undefined && total > 0 && ( +
    + +
    + )} + {/* Tenant Modal */} { + setCurrentPage(page); + }; + + // Reset tenants when page changes to super admin + useEffect(() => { + if (isSuperAdmin) { + setCurrentPage(1); + } + }, [isSuperAdmin]); // Tenant management state for super admin operations const [tenantsState, setTenantsState] = useState([]); @@ -263,7 +311,7 @@ export default function UserManageComp() { }, [isSuperAdmin, tenantId, user?.tenantId]); // Get current tenant name - const currentTenant = tenants.find((t) => t.tenant_id === tenantId); + const currentTenant = tenantData?.data?.find((t: Tenant) => t.tenant_id === tenantId); const currentTenantName = currentTenant?.tenant_name || t("tenantResources.tenants.unnamed"); // Tenant name editing states @@ -355,9 +403,16 @@ export default function UserManageComp() { setTenantId(id)} - tenants={tenants} - onTenantsChange={setTenantsState} - onTenantsRefetch={refetchTenants} + tenants={tenantData?.data || []} + total={tenantData?.total} + page={tenantData?.page} + pageSize={tenantData?.page_size} + totalPages={tenantData?.total_pages} + onPageChange={handlePageChange} + onTenantsRefetch={() => { + setCurrentPage(1); + refetchTenants(); + }} loading={tenantsLoading} t={t} /> diff --git a/frontend/hooks/tenant/useTenantList.ts b/frontend/hooks/tenant/useTenantList.ts index e62654104..a661986f2 100644 --- a/frontend/hooks/tenant/useTenantList.ts +++ b/frontend/hooks/tenant/useTenantList.ts @@ -1,10 +1,24 @@ import { useQuery } from "@tanstack/react-query"; -import { listTenants } from "@/services/tenantService"; +import { listTenants, Tenant } from "@/services/tenantService"; +import log from "@/lib/logger"; -export function useTenantList() { +export interface TenantListResult { + data: Tenant[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export function useTenantList(params?: { page?: number; page_size?: number }) { return useQuery({ - queryKey: ["tenants"], - queryFn: () => listTenants(), + queryKey: ["tenants", params?.page ?? 1, params?.page_size ?? 20], + queryFn: async () => { + log.info("[useTenantList] Fetching tenants with params:", params); + const result = await listTenants(params); + log.info("[useTenantList] Received result:", result); + return result; + }, staleTime: 1000 * 60, // Cache for 1 minute }); } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 29b676e16..fbf5b4336 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -258,7 +258,7 @@ export const API_ENDPOINTS = { `${API_BASE_URL}/market/agents/${agentId}/mcp_servers`, }, tenant: { - list: `${API_BASE_URL}/tenants`, + list: `${API_BASE_URL}/tenants/tenant-list`, create: `${API_BASE_URL}/tenants`, detail: (tenantId: string) => `${API_BASE_URL}/tenants/${tenantId}`, update: (tenantId: string) => `${API_BASE_URL}/tenants/${tenantId}`, diff --git a/frontend/services/tenantService.ts b/frontend/services/tenantService.ts index e695220b3..07ea645b6 100644 --- a/frontend/services/tenantService.ts +++ b/frontend/services/tenantService.ts @@ -25,6 +25,15 @@ export interface TenantListResponse { message: string; } +export interface TenantListPaginatedResponse { + data: Tenant[]; + message: string; + total: number; + page: number; + page_size: number; + total_pages: number; +} + export interface TenantDetailResponse { data: Tenant; message: string; @@ -35,17 +44,31 @@ export interface CreateTenantResponse { message: string; } +export interface ListTenantsParams { + page?: number; + page_size?: number; +} + /** - * List all tenants (filtered by user permissions) + * List tenants with pagination support (filtered by user permissions) */ -export async function listTenants(): Promise { +export async function listTenants(params?: ListTenantsParams): Promise { try { - const response = await fetchWithAuth(API_ENDPOINTS.tenant.list, { - method: "GET", + const url = API_ENDPOINTS.tenant.list; + + const response = await fetchWithAuth(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + page: params?.page ?? 1, + page_size: params?.page_size ?? 20, + }), }); - const result: TenantListResponse = await response.json(); - return result.data; + const result: TenantListPaginatedResponse = await response.json(); + return result; } catch (error) { if (error instanceof ApiError) { throw error; diff --git a/test/backend/app/test_tenant_app.py b/test/backend/app/test_tenant_app.py index c023746b3..d1918e743 100644 --- a/test/backend/app/test_tenant_app.py +++ b/test/backend/app/test_tenant_app.py @@ -26,7 +26,7 @@ # Import exception classes and models from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError -from consts.model import TenantCreateRequest, TenantUpdateRequest +from consts.model import TenantCreateRequest, TenantUpdateRequest, PaginationRequest # Import the modules we need from fastapi.testclient import TestClient @@ -174,7 +174,7 @@ def test_get_tenant_unexpected_error(self): assert data["detail"] == "Failed to retrieve tenant" def test_get_all_tenants_success(self): - """Test successful retrieval of all tenants""" + """Test successful retrieval of all tenants with pagination""" mock_tenants = [ { "tenant_id": "tenant-123", @@ -188,23 +188,68 @@ def test_get_all_tenants_success(self): } ] - with patch('apps.tenant_app.get_all_tenants') as mock_get_all_tenants: - mock_get_all_tenants.return_value = mock_tenants + with patch('apps.tenant_app.get_tenants_paginated') as mock_get_tenants: + mock_get_tenants.return_value = { + "data": mock_tenants, + "total": 2, + "page": 1, + "page_size": 20, + "total_pages": 1 + } + + request_data = { + "page": 1, + "page_size": 20 + } - response = client.get("/tenants") + response = client.post("/tenants/tenant-list", json=request_data) assert response.status_code == HTTPStatus.OK data = response.json() assert data["message"] == "Tenants retrieved successfully" assert data["data"] == mock_tenants - mock_get_all_tenants.assert_called_once() + assert data["total"] == 2 + assert data["page"] == 1 + assert data["page_size"] == 20 + assert data["total_pages"] == 1 + mock_get_tenants.assert_called_once_with(page=1, page_size=20) + + def test_get_all_tenants_pagination(self): + """Test tenant list with custom pagination parameters""" + with patch('apps.tenant_app.get_tenants_paginated') as mock_get_tenants: + mock_get_tenants.return_value = { + "data": [], + "total": 100, + "page": 2, + "page_size": 10, + "total_pages": 10 + } + + request_data = { + "page": 2, + "page_size": 10 + } + + response = client.post("/tenants/tenant-list", json=request_data) + + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["page"] == 2 + assert data["page_size"] == 10 + assert data["total"] == 100 + mock_get_tenants.assert_called_once_with(page=2, page_size=10) def test_get_all_tenants_unexpected_error(self): """Test retrieval of all tenants with unexpected error""" - with patch('apps.tenant_app.get_all_tenants') as mock_get_all_tenants: - mock_get_all_tenants.side_effect = Exception("Database error") + with patch('apps.tenant_app.get_tenants_paginated') as mock_get_tenants: + mock_get_tenants.side_effect = Exception("Database error") + + request_data = { + "page": 1, + "page_size": 20 + } - response = client.get("/tenants") + response = client.post("/tenants/tenant-list", json=request_data) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR data = response.json() diff --git a/test/backend/services/test_tenant_service.py b/test/backend/services/test_tenant_service.py index ad5479603..412ff7bcd 100644 --- a/test/backend/services/test_tenant_service.py +++ b/test/backend/services/test_tenant_service.py @@ -21,10 +21,10 @@ patch('backend.database.client.MinioClient', return_value=minio_client_mock).start() -from consts.exceptions import NotFoundException, ValidationError +from consts.exceptions import ValidationError from backend.services.tenant_service import ( get_tenant_info, - get_all_tenants, + get_tenants_paginated, create_tenant, update_tenant_info, delete_tenant, @@ -169,11 +169,11 @@ def test_get_tenant_info_both_configs_none(self, service_mocks): service_mocks['insert_config'].assert_called_once() -class TestGetAllTenants: - """Test cases for get_all_tenants function""" +class TestGetTenantsPaginated: + """Test cases for get_tenants_paginated function""" - def test_get_all_tenants_success(self, service_mocks): - """Test successfully retrieving all tenants""" + def test_get_tenants_paginated_success(self, service_mocks): + """Test successfully retrieving tenants with pagination""" # Setup tenant_ids = ["tenant1", "tenant2", "tenant3"] tenant_infos = [ @@ -184,17 +184,21 @@ def test_get_all_tenants_success(self, service_mocks): # Mock dependencies with patch('backend.services.tenant_service.get_all_tenant_ids', return_value=tenant_ids), \ - patch('backend.services.tenant_service.get_tenant_info', side_effect=tenant_infos) as mock_get_tenant_info: + patch('backend.services.tenant_service.get_tenant_info', side_effect=tenant_infos): # Execute - result = get_all_tenants() + result = get_tenants_paginated(page=1, page_size=20) # Assert - assert len(result) == 3 - assert result == tenant_infos + assert result["total"] == 3 + assert result["page"] == 1 + assert result["page_size"] == 20 + assert result["total_pages"] == 1 + assert len(result["data"]) == 3 + assert result["data"] == tenant_infos - def test_get_all_tenants_with_missing_configs(self, service_mocks): - """Test get_all_tenants when some tenants have missing configs""" + def test_get_tenants_paginated_with_missing_configs(self, service_mocks): + """Test get_tenants_paginated when some tenants have missing configs""" # Setup tenant_ids = ["tenant1", "tenant2", "tenant3"] @@ -218,41 +222,95 @@ def mock_get_tenant_info(tenant_id): patch('backend.services.tenant_service.get_tenant_info', side_effect=mock_get_tenant_info): # Execute - result = get_all_tenants() + result = get_tenants_paginated(page=1, page_size=20) # Assert - should return all tenants, with failed tenant having empty fields - assert len(result) == 3 - assert result[0]["tenant_id"] == "tenant1" - assert result[0]["tenant_name"] == "Tenant 1" - assert result[0]["default_group_id"] == "group1" - assert result[1]["tenant_id"] == "tenant2" - assert result[1]["tenant_name"] == "Tenant 2" - assert result[1]["default_group_id"] == "group2" + assert result["total"] == 3 + assert len(result["data"]) == 3 + assert result["data"][0]["tenant_id"] == "tenant1" + assert result["data"][0]["tenant_name"] == "Tenant 1" + assert result["data"][0]["default_group_id"] == "group1" + assert result["data"][1]["tenant_id"] == "tenant2" + assert result["data"][1]["tenant_name"] == "Tenant 2" + assert result["data"][1]["default_group_id"] == "group2" # Failed tenant should have empty name and default_group_id - assert result[2]["tenant_id"] == "tenant3" - assert result[2]["tenant_name"] == "" - assert result[2]["default_group_id"] == 'group3' + assert result["data"][2]["tenant_id"] == "tenant3" + assert result["data"][2]["tenant_name"] == "" + assert result["data"][2]["default_group_id"] == 'group3' - def test_get_all_tenants_empty_list(self, service_mocks): - """Test get_all_tenants when no tenants exist""" + def test_get_tenants_paginated_empty_list(self, service_mocks): + """Test get_tenants_paginated when no tenants exist""" # Mock dependencies with patch('backend.services.tenant_service.get_all_tenant_ids', return_value=[]) as mock_get_tenant_ids: # Execute - result = get_all_tenants() + result = get_tenants_paginated(page=1, page_size=20) # Assert - assert result == [] + assert result["data"] == [] + assert result["total"] == 0 + assert result["total_pages"] == 1 mock_get_tenant_ids.assert_called_once() - def test_get_all_tenants_get_all_tenant_ids_exception(self, service_mocks): - """Test get_all_tenants when get_all_tenant_ids raises exception""" + def test_get_tenants_paginated_get_all_tenant_ids_exception(self, service_mocks): + """Test get_tenants_paginated when get_all_tenant_ids raises exception""" # Mock dependencies with patch('backend.services.tenant_service.get_all_tenant_ids', side_effect=Exception("Database error")) as mock_get_tenant_ids: # Execute & Assert with pytest.raises(Exception, match="Database error"): - get_all_tenants() + get_tenants_paginated(page=1, page_size=20) + + def test_get_tenants_paginated_custom_page_size(self, service_mocks): + """Test get_tenants_paginated with custom page and page_size""" + # Setup + tenant_ids = ["tenant1", "tenant2", "tenant3", "tenant4", "tenant5"] + + # Create a function that returns tenant info based on tenant_id + def mock_get_tenant_info(tenant_id): + idx = int(tenant_id.replace("tenant", "")) + return {"tenant_id": tenant_id, "tenant_name": f"Tenant {idx}", "default_group_id": f"group{idx}"} + + # Mock dependencies + with patch('backend.services.tenant_service.get_all_tenant_ids', return_value=tenant_ids), \ + patch('backend.services.tenant_service.get_tenant_info', side_effect=mock_get_tenant_info): + + # Execute - page 2 with page_size 2 should return tenants 3 and 4 + result = get_tenants_paginated(page=2, page_size=2) + + # Assert + assert result["total"] == 5 + assert result["page"] == 2 + assert result["page_size"] == 2 + assert result["total_pages"] == 3 + assert len(result["data"]) == 2 + assert result["data"][0]["tenant_id"] == "tenant3" + assert result["data"][1]["tenant_id"] == "tenant4" + + def test_get_tenants_paginated_last_page(self, service_mocks): + """Test get_tenants_paginated on the last page with fewer items""" + # Setup + tenant_ids = ["tenant1", "tenant2", "tenant3", "tenant4", "tenant5"] + + # Create a function that returns tenant info based on tenant_id + def mock_get_tenant_info(tenant_id): + idx = int(tenant_id.replace("tenant", "")) + return {"tenant_id": tenant_id, "tenant_name": f"Tenant {idx}", "default_group_id": f"group{idx}"} + + # Mock dependencies + with patch('backend.services.tenant_service.get_all_tenant_ids', return_value=tenant_ids), \ + patch('backend.services.tenant_service.get_tenant_info', side_effect=mock_get_tenant_info): + + # Execute - page 3 with page_size 2 should return only tenant5 + result = get_tenants_paginated(page=3, page_size=2) + + # Assert + assert result["total"] == 5 + assert result["page"] == 3 + assert result["page_size"] == 2 + assert result["total_pages"] == 3 + assert len(result["data"]) == 1 + assert result["data"][0]["tenant_id"] == "tenant5" class TestCreateTenant: From bb4f4540bd3ca6b1280e488a4dc01c7fd8eef7f3 Mon Sep 17 00:00:00 2001 From: "XUYAQIDE\\xuyaq" Date: Wed, 25 Feb 2026 16:06:37 +0800 Subject: [PATCH 17/58] #2182 #2183 #2488 support version comparsion #2505 agent version published time should be dispalyed according to user's timezone --- .../app/[locale]/agents/AgentVersionCard.tsx | 262 ++++-------- .../[locale]/agents/AgentVersionManage.tsx | 175 ++++---- .../agents/components/AgentInfoComp.tsx | 110 +++-- .../versions/AgentVersionCompareModal.tsx | 382 ++++++++++++++++++ .../versions/AgentVersionPubulishModal.tsx | 109 +++++ frontend/hooks/agent/usePublishedAgentList.ts | 2 +- 6 files changed, 718 insertions(+), 322 deletions(-) create mode 100644 frontend/app/[locale]/agents/versions/AgentVersionCompareModal.tsx create mode 100644 frontend/app/[locale]/agents/versions/AgentVersionPubulishModal.tsx diff --git a/frontend/app/[locale]/agents/AgentVersionCard.tsx b/frontend/app/[locale]/agents/AgentVersionCard.tsx index bc38ad7c8..7f068a77a 100644 --- a/frontend/app/[locale]/agents/AgentVersionCard.tsx +++ b/frontend/app/[locale]/agents/AgentVersionCard.tsx @@ -26,10 +26,6 @@ import { Descriptions, DescriptionsProps, Modal, - Space, - Spin, - Empty, - Table, Dropdown, theme } from "antd"; @@ -48,19 +44,45 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import log from "@/lib/logger"; import { message } from "antd"; import { useQueryClient } from "@tanstack/react-query"; +import AgentVersionCompareModal from "./versions/AgentVersionCompareModal"; const { Text } = Typography; -const formatter = new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false +const formatter = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, }); +/** + * Format UTC time string from backend to local time string based on user timezone + */ +function formatUtcToLocal(dateTimeStr?: string | null) { + if (!dateTimeStr) { + return ""; + } + + // Detect whether the string already contains timezone information + const hasTimezone = /[zZ]|[+\-]\d{2}:?\d{2}$/.test(dateTimeStr); + + let date: Date; + if (hasTimezone) { + // If timezone exists, use as is + date = new Date(dateTimeStr); + } else { + // Treat as UTC time from database, convert to local time + // Normalize space-separated format like "2025-02-25 08:00:00" + const normalized = dateTimeStr.replace(" ", "T"); + date = new Date(`${normalized}Z`); + } + + return formatter.format(date); +} + /** * Get status configuration based on isCurrentVersion flag */ @@ -113,7 +135,7 @@ export function VersionCardItem({ const queryClient = useQueryClient(); // Get invalidate functions for refreshing data - const { invalidate: invalidateAgentVersionList } = useAgentVersionList(agentId); + const { agentVersionList, invalidate: invalidateAgentVersionList } = useAgentVersionList(agentId); const { invalidate: invalidateAgentInfo } = useAgentInfo(agentId); // Fetch version detail when expanded @@ -132,13 +154,15 @@ export function VersionCardItem({ const [rollbackLoading, setRollbackLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [compareData, setCompareData] = useState(null); + const [selectedVersionNoA, setSelectedVersionNoA] = useState(null); + const [selectedVersionNoB, setSelectedVersionNoB] = useState(null); // Get theme token for styling const { token } = theme.useToken(); - // Generate display date and operator from version data + // Generate display date from version data (convert from UTC to local time) const displayDate = useMemo(() => { - return formatter.format(new Date(version.create_time)); + return formatUtcToLocal(version.create_time); }, [version.create_time]); /** @@ -149,19 +173,20 @@ export function VersionCardItem({ message.error(t("agent.error.agentNotFound")); return; } + const versionNoA = currentVersionNo || 0; + const versionNoB = version.version_no; + setSelectedVersionNoA(versionNoA); + setSelectedVersionNoB(versionNoB); setCompareModalOpen(true); - await loadComparison(); + await loadComparison(versionNoA, versionNoB); }; /** * Load version comparison data between current version and selected version */ - const loadComparison = async () => { + const loadComparison = async (versionNoA: number, versionNoB: number) => { setLoading(true); try { - // Compare current version (currentVersionNo) with the version being rolled back to (version.version_no) - const versionNoA = currentVersionNo || 0; // Use current version, fallback to 0 (draft) if not available - const versionNoB = version.version_no; const result = await compareVersions(agentId, versionNoA, versionNoB); setCompareData(result); } catch (error) { @@ -172,6 +197,30 @@ export function VersionCardItem({ } }; + const handleChangeVersionA = async (value: number) => { + setSelectedVersionNoA(value); + if (!selectedVersionNoB) { + return; + } + if (value === selectedVersionNoB) { + message.warning(t("agent.version.selectDifferentVersions")); + return; + } + await loadComparison(value, selectedVersionNoB); + }; + + const handleChangeVersionB = async (value: number) => { + setSelectedVersionNoB(value); + if (!selectedVersionNoA) { + return; + } + if (value === selectedVersionNoA) { + message.warning(t("agent.version.selectDifferentVersions")); + return; + } + await loadComparison(selectedVersionNoA, value); + }; + /** * Handle rollback confirmation * Rollback updates current_version_no to point to the target version @@ -430,168 +479,21 @@ export function VersionCardItem({ )} - {/* Version Comparison Modal */} - - - {t("agent.version.rollbackCompareTitle")} - - } + setCompareModalOpen(false)} - footer={[ - , - , - ]} - width={800} - centered - > - - {compareData?.success && compareData?.data ? ( - - {/* Comparison Table */} - {(() => { - const { version_a, version_b } = compareData.data; - - const columns = [ - { - title: t("agent.version.versionName"), - dataIndex: 'field', - key: 'field', - width: '25%', - className: 'bg-gray-50 text-gray-600 font-medium', - }, - { - title: version_a.version.version_name, - dataIndex: 'current', - key: 'current', - width: '37%', - }, - { - title: version_b.version.version_name, - dataIndex: 'version', - key: 'version', - width: '38%', - }, - ]; - - const data = [ - { - key: 'name', - field: t("agent.version.field.name"), - current: ( - - {version_a.name} - - ), - version: ( - - {version_b.name} - - ), - }, - { - key: 'model_name', - field: t("agent.version.field.modelName"), - current: ( - - {version_a.model_name || '-'} - - ), - version: ( - - {version_b.model_name || '-'} - - ), - }, - { - key: 'description', - field: t("agent.version.field.description"), - current: ( - - {version_a.description || '-'} - - ), - version: ( - - {version_b.description || '-'} - - ), - }, - { - key: 'duty_prompt', - field: t("agent.version.field.dutyPrompt"), - current: ( - - {version_a.duty_prompt?.slice(0, 100) || '-'} - {version_a.duty_prompt && version_a.duty_prompt.length > 100 && '...'} - - ), - version: ( - - {version_b.duty_prompt?.slice(0, 100) || '-'} - {version_b.duty_prompt && version_b.duty_prompt.length > 100 && '...'} - - ), - }, - { - key: 'tools', - field: t("agent.version.field.tools"), - current: ( - - {version_a.tools?.length || 0} - - ), - version: ( - - {version_b.tools?.length || 0} - - ), - }, - { - key: 'sub_agents', - field: t("agent.version.field.subAgents"), - current: ( - - {version_a.sub_agent_id_list?.length || 0} - - ), - version: ( - - {version_b.sub_agent_id_list?.length || 0} - - ), - }, - ]; - - return ( - - ); - })()} - - ) : ( - - )} - - + showRollback + rollbackLoading={rollbackLoading} + onRollbackConfirm={handleRollbackConfirm} + selectedVersionNoA={selectedVersionNoA} + selectedVersionNoB={selectedVersionNoB} + onChangeVersionA={handleChangeVersionA} + onChangeVersionB={handleChangeVersionB} + /> {/* Delete Version Confirmation Modal */} state.currentAgentId); const { agentVersionList, total, isLoading, invalidate: invalidateAgentVersionList } = useAgentVersionList(currentAgentId); const { agentInfo, invalidate: invalidateAgentInfo } = useAgentInfo(currentAgentId); + + const [compareModalOpen, setCompareModalOpen] = useState(false); + const [compareLoading, setCompareLoading] = useState(false); + const [compareData, setCompareData] = useState(null); + const [selectedVersionA, setSelectedVersionA] = useState(null); + const [selectedVersionB, setSelectedVersionB] = useState(null); - const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); - const [isPublishing, setIsPublishing] = useState(false); - const [publishForm] = Form.useForm(); - - // Open publish modal - const handlePublishClick = () => { - setIsPublishModalOpen(true); + const loadComparison = async (agentId: number, versionNoA: number, versionNoB: number) => { + try { + setCompareLoading(true); + const result = await compareVersions(agentId, versionNoA, versionNoB); + setCompareData(result); + } catch (error) { + log.error("Failed to compare versions:", error); + message.error(t("agent.version.compareError")); + } finally { + setCompareLoading(false); + } }; - // Handle publish version - const handlePublish = async (values: { version_name?: string; release_note?: string }) => { + const handleOpenCompareModal = async () => { if (!currentAgentId) { message.error(t("agent.error.agentNotFound")); return; } + if (agentVersionList.length < 2) { + message.warning(t("agent.version.needTwoVersions")); + return; + } - // Prevent duplicate submissions - if (isPublishing) { - log.warn("Publish request already in progress, ignoring duplicate click"); + // Use the last two versions by version_no as default comparison + const sorted = [...agentVersionList].sort((a, b) => a.version_no - b.version_no); + const defaultVersionA = sorted[sorted.length - 2]?.version_no; + const defaultVersionB = sorted[sorted.length - 1]?.version_no; + + if (!defaultVersionA || !defaultVersionB) { + message.warning(t("agent.version.needTwoVersions")); return; } - try { - setIsPublishing(true); - await publishVersion(currentAgentId, values); - message.success(t("agent.version.publishSuccess")); - setIsPublishModalOpen(false); - publishForm.resetFields(); - invalidateAgentVersionList(); - invalidateAgentInfo(); - queryClient.invalidateQueries({ queryKey: ["agents"] }); - } catch (error) { - log.error("Failed to publish version:", error); - message.error(t("agent.version.publishFailed")); - } finally { - setIsPublishing(false); + setSelectedVersionA(defaultVersionA); + setSelectedVersionB(defaultVersionB); + setCompareModalOpen(true); + await loadComparison(currentAgentId, defaultVersionA, defaultVersionB); + }; + + const handleChangeVersionA = async (value: number) => { + setSelectedVersionA(value); + if (!currentAgentId || !selectedVersionB) { + return; + } + if (value === selectedVersionB) { + message.warning(t("agent.version.selectDifferentVersions")); + return; } + await loadComparison(currentAgentId, value, selectedVersionB); + }; + + const handleChangeVersionB = async (value: number) => { + setSelectedVersionB(value); + if (!currentAgentId || !selectedVersionA) { + return; + } + if (value === selectedVersionA) { + message.warning(t("agent.version.selectDifferentVersions")); + return; + } + await loadComparison(currentAgentId, selectedVersionA, value); }; const footer = [ @@ -92,9 +102,7 @@ export default function AgentVersionManage() { @@ -112,15 +120,6 @@ export default function AgentVersionManage() { {t("agent.version.manage")} } - extra={ - - } actions={footer} styles={{ body: { @@ -152,46 +151,18 @@ export default function AgentVersionManage() { - {/* Publish Version Modal */} - setIsPublishModalOpen(false)} - footer={null} - destroyOnHidden - > -
    - - - - -