diff --git a/src/backend/bisheng/api/v1/schemas.py b/src/backend/bisheng/api/v1/schemas.py index c17f3cb0f..27486e0b8 100644 --- a/src/backend/bisheng/api/v1/schemas.py +++ b/src/backend/bisheng/api/v1/schemas.py @@ -399,6 +399,9 @@ class WSModel(BaseModel): id: str name: str | None = None displayName: str | None = None + # Optional one-line intro shown under the model name in the workspace + # model picker; hidden when empty. Length capped to keep the dropdown tidy. + description: str | None = Field(default=None, max_length=50) visual: bool | None = False diff --git a/src/backend/bisheng/channel/api/endpoints/channel_chat.py b/src/backend/bisheng/channel/api/endpoints/channel_chat.py index b73104a0c..a90e229d7 100644 --- a/src/backend/bisheng/channel/api/endpoints/channel_chat.py +++ b/src/backend/bisheng/channel/api/endpoints/channel_chat.py @@ -6,10 +6,10 @@ Provides the following functionalities: - GET /chat/messages/{article_doc_id}: Query chat history - DELETE /chat/messages/{article_doc_id}: Clear chat content """ + import json import logging from datetime import datetime -from typing import List from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse @@ -17,10 +17,8 @@ from langchain_core.documents import Document from langchain_core.messages import HumanMessage, SystemMessage from sse_starlette import EventSourceResponse -from bisheng.api.services.workstation import ( - WorkstationConversation, WorkstationMessage -) -from bisheng.api.v1.schemas import resp_200, ChatResponse +from bisheng.api.services.workstation import WorkstationConversation, WorkstationMessage +from bisheng.api.v1.schemas import ChatResponse, resp_200 from bisheng.channel.domain.schemas.channel_chat_schema import ChannelArticleChatRequest from bisheng.channel.domain.services.article_es_service import ArticleEsService from bisheng.channel.domain.services.channel_chat_service import ChannelChatService @@ -29,95 +27,94 @@ from bisheng.common.dependencies.user_deps import UserPayload from bisheng.common.errcode import BaseErrorCode from bisheng.common.errcode.channel import ChannelChatConversationNotFoundError from bisheng.common.errcode.http_error import ServerError, UnAuthorizedError -from bisheng.common.schemas.api import resp_500, SSEResponse -from bisheng.llm.domain.utils import extract_reasoning_content +from bisheng.common.schemas.api import SSEResponse, resp_500 from bisheng.database.constants import MessageCategory from bisheng.database.models.message import ChatMessage, ChatMessageDao from bisheng.database.models.session import MessageSession +from bisheng.llm.domain.utils import extract_reasoning_content logger = logging.getLogger(__name__) -router = APIRouter(prefix='/chat', tags=['Channel Article Chat']) +router = APIRouter(prefix="/chat", tags=["Channel Article Chat"]) def custom_json_serializer(obj): if isinstance(obj, datetime): return obj.isoformat() - raise TypeError(f'Type {type(obj)} not serializable') + raise TypeError(f"Type {type(obj)} not serializable") def user_message(msgId, conversationId, sender, text): - msg = json.dumps({ - 'message': { - 'messageId': msgId, - 'conversationId': conversationId, - 'sender': sender, - 'text': text - }, - 'created': True - }) - return f'event: message\ndata: {msg}\n\n' + msg = json.dumps( + { + "message": {"messageId": msgId, "conversationId": conversationId, "sender": sender, "text": text}, + "created": True, + } + ) + return f"event: message\ndata: {msg}\n\n" def step_message(stepId, runId, index, msgId): - msg = json.dumps({ - 'event': 'on_run_step', - 'data': { - 'id': stepId, - 'runId': runId, - 'type': 'message_creation', - 'index': index, - 'stepDetails': { - 'type': 'message_creation', - 'message_creation': { - 'message_id': msgId - } - } + msg = json.dumps( + { + "event": "on_run_step", + "data": { + "id": stepId, + "runId": runId, + "type": "message_creation", + "index": index, + "stepDetails": {"type": "message_creation", "message_creation": {"message_id": msgId}}, + }, } - }) - return f'event: message\ndata: {msg}\n\n' + ) + return f"event: message\ndata: {msg}\n\n" def delta(id, delta): - return {'id': id, 'delta': delta} + return {"id": id, "delta": delta} -async def final_message(conversation: MessageSession, title: str, requestMessage: ChatMessage, - text: str, error: bool, modelName: str, - source_document: List[Document] = None): +async def final_message( + conversation: MessageSession, + title: str, + requestMessage: ChatMessage, + text: str, + error: bool, + modelName: str, + source_document: list[Document] = None, +): responseMessage = await ChatMessageDao.ainsert_one( ChatMessage( user_id=conversation.user_id, chat_id=conversation.chat_id, flow_id=conversation.flow_id, - type='assistant', + type="assistant", is_bot=True, message=text, - category='answer', + category="answer", sender=modelName, - extra=json.dumps({ - 'parentMessageId': requestMessage.id, - 'error': error - }), - source=0 - )) + extra=json.dumps({"parentMessageId": requestMessage.id, "error": error}), + source=0, + ) + ) msg = json.dumps( { - 'final': True, - 'conversation': WorkstationConversation.from_chat_session(conversation).model_dump(), - 'title': title, - 'requestMessage': (await WorkstationMessage.from_chat_message(requestMessage)).model_dump(), - 'responseMessage': (await WorkstationMessage.from_chat_message(responseMessage)).model_dump(), + "final": True, + "conversation": WorkstationConversation.from_chat_session(conversation).model_dump(), + "title": title, + "requestMessage": (await WorkstationMessage.from_chat_message(requestMessage)).model_dump(), + "responseMessage": (await WorkstationMessage.from_chat_message(responseMessage)).model_dump(), }, - default=custom_json_serializer) - return f'event: message\ndata: {msg}\n\n' + default=custom_json_serializer, + ) + return f"event: message\ndata: {msg}\n\n" -@router.post('/completions', summary='Channel Article AI Assistant Chat') +@router.post("/completions", summary="Channel Article AI Assistant Chat") async def chat_completions( - data: ChannelArticleChatRequest, - login_user: UserPayload = Depends(UserPayload.get_login_user), + data: ChannelArticleChatRequest, + login_user: UserPayload = Depends(UserPayload.get_login_user), ): """ Channel Article AI Assistant Chat API, returns SSE stream. @@ -145,7 +142,7 @@ async def chat_completions( error_response = e if isinstance(e, BaseErrorCode) else ServerError(msg=str(e)) return EventSourceResponse(iter([error_response.to_sse_event_instance()])) except Exception as e: - logger.exception(f'Error in channel article chat setup: {e}') + logger.exception(f"Error in channel article chat setup: {e}") return EventSourceResponse(iter([ServerError(exception=e).to_sse_event_instance()])) async def event_stream(): @@ -162,35 +159,27 @@ async def chat_completions( user_prompt_template = ( subscription_config.user_prompt if subscription_config and subscription_config.user_prompt - else ( - "# 参考资料\n```\n{article_content}\n```\n# 用户问题\n{question}" - ) - ) - user_prompt = user_prompt_template.format( - article_content=article_content, - question=data.text + else ("# 参考资料\n```\n{article_content}\n```\n# 用户问题\n{question}") ) + user_prompt = user_prompt_template.format(article_content=article_content, question=data.text) await ChatMessageDao.ainsert_one( ChatMessage( user_id=login_user.user_id, chat_id=conversation.chat_id, flow_id=data.article_doc_id, - type='human', + type="human", is_bot=False, - sender='User', + sender="User", message=json.dumps({"query": data.text}, ensure_ascii=False), category=MessageCategory.QUESTION, source=0, - )) + ) + ) # Get chat history (excluding the latest one) history_messages = (await ChannelChatService.get_chat_history(conversationId, 8))[:-1] # Build LLM input - inputs = [ - SystemMessage(content=system_prompt), - *history_messages, - HumanMessage(content=user_prompt) - ] + inputs = [SystemMessage(content=system_prompt), *history_messages, HumanMessage(content=user_prompt)] answer = "" reasoning_answer = "" @@ -200,32 +189,25 @@ async def chat_completions( reasoning_content = extract_reasoning_content(chunk) answer += content reasoning_answer += reasoning_content - yield SSEResponse(data=ChatResponse( - category=MessageCategory.STREAM, - message={ - "content": content, - "reasoning_content": reasoning_content, - }, - type="stream" - )).to_string() + yield SSEResponse( + data=ChatResponse( + category=MessageCategory.STREAM, + message={ + "content": content, + "reasoning_content": reasoning_content, + }, + type="stream", + ) + ).to_string() - yield SSEResponse(data=ChatResponse( - category=MessageCategory.STREAM, - message={ - "content": answer, - "reasoning_content": reasoning_answer - }, - type="end" - )).to_string() - - # Append reasoning process to final result - await ChatMessageDao.ainsert_one( + # Persist the answer BEFORE the end event so we can hand the client the + # real ChatMessage id. The client renders the streamed answer under a + # temporary placeholder id; without the real id, like/dislike clicked + # before a reload writes to a non-existent row and silently vanishes. + answer_message = await ChatMessageDao.ainsert_one( ChatMessage( category=MessageCategory.ANSWER, - message=json.dumps({ - "content": answer, - "reasoning_content": reasoning_answer - }, ensure_ascii=False), + message=json.dumps({"content": answer, "reasoning_content": reasoning_answer}, ensure_ascii=False), user_id=login_user.user_id, chat_id=conversation.chat_id, flow_id=data.article_doc_id, @@ -233,23 +215,35 @@ async def chat_completions( is_bot=True, ) ) + + yield SSEResponse( + data=ChatResponse( + category=MessageCategory.STREAM, + message={ + "content": answer, + "reasoning_content": reasoning_answer, + "message_id": answer_message.id, + }, + type="end", + ) + ).to_string() except BaseErrorCode as e: yield e.to_sse_event_instance_str() except Exception as e: - logger.exception(f'Error in channel article chat processing') + logger.exception("Error in channel article chat processing") yield ServerError(exception=e).to_sse_event_instance_str() try: - return StreamingResponse(event_stream(), media_type='text/event-stream') + return StreamingResponse(event_stream(), media_type="text/event-stream") except Exception as e: - logger.exception(f'Error creating channel article chat stream: {e}') + logger.exception(f"Error creating channel article chat stream: {e}") return EventSourceResponse(iter([ServerError(exception=e).to_sse_event_instance()])) -@router.get('/messages/{article_doc_id}', summary='Query Channel Article AI Assistant Chat History') +@router.get("/messages/{article_doc_id}", summary="Query Channel Article AI Assistant Chat History") async def get_chat_history( - article_doc_id: str, - login_user: UserPayload = Depends(UserPayload.get_login_user), + article_doc_id: str, + login_user: UserPayload = Depends(UserPayload.get_login_user), ): """Query Channel Article AI Assistant Chat History Content""" messages = await ChannelChatService.get_chat_messages(article_doc_id, login_user) @@ -258,10 +252,10 @@ async def get_chat_history( return resp_200(data=messages) -@router.delete('/messages/{article_doc_id}', summary='Clear Channel Article AI Assistant Chat Content') +@router.delete("/messages/{article_doc_id}", summary="Clear Channel Article AI Assistant Chat Content") async def clear_chat( - article_doc_id: str, - login_user: UserPayload = Depends(UserPayload.get_login_user), + article_doc_id: str, + login_user: UserPayload = Depends(UserPayload.get_login_user), ): """Clear Channel Article AI Assistant Chat Content""" try: diff --git a/src/backend/bisheng/common/errcode/server.py b/src/backend/bisheng/common/errcode/server.py index d2ae5762f..e0f233c8d 100644 --- a/src/backend/bisheng/common/errcode/server.py +++ b/src/backend/bisheng/common/errcode/server.py @@ -4,79 +4,81 @@ from .base import BaseErrorCode # RTService-related return error code, function module code:100 class NoSftServerError(BaseErrorCode): Code: int = 10001 - Msg: str = 'not foundSFTSERVICES' + Msg: str = "not foundSFTSERVICES" # Invalid nonce class InvalidOperationError(BaseErrorCode): Code: int = 10002 - Msg: str = 'Invalid nonce' + Msg: str = "Invalid nonce" # Resource download failed class ResourceDownloadError(BaseErrorCode): Code: int = 10003 - Msg: str = 'Resource download failed' + Msg: str = "Resource download failed" # Knowledge Base Not Configuredembeddingmodel, please set from workbench configuration class NoEmbeddingModelError(BaseErrorCode): Code: int = 10004 - Msg: str = 'Knowledge Base Not Configuredembeddingmodel, please set from workbench configuration' + Msg: str = "Knowledge Base Not Configuredembeddingmodel, please set from workbench configuration" # The knowledge base uponembeddingModel does not exist, please set from workbench configuration class EmbeddingModelNotExistError(BaseErrorCode): Code: int = 10005 - Msg: str = 'The knowledge base uponembeddingModel does not exist, please set from workbench configuration' + Msg: str = "The knowledge base uponembeddingModel does not exist, please set from workbench configuration" # The knowledge base uponembeddingWrong model type, please set from workbench configuration class EmbeddingModelTypeError(BaseErrorCode): Code: int = 10006 - Msg: str = 'The knowledge base uponembeddingWrong model type, please set from workbench configuration' + Msg: str = "The knowledge base uponembeddingWrong model type, please set from workbench configuration" # Please contact the administrator to check the status of the workbench vector retrieval model class EmbeddingModelStatusError(BaseErrorCode): Code: int = 10007 - Msg: str = 'Please contact the administrator to check the status of the workbench vector retrieval model' + Msg: str = "Please contact the administrator to check the status of the workbench vector retrieval model" # No bulkpost found in Trashllmmodel config class NoLlmModelConfigError(BaseErrorCode): Code: int = 10008 - Msg: str = 'No bulkpost found in Trashllmmodel config' + Msg: str = "No bulkpost found in Trashllmmodel config" # llmModel configuration has been deleted, please reconfigure the model class LlmModelConfigDeletedError(BaseErrorCode): Code: int = 10009 - Msg: str = 'llmModel configuration has been deleted, please reconfigure the model' + Msg: str = "llmModel configuration has been deleted, please reconfigure the model" # Service provider configuration has been deleted, please reconfigurellmModels class LlmProviderDeletedError(BaseErrorCode): Code: int = 10010 - Msg: str = 'Service provider configuration has been deleted, please reconfigurellmModels' + Msg: str = "Service provider configuration has been deleted, please reconfigurellmModels" # Support onlyLLMModel of type, not supported{model_info.model_type}Type of model class LlmModelTypeError(BaseErrorCode): Code: int = 10011 - Msg: str = 'Support onlyLLMModel of type, not supported{model_type}Type of model' + Msg: str = "Support onlyLLMModel of type, not supported{model_type}Type of model" # {server_info.name}under{model_info.model_name}The model is offline, please contact the administrator to launch the corresponding model class LlmModelOfflineError(BaseErrorCode): Code: int = 10012 - Msg: str = '{server_name}under{model_name}The model is offline, please contact the administrator to launch the corresponding model' + Msg: str = "{server_name}under{model_name}The model is offline, please contact the administrator to launch the corresponding model" # InisialisasillmFailed, please check the configuration or contact the administrator.Error message:{e} class InitLlmError(BaseErrorCode): Code: int = 10013 - Msg: str = 'InisialisasillmFailed, please check the configuration or contact the administrator.Error message:{exception}' + Msg: str = ( + "InisialisasillmFailed, please check the configuration or contact the administrator.Error message:{exception}" + ) class NoAsrModelConfigError(BaseErrorCode): @@ -111,49 +113,58 @@ class InitAsrError(BaseErrorCode): class NoTtsModelConfigError(BaseErrorCode): Code: int = 10020 - Msg: str = 'No bulkpost found in Trashttsmodel config' + Msg: str = "No bulkpost found in Trashttsmodel config" class TtsModelConfigDeletedError(BaseErrorCode): Code: int = 10021 - Msg: str = 'ttsModel configuration has been deleted, please reconfigure the model' + Msg: str = "ttsModel configuration has been deleted, please reconfigure the model" class TtsProviderDeletedError(BaseErrorCode): Code: int = 10022 - Msg: str = 'Service provider configuration has been deleted, please reconfigurettsModels' + Msg: str = "Service provider configuration has been deleted, please reconfigurettsModels" class TtsModelTypeError(BaseErrorCode): Code: int = 10023 - Msg: str = 'Support onlyTTSModel of type, not supported{model_type}Type of model' + Msg: str = "Support onlyTTSModel of type, not supported{model_type}Type of model" class TtsModelOfflineError(BaseErrorCode): Code: int = 10024 - Msg: str = '{server_name}under{model_name}The model is offline, please contact the administrator to launch the corresponding model' + Msg: str = "{server_name}under{model_name}The model is offline, please contact the administrator to launch the corresponding model" class InitTtsError(BaseErrorCode): Code: int = 10025 - Msg: str = 'InisialisasittsFailed, please check the configuration or contact the administrator.Error message:{exception}' + Msg: str = ( + "InisialisasittsFailed, please check the configuration or contact the administrator.Error message:{exception}" + ) + + +class TtsSynthesisFailedError(BaseErrorCode): + # Distinct business code (not HTTP 500) so the client shows a localized toast + # instead of raising the global service-maintenance overlay for a TTS failure. + Code: int = 10026 + Msg: str = "Speech synthesis failed, please try again later" class SystemConfigEmptyError(BaseErrorCode): Code: int = 10030 - Msg: str = 'System configuration cannot be empty' + Msg: str = "System configuration cannot be empty" class SystemConfigInvalidError(BaseErrorCode): Code: int = 10031 - Msg: str = 'The system configuration format is incorrect, please check the configuration content:{exception}' + Msg: str = "The system configuration format is incorrect, please check the configuration content:{exception}" class UploadFileEmptyError(BaseErrorCode): Code: int = 10040 - Msg: str = 'Uploaded file cannot be empty' + Msg: str = "Uploaded file cannot be empty" class UploadFileExtError(BaseErrorCode): Code: int = 10041 - Msg: str = 'The upload file format is not supported, please upload a file in the correct format' + Msg: str = "The upload file format is not supported, please upload a file in the correct format" diff --git a/src/backend/bisheng/database/models/tenant.py b/src/backend/bisheng/database/models/tenant.py index f728d8f9f..2af8c5348 100644 --- a/src/backend/bisheng/database/models/tenant.py +++ b/src/backend/bisheng/database/models/tenant.py @@ -373,6 +373,21 @@ class TenantDao: result = await session.exec(stmt) return [row for row in result.all()] + @classmethod + def get_children_ids_active(cls, root_id: int = ROOT_TENANT_ID) -> list[int]: + """Sync counterpart of :meth:`aget_children_ids_active`. + + Used by Celery Beat tasks that run in a synchronous worker context and + cannot await the async DAO (e.g. ``sync_information_article``). + """ + with bypass_tenant_filter(): + with get_sync_db_session() as session: + stmt = select(Tenant.id).where( + Tenant.parent_tenant_id == root_id, + Tenant.status == "active", + ) + return [row for row in session.exec(stmt).all()] + @classmethod async def aget_non_active_ids(cls) -> list[int]: """Return ids of tenants in disabled/archived/orphaned status. diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py index c75c43a61..e7ab5480d 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py @@ -1,12 +1,13 @@ import asyncio import json +from collections.abc import AsyncIterator from datetime import datetime -from typing import List, Optional, AsyncIterator, Tuple, Dict, Any +from typing import Any from fastapi import HTTPException, Request from langchain_core.documents import Document from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from loguru import logger from bisheng.api.services.workstation import WorkStationService @@ -22,22 +23,22 @@ from bisheng.core.prompts.manager import get_prompt_manager from bisheng.database.constants import MessageCategory from bisheng.database.models.flow import FlowType from bisheng.database.models.group_resource import ResourceTypeEnum -from bisheng.database.models.message import ChatMessageDao, ChatMessage -from bisheng.database.models.session import MessageSessionDao, MessageSession +from bisheng.database.models.message import ChatMessage, ChatMessageDao +from bisheng.database.models.session import MessageSession, MessageSessionDao from bisheng.database.models.tag import TagBusinessTypeEnum, TagDao from bisheng.knowledge.domain.knowledge_rag import KnowledgeRag from bisheng.knowledge.domain.models.knowledge import KnowledgeDao, KnowledgeTypeEnum from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileDao from bisheng.knowledge.domain.models.knowledge_space_file import SpaceFileDao from bisheng.knowledge.rag.version_filter import build_primary_only_filter -from bisheng.llm.domain.utils import extract_reasoning_content from bisheng.llm.domain import LLMService +from bisheng.llm.domain.utils import extract_reasoning_content from bisheng.tool.domain.langchain.knowledge import KnowledgeRetrieverTool from bisheng.utils import generate_uuid class KnowledgeSpaceChatService: - """ Service class for handling Knowledge Space AI Chat operations """ + """Service class for handling Knowledge Space AI Chat operations""" def __init__(self, request: Request, login_user: UserPayload): self.request = request @@ -46,7 +47,7 @@ class KnowledgeSpaceChatService: def _permission_service(self): from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService - if not hasattr(self, '_knowledge_space_permission_service'): + if not hasattr(self, "_knowledge_space_permission_service"): self._knowledge_space_permission_service = KnowledgeSpaceService(self.request, self.login_user) return self._knowledge_space_permission_service @@ -56,9 +57,9 @@ class KnowledgeSpaceChatService: KnowledgeFileVisibilityService, ) - if not hasattr(self, '_knowledge_file_visibility_service'): + if not hasattr(self, "_knowledge_file_visibility_service"): svc = KnowledgeFileVisibilityService(self.request, self.login_user) - svc.version_repo = getattr(self, 'version_repo', None) + svc.version_repo = getattr(self, "version_repo", None) self._knowledge_file_visibility_service = svc return self._knowledge_file_visibility_service @@ -76,34 +77,34 @@ class KnowledgeSpaceChatService: async def _require_space_view_permission(self, space_id: int): svc = self._permission_service() await svc._require_read_permission(space_id) - await svc._require_permission_id('knowledge_space', space_id, 'view_space') + await svc._require_permission_id("knowledge_space", space_id, "view_space") async def _require_folder_view_permission(self, space_id: int, folder_id: int): svc = self._permission_service() - folder = await svc._require_folder_relation(space_id, folder_id, 'can_read') - await svc._require_permission_id('folder', folder_id, 'view_folder', space_id=space_id) + folder = await svc._require_folder_relation(space_id, folder_id, "can_read") + await svc._require_permission_id("folder", folder_id, "view_folder", space_id=space_id) return folder async def _require_file_view_permission(self, space_id: int, file_id: int): svc = self._permission_service() - file_record = await svc._require_file_relation(file_id, 'can_read', space_id=space_id) - await svc._require_permission_id('knowledge_file', file_id, 'view_file', space_id=space_id) + file_record = await svc._require_file_relation(file_id, "can_read", space_id=space_id) + await svc._require_permission_id("knowledge_file", file_id, "view_file", space_id=space_id) return file_record @classmethod def generate_flow_id_for_file(cls, knowledge_id: int, file_id: int) -> str: - """ Generate a unique flow_id representation for a single file chat """ + """Generate a unique flow_id representation for a single file chat""" return f"space_{knowledge_id}_file_{file_id}" @classmethod def generate_flow_id_for_folder(cls, knowledge_id: int, folder_id: int = 0) -> str: - """ Generate a unique flow_id representation for a folder chat """ + """Generate a unique flow_id representation for a folder chat""" return f"space_{knowledge_id}_folder_{folder_id}" - async def chat_single_file(self, knowledge_id: int, file_id: int, query: str, - model_id: int) \ - -> AsyncIterator[ChatResponse]: - """ Single file RAG query """ + async def chat_single_file( + self, knowledge_id: int, file_id: int, query: str, model_id: int + ) -> AsyncIterator[ChatResponse]: + """Single file RAG query""" # Verify file exists and is a file file_record = await self._require_file_view_permission(knowledge_id, file_id) # F029/AC-09: trace that the view_file gate passed; the document_id == file_id @@ -120,45 +121,46 @@ class KnowledgeSpaceChatService: flow_id = self.generate_flow_id_for_file(knowledge_id, file_id) - session = await MessageSessionDao.afilter_session(flow_ids=[flow_id], - flow_type=[FlowType.KNOLEDGE_SPACE.value], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + flow_ids=[flow_id], + flow_type=[FlowType.KNOLEDGE_SPACE.value], + user_ids=[self.login_user.user_id], + include_delete=False, + ) if not session: - session = await MessageSessionDao.async_insert_one(MessageSession( - chat_id=generate_uuid(), - flow_id=flow_id, - flow_name=file_record.file_name, - flow_type=FlowType.KNOLEDGE_SPACE.value, - user_id=self.login_user.user_id, - )) + session = await MessageSessionDao.async_insert_one( + MessageSession( + chat_id=generate_uuid(), + flow_id=flow_id, + flow_name=file_record.file_name, + flow_type=FlowType.KNOLEDGE_SPACE.value, + user_id=self.login_user.user_id, + ) + ) else: session = session[0] milvus_vector = await KnowledgeRag.init_knowledge_milvus_vectorstore(self.login_user.user_id, knowledge=space) - vector_retriever = milvus_vector.as_retriever(search_kwargs={ - "k": 100, - "param": {"ef": 110}, - "expr": f"document_id == {file_id}" - }) + vector_retriever = milvus_vector.as_retriever( + search_kwargs={"k": 100, "param": {"ef": 110}, "expr": f"document_id == {file_id}"} + ) es_vector = await KnowledgeRag.init_knowledge_es_vectorstore(knowledge=space) - es_retriever = es_vector.as_retriever(search_kwargs={ - "filter": [{"term": {"metadata.document_id": file_id}}] - }) + es_retriever = es_vector.as_retriever(search_kwargs={"filter": [{"term": {"metadata.document_id": file_id}}]}) async for one in self.space_rag(session, vector_retriever, es_retriever, query, model_id, None): yield one - async def space_rag(self, session, vector_retriever, es_retriever, query: str, model_id: int, tags: Any = None) \ - -> AsyncIterator[ChatResponse]: + async def space_rag( + self, session, vector_retriever, es_retriever, query: str, model_id: int, tags: Any = None + ) -> AsyncIterator[ChatResponse]: llm, space_conf = await self.get_space_llm_config(model_id=model_id) retriever_tool = KnowledgeRetrieverTool( vector_retriever=vector_retriever, elastic_retriever=es_retriever, max_content=space_conf.max_chunk_size, - sort_by_source_and_index=True + sort_by_source_and_index=True, ) - finally_docs: List[Document] = await retriever_tool.ainvoke(query) + finally_docs: list[Document] = await retriever_tool.ainvoke(query) logger.debug(f"retrieved_finally_docs: {len(finally_docs)}") file_content = "" for one in finally_docs: @@ -168,17 +170,18 @@ class KnowledgeSpaceChatService: if space_conf.system_prompt: inputs = [ - SystemMessage(content=space_conf.system_prompt.format(cur_date=datetime.now().strftime('%Y-%m-%d'))), + SystemMessage(content=space_conf.system_prompt.format(cur_date=datetime.now().strftime("%Y-%m-%d"))), HumanMessage( - content=space_conf.user_prompt.format(retrieved_file_content=file_content, question=query)), + content=space_conf.user_prompt.format(retrieved_file_content=file_content, question=query) + ), ] else: prompt_obj = prompt_service.render_prompt( namespace="knowledge_space", prompt_name="rag_prompt", - cur_date=datetime.now().strftime('%Y-%m-%d'), + cur_date=datetime.now().strftime("%Y-%m-%d"), retrieved_file_content=file_content, - question=query + question=query, ) inputs = [SystemMessage(content=prompt_obj.prompt.system), HumanMessage(content=prompt_obj.prompt.user)] answer = "" @@ -205,18 +208,21 @@ class KnowledgeSpaceChatService: "content": one.content, "reasoning_content": chunk_reasoning_content, }, - type="stream" + type="stream", ) reasoning_content += chunk_reasoning_content answer += one.content messages = [ ChatMessage( category=MessageCategory.QUESTION, - message=json.dumps({ - "query": query, - "tags": tags, - "model_id": model_id, - }, ensure_ascii=False), + message=json.dumps( + { + "query": query, + "tags": tags, + "model_id": model_id, + }, + ensure_ascii=False, + ), chat_id=session.chat_id, flow_id=session.flow_id, user_id=self.login_user.user_id, @@ -225,33 +231,38 @@ class KnowledgeSpaceChatService: ), ChatMessage( category=MessageCategory.ANSWER, - message=json.dumps({ - "content": answer, - "reasoning_content": reasoning_content - }, ensure_ascii=False), + message=json.dumps({"content": answer, "reasoning_content": reasoning_content}, ensure_ascii=False), chat_id=session.chat_id, flow_id=session.flow_id, user_id=self.login_user.user_id, type="end", is_bot=True, - ) + ), ] await ChatMessageDao.ainsert_batch(messages) if not session.name: - asyncio.create_task(self.generate_conversation( - user_id=self.login_user.user_id, - chat_id=session.chat_id, - question=query, - answer=answer, - )) + asyncio.create_task( + self.generate_conversation( + user_id=self.login_user.user_id, + chat_id=session.chat_id, + question=query, + answer=answer, + ) + ) yield ChatResponse( category=MessageCategory.STREAM, message={ "content": answer, "reasoning_content": reasoning_content, + # Real persisted answer ChatMessage id: the client renders the + # streamed answer under a temporary placeholder id; sending the + # real id on the end event lets it swap in immediately so + # like/dislike writes to the right row (previously a like clicked + # before a page refresh was lost, since it hit the placeholder id). + "message_id": messages[1].id, }, - type="end" + type="end", ) @staticmethod @@ -263,22 +274,25 @@ class KnowledgeSpaceChatService: llm = await LLMService.get_bisheng_llm( model_id=llm_conf.chat_title_llm.id, app_id=ApplicationTypeEnum.DAILY_CHAT.value, - app_name='knowledge_sapce_chat_title', + app_name="knowledge_sapce_chat_title", app_type=ApplicationTypeEnum.DAILY_CHAT, - user_id=user_id + user_id=user_id, ) title = await generate_conversation_title_async(question=question, llm=llm, answer=answer) await MessageSessionDao.update_session_name(chat_id, title) - async def single_file_history(self, knowledge_id: int, file_id: int, page_size: int = 20) \ - -> List[ChatMessageHistoryResponse]: + async def single_file_history( + self, knowledge_id: int, file_id: int, page_size: int = 20 + ) -> list[ChatMessageHistoryResponse]: await self._require_file_view_permission(knowledge_id, file_id) flow_id = self.generate_flow_id_for_file(knowledge_id, file_id) - session = await MessageSessionDao.afilter_session(flow_ids=[flow_id], - flow_type=[FlowType.KNOLEDGE_SPACE.value], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + flow_ids=[flow_id], + flow_type=[FlowType.KNOLEDGE_SPACE.value], + user_ids=[self.login_user.user_id], + include_delete=False, + ) if not session: return [] session = session[0] @@ -287,18 +301,20 @@ class KnowledgeSpaceChatService: async def clear_file_history(self, knowledge_id: int, file_id: int) -> bool: await self._require_file_view_permission(knowledge_id, file_id) flow_id = self.generate_flow_id_for_file(knowledge_id, file_id) - session = await MessageSessionDao.afilter_session(flow_ids=[flow_id], - flow_type=[FlowType.KNOLEDGE_SPACE.value], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + flow_ids=[flow_id], + flow_type=[FlowType.KNOLEDGE_SPACE.value], + user_ids=[self.login_user.user_id], + include_delete=False, + ) if not session: return True session = session[0] await ChatMessageDao.adelete_by_user_chat_id(chat_id=session.chat_id, user_id=self.login_user.user_id) return True - async def get_chat_folder_session(self, space_id: int, folder_id: int) -> List[MessageSession]: - """ Query sessions for a specific folder_id """ + async def get_chat_folder_session(self, space_id: int, folder_id: int) -> list[MessageSession]: + """Query sessions for a specific folder_id""" if folder_id: await self._require_folder_view_permission(space_id, folder_id) else: @@ -306,10 +322,12 @@ class KnowledgeSpaceChatService: flow_id = self.generate_flow_id_for_folder(space_id, folder_id) - session = await MessageSessionDao.afilter_session(flow_ids=[flow_id], - flow_type=[FlowType.KNOLEDGE_SPACE.value], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + flow_ids=[flow_id], + flow_type=[FlowType.KNOLEDGE_SPACE.value], + user_ids=[self.login_user.user_id], + include_delete=False, + ) return session async def create_chat_folder_session(self, space_id: int, folder_id: int) -> MessageSession: @@ -322,13 +340,15 @@ class KnowledgeSpaceChatService: folder_record = await self._require_folder_view_permission(space_id, folder_id) flow_name = f"{flow_name}-{folder_record.file_name}" flow_id = self.generate_flow_id_for_folder(space_id, folder_id) - session = await MessageSessionDao.async_insert_one(MessageSession( - chat_id=generate_uuid(), - flow_id=flow_id, - flow_type=FlowType.KNOLEDGE_SPACE.value, - flow_name=f"Knowledge Space Dir: {flow_name}", - user_id=self.login_user.user_id, - )) + session = await MessageSessionDao.async_insert_one( + MessageSession( + chat_id=generate_uuid(), + flow_id=flow_id, + flow_type=FlowType.KNOLEDGE_SPACE.value, + flow_name=f"Knowledge Space Dir: {flow_name}", + user_id=self.login_user.user_id, + ) + ) return session async def delete_chat_folder_session(self, space_id: int, folder_id: int, chat_id: str) -> bool: @@ -337,17 +357,20 @@ class KnowledgeSpaceChatService: else: await self._require_space_view_permission(space_id) flow_id = self.generate_flow_id_for_folder(space_id, folder_id) - session = await MessageSessionDao.afilter_session(chat_ids=[chat_id], - flow_ids=[flow_id], - flow_type=[FlowType.KNOLEDGE_SPACE.value], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + chat_ids=[chat_id], + flow_ids=[flow_id], + flow_type=[FlowType.KNOLEDGE_SPACE.value], + user_ids=[self.login_user.user_id], + include_delete=False, + ) if session: await MessageSessionDao.delete_session(chat_id=chat_id) return True - async def get_chat_folder_history(self, space_id: int, folder_id: int, chat_id: str, page_size: int = 20) \ - -> List[ChatMessageHistoryResponse]: + async def get_chat_folder_history( + self, space_id: int, folder_id: int, chat_id: str, page_size: int = 20 + ) -> list[ChatMessageHistoryResponse]: if folder_id: await self._require_folder_view_permission(space_id, folder_id) else: @@ -361,11 +384,13 @@ class KnowledgeSpaceChatService: else: await self._require_space_view_permission(space_id) flow_id = self.generate_flow_id_for_folder(space_id, folder_id) - session = await MessageSessionDao.afilter_session(chat_ids=[chat_id], - flow_ids=[flow_id], - flow_type=[FlowType.KNOLEDGE_SPACE.value], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + chat_ids=[chat_id], + flow_ids=[flow_id], + flow_type=[FlowType.KNOLEDGE_SPACE.value], + user_ids=[self.login_user.user_id], + include_delete=False, + ) if not session: return True session = session[0] @@ -375,8 +400,8 @@ class KnowledgeSpaceChatService: async def _build_folder_search_kwargs( self, knowledge_id: int, - target_file_ids: Optional[List[int]], - ) -> Tuple[Optional[dict], Optional[dict]]: + target_file_ids: list[int] | None, + ) -> tuple[dict | None, dict | None]: """Compute Milvus and ES search_kwargs with primary-version-only filtering. Args: @@ -390,9 +415,7 @@ class KnowledgeSpaceChatService: skip retriever construction). """ # Fetch non-primary file ids once, used in both branches. - excluded: List[int] = await self.version_repo.find_non_primary_file_ids_by_knowledge_ids( - [knowledge_id] - ) + excluded: list[int] = await self.version_repo.find_non_primary_file_ids_by_knowledge_ids([knowledge_id]) if target_file_ids is None: # Branch A: whole-space query — apply not-in filter when exclusions exist. @@ -430,9 +453,9 @@ class KnowledgeSpaceChatService: *, space, query: str, - candidate_file_ids: Optional[List[int]], + candidate_file_ids: list[int] | None, max_content: int, - ) -> List[Document]: + ) -> list[Document]: """F029: two-layer view_file filter retrieval loop (AD-01 / AD-03). Returns docs whose ``document_id`` belongs to a file the current user @@ -442,9 +465,7 @@ class KnowledgeSpaceChatService: visibility = self._visibility_service() conf = self._qa_filter_conf() - index_filter = await visibility.build_index_prefilter( - space.id, candidate_file_ids - ) + index_filter = await visibility.build_index_prefilter(space.id, candidate_file_ids) if index_filter.is_empty: logger.info( "permission_filter | space_id={} strategy=empty accessible_ids_size={} " @@ -461,16 +482,16 @@ class KnowledgeSpaceChatService: conf.retrieval_expansion_multiplier, ) - survivors: List[Document] = [] + survivors: list[Document] = [] for attempt_idx, multiplier in enumerate(multipliers, start=1): base_k = 100 # current retrieval default; multiplier scales it - milvus_kwargs: Dict[str, Any] = { + milvus_kwargs: dict[str, Any] = { "k": base_k * multiplier, "param": {"ef": 110}, } if base_milvus_expr: milvus_kwargs["expr"] = base_milvus_expr - es_kwargs: Dict[str, Any] = {"k": base_k * multiplier} + es_kwargs: dict[str, Any] = {"k": base_k * multiplier} if base_es_filter: es_kwargs["filter"] = base_es_filter @@ -487,21 +508,15 @@ class KnowledgeSpaceChatService: max_content=max_content, sort_by_source_and_index=True, ) - docs: List[Document] = await retriever_tool.ainvoke(query) + docs: list[Document] = await retriever_tool.ainvoke(query) unique_file_ids = { int(d.metadata.get("document_id")) for d in docs if d.metadata and d.metadata.get("document_id") is not None } - permitted = await visibility.post_filter_visible_files( - space.id, unique_file_ids - ) - survivors = [ - d - for d in docs - if int(d.metadata.get("document_id", -1)) in permitted - ] + permitted = await visibility.post_filter_visible_files(space.id, unique_file_ids) + survivors = [d for d in docs if int(d.metadata.get("document_id", -1)) in permitted] dropped = len(docs) - len(survivors) logger.info( @@ -524,7 +539,7 @@ class KnowledgeSpaceChatService: async def _render_rag_response( self, session, - finally_docs: List[Document], + finally_docs: list[Document], query: str, model_id: int, tags: Any = None, @@ -544,17 +559,18 @@ class KnowledgeSpaceChatService: if space_conf.system_prompt: inputs = [ - SystemMessage(content=space_conf.system_prompt.format(cur_date=datetime.now().strftime('%Y-%m-%d'))), + SystemMessage(content=space_conf.system_prompt.format(cur_date=datetime.now().strftime("%Y-%m-%d"))), HumanMessage( - content=space_conf.user_prompt.format(retrieved_file_content=file_content, question=query)), + content=space_conf.user_prompt.format(retrieved_file_content=file_content, question=query) + ), ] else: prompt_obj = prompt_service.render_prompt( namespace="knowledge_space", prompt_name="rag_prompt", - cur_date=datetime.now().strftime('%Y-%m-%d'), + cur_date=datetime.now().strftime("%Y-%m-%d"), retrieved_file_content=file_content, - question=query + question=query, ) inputs = [SystemMessage(content=prompt_obj.prompt.system), HumanMessage(content=prompt_obj.prompt.user)] answer = "" @@ -581,18 +597,21 @@ class KnowledgeSpaceChatService: "content": one.content, "reasoning_content": chunk_reasoning_content, }, - type="stream" + type="stream", ) reasoning_content += chunk_reasoning_content answer += one.content messages = [ ChatMessage( category=MessageCategory.QUESTION, - message=json.dumps({ - "query": query, - "tags": tags, - "model_id": model_id, - }, ensure_ascii=False), + message=json.dumps( + { + "query": query, + "tags": tags, + "model_id": model_id, + }, + ensure_ascii=False, + ), chat_id=session.chat_id, flow_id=session.flow_id, user_id=self.login_user.user_id, @@ -601,43 +620,54 @@ class KnowledgeSpaceChatService: ), ChatMessage( category=MessageCategory.ANSWER, - message=json.dumps({ - "content": answer, - "reasoning_content": reasoning_content - }, ensure_ascii=False), + message=json.dumps({"content": answer, "reasoning_content": reasoning_content}, ensure_ascii=False), chat_id=session.chat_id, flow_id=session.flow_id, user_id=self.login_user.user_id, type="end", is_bot=True, - ) + ), ] await ChatMessageDao.ainsert_batch(messages) if not session.name: - asyncio.create_task(self.generate_conversation( - user_id=self.login_user.user_id, - chat_id=session.chat_id, - question=query, - answer=answer, - )) + asyncio.create_task( + self.generate_conversation( + user_id=self.login_user.user_id, + chat_id=session.chat_id, + question=query, + answer=answer, + ) + ) yield ChatResponse( category=MessageCategory.STREAM, message={ "content": answer, "reasoning_content": reasoning_content, + # Real persisted answer ChatMessage id: the client renders the + # streamed answer under a temporary placeholder id; sending the + # real id on the end event lets it swap in immediately so + # like/dislike writes to the right row (previously a like clicked + # before a page refresh was lost, since it hit the placeholder id). + "message_id": messages[1].id, }, - type="end" + type="end", ) - async def chat_folder(self, knowledge_id: int, folder_id: int, chat_id: str, query: str, - model_id: int, tags: Optional[List[Dict]] = None) -> AsyncIterator[ChatResponse]: - """ Folder RAG query """ + async def chat_folder( + self, + knowledge_id: int, + folder_id: int, + chat_id: str, + query: str, + model_id: int, + tags: list[dict] | None = None, + ) -> AsyncIterator[ChatResponse]: + """Folder RAG query""" flow_id = self.generate_flow_id_for_folder(knowledge_id, folder_id) - session = await MessageSessionDao.afilter_session(chat_ids=[chat_id], - flow_ids=[flow_id], - user_ids=[self.login_user.user_id], - include_delete=False) + session = await MessageSessionDao.afilter_session( + chat_ids=[chat_id], flow_ids=[flow_id], user_ids=[self.login_user.user_id], include_delete=False + ) if not session: raise NotFoundError(msg="Folder session not found") session = session[0] @@ -659,8 +689,9 @@ class KnowledgeSpaceChatService: target_file_ids = [one.id for one in folder_files] if tags: - tag_file_ids = await TagDao.aget_resources_by_tags([one.get("id") for one in tags], - resource_type=ResourceTypeEnum.SPACE_FILE) + tag_file_ids = await TagDao.aget_resources_by_tags( + [one.get("id") for one in tags], resource_type=ResourceTypeEnum.SPACE_FILE + ) tag_file_ids = [int(one.resource_id) for one in tag_file_ids] if target_file_ids is not None: @@ -681,23 +712,23 @@ class KnowledgeSpaceChatService: max_content=space_conf.max_chunk_size, ) - async for one in self._render_rag_response( - session, finally_docs, query, model_id, tags - ): + async for one in self._render_rag_response(session, finally_docs, query, model_id, tags): yield one - async def get_space_llm_config(self, model_id: int) -> Tuple[BaseChatModel, KnowledgeSpaceConfig]: + async def get_space_llm_config(self, model_id: int) -> tuple[BaseChatModel, KnowledgeSpaceConfig]: """ Get chat configuration (model and prompts) Returns: tuple: (model_id, subscription_config) """ - llm = await LLMService.get_bisheng_llm(model_id=model_id, - app_id=ApplicationTypeEnum.KNOWLEDGE_SPACE.value, - app_name=ApplicationTypeEnum.KNOWLEDGE_SPACE.value, - app_type=ApplicationTypeEnum.KNOWLEDGE_SPACE, - user_id=self.login_user.user_id) + llm = await LLMService.get_bisheng_llm( + model_id=model_id, + app_id=ApplicationTypeEnum.KNOWLEDGE_SPACE.value, + app_name=ApplicationTypeEnum.KNOWLEDGE_SPACE.value, + app_type=ApplicationTypeEnum.KNOWLEDGE_SPACE, + user_id=self.login_user.user_id, + ) # Get subscription configuration config = await WorkStationService.get_knowledge_space_config() @@ -709,8 +740,8 @@ class KnowledgeSpaceChatService: async def _resolve_kb_target_file_ids( self, knowledge_id: int, - tag_names: List[str], - ) -> Optional[List[int]]: + tag_names: list[str], + ) -> list[int] | None: """Map a list of tag names (scoped to a knowledge space) to file ids. Returns ``None`` when no tag filter is requested (caller treats as @@ -720,7 +751,7 @@ class KnowledgeSpaceChatService: if not tag_names: return None - resolved_tag_ids: List[int] = [] + resolved_tag_ids: list[int] = [] for tag_name in tag_names: tags = await TagDao.get_tags_by_business( business_type=TagBusinessTypeEnum.KNOWLEDGE_SPACE, @@ -741,11 +772,11 @@ class KnowledgeSpaceChatService: self, *, query: str, - knowledge_base_ids: List[int], - kb_filters: Optional[Dict[int, Dict[str, Any]]] = None, + knowledge_base_ids: list[int], + kb_filters: dict[int, dict[str, Any]] | None = None, top_k: int = 10, max_content: int = 15000, - ) -> List[Tuple[int, Document]]: + ) -> list[tuple[int, Document]]: """Retrieve chunks across one or more knowledge bases without LLM generation. Args: @@ -764,7 +795,7 @@ class KnowledgeSpaceChatService: raise HTTPException(status_code=400, detail="knowledge_base_ids must not be empty") kb_id_set = set(knowledge_base_ids) - filters_by_kb: Dict[int, Dict[str, Any]] = {} + filters_by_kb: dict[int, dict[str, Any]] = {} if kb_filters: for kb_id, spec in kb_filters.items(): if kb_id not in kb_id_set: @@ -792,14 +823,14 @@ class KnowledgeSpaceChatService: ) ) - flattened: List[Tuple[int, Document]] = [] + flattened: list[tuple[int, Document]] = [] for chunks in per_kb_results: flattened.extend(chunks) flattened = flattened[:top_k] await self._attach_document_update_time(flattened) return flattened - async def _attach_document_update_time(self, results: List[Tuple[int, Document]]) -> None: + async def _attach_document_update_time(self, results: list[tuple[int, Document]]) -> None: """Annotate each chunk's metadata with its source file's latest update time. The metadata ``document_id`` equals the ``KnowledgeFile`` id, so a single @@ -808,19 +839,14 @@ class KnowledgeSpaceChatService: or update time get an empty string. """ document_ids = { - int(doc.metadata.get("document_id", 0)) - for _, doc in results - if doc.metadata.get("document_id") + int(doc.metadata.get("document_id", 0)) for _, doc in results if doc.metadata.get("document_id") } document_ids.discard(0) if not document_ids: return files = await KnowledgeFileDao.aget_file_by_ids(list(document_ids)) - update_time_by_id = { - f.id: f.update_time.strftime("%Y-%m-%d %H:%M:%S") if f.update_time else "" - for f in files - } + update_time_by_id = {f.id: f.update_time.strftime("%Y-%m-%d %H:%M:%S") if f.update_time else "" for f in files} for _, doc in results: doc_id = int(doc.metadata.get("document_id", 0)) doc.metadata["document_update_time"] = update_time_by_id.get(doc_id, "") @@ -830,9 +856,9 @@ class KnowledgeSpaceChatService: kb_id: int, *, query: str, - tag_names: List[str], + tag_names: list[str], max_content: int, - ) -> List[Tuple[int, Document]]: + ) -> list[tuple[int, Document]]: """Retrieve chunks for a single knowledge base. Raises NotFoundError if missing.""" await self._require_space_view_permission(kb_id) space = await KnowledgeDao.aquery_by_id(kb_id) @@ -847,9 +873,7 @@ class KnowledgeSpaceChatService: if milvus_kwargs is None and es_kwargs is None: return [] - milvus_vector = await KnowledgeRag.init_knowledge_milvus_vectorstore( - self.login_user.user_id, knowledge=space - ) + milvus_vector = await KnowledgeRag.init_knowledge_milvus_vectorstore(self.login_user.user_id, knowledge=space) es_vector = await KnowledgeRag.init_knowledge_es_vectorstore(knowledge=space) vector_retriever = milvus_vector.as_retriever(search_kwargs=milvus_kwargs) es_retriever = es_vector.as_retriever(search_kwargs=es_kwargs) @@ -860,7 +884,7 @@ class KnowledgeSpaceChatService: max_content=max_content, sort_by_source_and_index=False, ) - docs: List[Document] = await retriever_tool.ainvoke(query) + docs: list[Document] = await retriever_tool.ainvoke(query) return [(kb_id, d) for d in docs] async def _aretrieve_chunks_dispatch( @@ -868,9 +892,9 @@ class KnowledgeSpaceChatService: kb_id: int, *, query: str, - tag_names: List[str], + tag_names: list[str], max_content: int, - ) -> List[Tuple[int, Document]]: + ) -> list[tuple[int, Document]]: """F030: route a single id to the space or document-knowledge-base path. Knowledge space (type=3) keeps the view_space/view_file-gated path; a @@ -888,11 +912,11 @@ class KnowledgeSpaceChatService: if not row: raise NotFoundError(msg=f"Knowledge resource {kb_id} not found") if row.type == KnowledgeTypeEnum.SPACE.value: - return await self._aretrieve_chunks_for_kb( - kb_id, query=query, tag_names=tag_names, max_content=max_content) + return await self._aretrieve_chunks_for_kb(kb_id, query=query, tag_names=tag_names, max_content=max_content) if row.type == KnowledgeTypeEnum.NORMAL.value: return await self._aretrieve_chunks_for_knowledge_base( - row, query=query, tag_names=tag_names, max_content=max_content) + row, query=query, tag_names=tag_names, max_content=max_content + ) # QA (type=1) / personal (type=2) / illegal types are not retrievable here. raise KnowledgeTypeNotSupportedError() @@ -901,9 +925,9 @@ class KnowledgeSpaceChatService: kb, *, query: str, - tag_names: List[str], + tag_names: list[str], max_content: int, - ) -> List[Tuple[int, Document]]: + ) -> list[tuple[int, Document]]: """Retrieve chunks for a document/QA knowledge base (type 0/1, F030). Uses knowledge-base read permission (not view_space) and retrieves across @@ -939,9 +963,7 @@ class KnowledgeSpaceChatService: milvus_kwargs = {"k": 100, "param": {"ef": 110}} es_kwargs = {"k": 100} - milvus_vector = await KnowledgeRag.init_knowledge_milvus_vectorstore( - self.login_user.user_id, knowledge=kb - ) + milvus_vector = await KnowledgeRag.init_knowledge_milvus_vectorstore(self.login_user.user_id, knowledge=kb) es_vector = await KnowledgeRag.init_knowledge_es_vectorstore(knowledge=kb) vector_retriever = milvus_vector.as_retriever(search_kwargs=milvus_kwargs) es_retriever = es_vector.as_retriever(search_kwargs=es_kwargs) @@ -952,14 +974,14 @@ class KnowledgeSpaceChatService: max_content=max_content, sort_by_source_and_index=False, ) - docs: List[Document] = await retriever_tool.ainvoke(query) + docs: list[Document] = await retriever_tool.ainvoke(query) return [(kb_id, d) for d in docs] async def _resolve_kb_file_ids_by_tags( self, knowledge_id: int, - tag_names: List[str], - ) -> Optional[List[int]]: + tag_names: list[str], + ) -> list[int] | None: """Map tag names (scoped to a knowledge base) to file ids. ``None`` = no tag filter (whole KB). Empty list = tags given but no files @@ -967,7 +989,7 @@ class KnowledgeSpaceChatService: """ if not tag_names: return None - resolved_tag_ids: List[int] = [] + resolved_tag_ids: list[int] = [] for tag_name in tag_names: tags = await TagDao.get_tags_by_business( business_type=TagBusinessTypeEnum.KNOWLEDGE, @@ -984,7 +1006,7 @@ class KnowledgeSpaceChatService: return [int(link.resource_id) for link in tag_links] @staticmethod - async def get_history(chat_id: str, limit: int = 4) -> List[BaseMessage]: + async def get_history(chat_id: str, limit: int = 4) -> list[BaseMessage]: res = await ChatMessageDao.aget_messages_by_chat_id(chat_id, ["question", "answer"], limit=limit) messages = [] for one in res: diff --git a/src/backend/bisheng/linsight/api/endpoints/linsight.py b/src/backend/bisheng/linsight/api/endpoints/linsight.py index 103f42464..ec614e6f4 100644 --- a/src/backend/bisheng/linsight/api/endpoints/linsight.py +++ b/src/backend/bisheng/linsight/api/endpoints/linsight.py @@ -537,7 +537,18 @@ async def get_linsight_session_version_list( model for model in linsight_session_version_models if model.id == shared_version_id ] - return resp_200([model.model_dump() for model in linsight_session_version_models]) + # Unified like/dislike via ChatMessage: the task result is itself a + # category="task" ChatMessage and the verdict lives on that row. Attach each + # version's linked task message_id + liked so the standalone linsight page can + # rate through the shared /liked endpoint and re-highlight on reload (same as + # the in-conversation task turn). + version_dumps = [model.model_dump() for model in linsight_session_version_models] + feedback_map = await linsight_execute_utils.get_task_feedback_by_version(session_id) + for dump in version_dumps: + info = feedback_map.get(dump.get("id")) + dump["message_id"] = info["message_id"] if info else None + dump["liked"] = info["liked"] if info else 0 + return resp_200(version_dumps) # Get task execution details diff --git a/src/backend/bisheng/linsight/domain/utils.py b/src/backend/bisheng/linsight/domain/utils.py index ffea34752..85e36b29a 100644 --- a/src/backend/bisheng/linsight/domain/utils.py +++ b/src/backend/bisheng/linsight/domain/utils.py @@ -387,6 +387,32 @@ async def persist_task_turn_message(session_model: LinsightSessionVersion) -> Ch ) +async def get_task_feedback_by_version(session_id: str) -> dict[str, dict]: + """Map each linsight session_version id -> its task ChatMessage feedback. + + The task result is a bot ``ChatMessage`` (``category="task"``) in the + conversation ``session_id`` carrying ``extra.linsight_session_version_id``. + The like/dislike verdict is stored on that ChatMessage row (unified with + daily / knowledge / channel), so the standalone linsight page rates and + echoes the highlight via the shared chatmessage feedback instead of a + linsight-specific column. + + Returns ``{session_version_id: {"message_id": int, "liked": int}}``. + """ + rows = await ChatMessageDao.aget_messages_by_chat_id(chat_id=session_id, category_list=["task"], limit=1000) + result: dict[str, dict] = {} + for row in rows: + if not row.is_bot: + continue + try: + svid = json.loads(row.extra or "{}").get("linsight_session_version_id") + except (json.JSONDecodeError, TypeError): + svid = None + if svid: + result[svid] = {"message_id": row.id, "liked": row.liked or 0} + return result + + async def persist_task_user_turn(chat_id: str, user_id: int, question: str, files: list | None = None) -> ChatMessage: """F035 Track J (TJ-3): persist the task user turn into the unified conversation. diff --git a/src/backend/bisheng/llm/domain/services/llm.py b/src/backend/bisheng/llm/domain/services/llm.py index 86befe18c..4a0eddf68 100644 --- a/src/backend/bisheng/llm/domain/services/llm.py +++ b/src/backend/bisheng/llm/domain/services/llm.py @@ -94,6 +94,40 @@ def _llm_api_key_hash(config: dict | None) -> str | None: return hashlib.sha256(key.encode()).hexdigest()[:16] +def _coerce_model_id(value: Any) -> int | None: + if value is None or isinstance(value, bool): + return None + try: + model_id = int(value) + except (TypeError, ValueError): + return None + return model_id if model_id > 0 else None + + +def _workbench_model_ref_values(config: WorkbenchModelConfig) -> list[Any]: + return [ + *(one.id for one in (config.models or [])), + config.linsight_default_model_id, + getattr(config.embedding_model, "id", None), + getattr(config.asr_model, "id", None), + getattr(config.tts_model, "id", None), + getattr(config.chat_title_llm, "id", None), + ] + + +def _allowed_system_model_owner( + model_tenant_id: int | None, + target_tenant_id: int, + *, + inherited_from_root: bool, +) -> bool: + if inherited_from_root: + return model_tenant_id == ROOT_TENANT_ID + if model_tenant_id == target_tenant_id: + return True + return target_tenant_id != ROOT_TENANT_ID and model_tenant_id == ROOT_TENANT_ID + + async def _write_llm_audit( login_user: "UserPayload", action: str, @@ -133,6 +167,25 @@ class LLMService: return None return model_id if model_id > 0 else None + # Credential and endpoint fields in the server config. Users pasting values with + # leading/trailing whitespace break the http request header(e.g. "Bearer sk-xxx ") + STRIP_CONFIG_KEY_SUFFIXES = ("_key", "_secret", "_base", "_url", "_endpoint", "_proxy", "_version") + + @classmethod + def strip_config_whitespace(cls, config: dict | None) -> dict | None: + """Strip leading/trailing whitespace from credential and endpoint fields in the server config""" + if not isinstance(config, dict): + return config + result = {} + for key, value in config.items(): + if isinstance(value, dict): + result[key] = cls.strip_config_whitespace(value) + elif isinstance(value, str) and key.endswith(cls.STRIP_CONFIG_KEY_SUFFIXES): + result[key] = value.strip() + else: + result[key] = value + return result + @classmethod async def _aget_inherited_system_default_server_ids_for_leaf( cls, @@ -242,6 +295,83 @@ class LLMService: ) return model_cls(**(json.loads(value) if value else {})) + @classmethod + async def _sanitize_workbench_config_refs( + cls, + config: WorkbenchModelConfig, + target_tenant_id: int, + *, + inherited_from_root: bool, + ) -> WorkbenchModelConfig: + model_ids = { + model_id + for model_id in (_coerce_model_id(value) for value in _workbench_model_ref_values(config)) + if model_id is not None + } + if not model_ids: + return cls._filter_workbench_config(config, set()) + + with bypass_tenant_filter(): + rows = await LLMDao.aget_model_by_ids(list(model_ids)) + allowed_ids = { + row.id + for row in rows + if _allowed_system_model_owner( + row.tenant_id, + target_tenant_id, + inherited_from_root=inherited_from_root, + ) + } + return cls._filter_workbench_config(config, allowed_ids) + + @classmethod + def _sanitize_workbench_config_refs_sync( + cls, + config: WorkbenchModelConfig, + target_tenant_id: int, + *, + inherited_from_root: bool, + ) -> WorkbenchModelConfig: + model_ids = { + model_id + for model_id in (_coerce_model_id(value) for value in _workbench_model_ref_values(config)) + if model_id is not None + } + if not model_ids: + return cls._filter_workbench_config(config, set()) + + with bypass_tenant_filter(): + rows = LLMDao.get_model_by_ids(list(model_ids)) + allowed_ids = { + row.id + for row in rows + if _allowed_system_model_owner( + row.tenant_id, + target_tenant_id, + inherited_from_root=inherited_from_root, + ) + } + return cls._filter_workbench_config(config, allowed_ids) + + @staticmethod + def _filter_workbench_config( + config: WorkbenchModelConfig, + allowed_ids: set[int], + ) -> WorkbenchModelConfig: + def is_allowed(value: Any) -> bool: + model_id = _coerce_model_id(value) + return model_id is not None and model_id in allowed_ids + + if config.models is not None: + config.models = [one for one in config.models if is_allowed(one.id)] + if not is_allowed(config.linsight_default_model_id): + config.linsight_default_model_id = None + for field_name in ("embedding_model", "asr_model", "tts_model", "chat_title_llm"): + ws_model = getattr(config, field_name) + if ws_model is not None and not is_allowed(ws_model.id): + setattr(config, field_name, None) + return config + @classmethod async def get_all_llm( cls, @@ -486,6 +616,7 @@ class LLMService: raise ModelNameRepeatError.http_exception() db_server = LLMServer(**server.model_dump(exclude={"models", "share_to_children"})) + db_server.config = cls.strip_config_whitespace(db_server.config) db_server.user_id = login_user.user_id db_server = await LLMDao.ainsert_server_with_models( @@ -711,7 +842,9 @@ class LLMService: exist_server.limit_flag = server.limit_flag exist_server.limit = server.limit mask_maker = JsonFieldMasker() - exist_server.config = mask_maker.update_json_with_masked(exist_server.config, server.config) + exist_server.config = cls.strip_config_whitespace( + mask_maker.update_json_with_masked(exist_server.config, server.config) + ) # Route share_to_children flips through the dedicated DAO helper # so super-admin / Root-only invariants are enforced via FGA. @@ -1305,18 +1438,31 @@ class LLMService: cls, tenant_id: int | None = None, ) -> tuple[WorkbenchModelConfig, bool, bool]: - return await cls._aget_typed_with_meta( + target = _resolve_tenant_id(tenant_id) + config, inherited, blocked = await cls._aget_typed_with_meta( ConfigKeyEnum.LINSIGHT_LLM, WorkbenchModelConfig, - tenant_id, + target, ) + config = await cls._sanitize_workbench_config_refs( + config, + target, + inherited_from_root=inherited, + ) + return config, inherited, blocked @classmethod def get_workbench_llm_sync(cls, tenant_id: int | None = None) -> WorkbenchModelConfig: - return cls._get_typed_sync( - ConfigKeyEnum.LINSIGHT_LLM, - WorkbenchModelConfig, - tenant_id, + target = _resolve_tenant_id(tenant_id) + value, inherited, _ = TenantSystemModelConfigDao.resolve( + tenant_id=target, + key=ConfigKeyEnum.LINSIGHT_LLM.value, + ) + config = WorkbenchModelConfig(**(json.loads(value) if value else {})) + return cls._sanitize_workbench_config_refs_sync( + config, + target, + inherited_from_root=inherited, ) @classmethod @@ -1370,7 +1516,14 @@ class LLMService: app_type=ApplicationTypeEnum.TTS, user_id=login_user.user_id, ) - audio_bytes = await tts_client.ainvoke(text) + try: + audio_bytes = await tts_client.ainvoke(text) + except Exception as e: + # Provider-level synthesis failure (e.g. empty audio from the TTS + # backend) — surface as a dedicated business code, not a raw 500, + # so the client shows a toast instead of the global maintenance overlay. + logger.exception("workbench tts synthesis failed") + raise TtsSynthesisFailedError.http_exception() from e # upload to minio object_name = f"tts/{generate_uuid()}.mp3" diff --git a/src/backend/bisheng/permission/api/endpoints/resource_permission.py b/src/backend/bisheng/permission/api/endpoints/resource_permission.py index 96f310482..1ed243783 100644 --- a/src/backend/bisheng/permission/api/endpoints/resource_permission.py +++ b/src/backend/bisheng/permission/api/endpoints/resource_permission.py @@ -1538,6 +1538,20 @@ async def authorize_resource( and not _is_invalid_owner_subject(revoke.subject_type, revoke.relation) ] + # Users cannot modify their OWN permission in the member dialog: changing your + # own role (e.g. owner→editor) strips your management access and locks you out + # of the dialog on the next reload; removing yourself is likewise disallowed. + # Managing OTHERS is fine. Creator rows are already locked client-side via + # is_creator; this is the server-side backstop for every resource type. + self_subject_changes = [ + item + for item in (tuple_grants + tuple_revokes) + if getattr(item, "subject_type", None) == "user" + and int(getattr(item, "subject_id", 0) or 0) == int(login_user.user_id) + ] + if self_subject_changes: + return PermissionDeniedError.return_resp("不能修改自己的权限") + # Owner and creator are decoupled: an owner may be revoked/downgraded as long # as another owner survives, but removing the last owner would orphan the # resource (INV-2). Applies to ALL owner revokes (self or someone else's), and diff --git a/src/backend/bisheng/sso_sync/domain/services/login_sync_service.py b/src/backend/bisheng/sso_sync/domain/services/login_sync_service.py index 245f830b7..8a2146c25 100644 --- a/src/backend/bisheng/sso_sync/domain/services/login_sync_service.py +++ b/src/backend/bisheng/sso_sync/domain/services/login_sync_service.py @@ -9,9 +9,9 @@ derivation → leaf status check → JWT signing. from __future__ import annotations +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import datetime -from typing import AsyncIterator, List, Optional, Tuple from loguru import logger @@ -29,10 +29,10 @@ from bisheng.core.context.tenant import ( set_current_tenant_id, ) from bisheng.database.constants import ( - AdminRole, - DefaultRole, USER_DISABLE_SOURCE_GATEWAY, USER_DISABLE_SOURCE_ORG_SYNC, + AdminRole, + DefaultRole, ) from bisheng.database.models.audit_log import AuditLogDao from bisheng.database.models.department import DepartmentDao, UserDepartmentDao @@ -73,14 +73,19 @@ from bisheng.user.domain.models.user_role import UserRoleDao from bisheng.user.domain.services.auth import AuthJwt, LoginUser from bisheng.user.domain.services.user import UserService -_USER_LOCK_KEY = 'user:sso_lock:{external_user_id}' +_USER_LOCK_KEY = "user:sso_lock:{external_user_id}" class LoginSyncService: SOURCE = DEFAULT_SSO_SYNC_SOURCE + #: Seeded guest department (临时访客), see ``init_data._init_default_root_department``. + #: Used as the department fallback for SSO users whose payload carries no HR + #: department, mirroring self-registration (``UserService.user_register``). + GUEST_DEPT_ID = "BS@guest" + @staticmethod - def _disable_source_for_row(row_source: str, want_delete: int) -> Optional[str]: + def _disable_source_for_row(row_source: str, want_delete: int) -> str | None: if want_delete != 1: return None if row_source == WECOM_SOURCE: @@ -91,12 +96,10 @@ class LoginSyncService: async def execute( cls, payload: LoginSyncRequest, - request_ip: str = '', + request_ip: str = "", row_source: str = DEFAULT_SSO_SYNC_SOURCE, ) -> LoginSyncResponse: - ttl = int( - getattr(settings.sso_sync, 'user_lock_ttl_seconds', 30) or 30 - ) + ttl = int(getattr(settings.sso_sync, "user_lock_ttl_seconds", 30) or 30) lock_key = _USER_LOCK_KEY.format( external_user_id=payload.external_user_id, ) @@ -104,11 +107,12 @@ class LoginSyncService: async with _acquire_user_lock(lock_key, ttl=ttl) as acquired: if not acquired: raise SsoUserLockBusyError.http_exception( - f'another SSO login for {payload.external_user_id} is ' - f'in progress' + f"another SSO login for {payload.external_user_id} is in progress" ) return await cls._execute_locked( - payload, request_ip, row_source, + payload, + request_ip, + row_source, ) @classmethod @@ -123,18 +127,14 @@ class LoginSyncService: try: # --- parent chain (enabled + disabled WeCom users share binding) --- if payload.primary_dept_external_id: - all_exts = [payload.primary_dept_external_id] + list( - payload.secondary_dept_external_ids or [] - ) + all_exts = [payload.primary_dept_external_id] + list(payload.secondary_dept_external_ids or []) ext_to_dept = await DeptUpsertService.assert_parent_chain_exists( all_exts, source=row_source, ) primary_dept = ext_to_dept[payload.primary_dept_external_id] secondary_depts = [ - ext_to_dept[e] - for e in (payload.secondary_dept_external_ids or []) - if e in ext_to_dept + ext_to_dept[e] for e in (payload.secondary_dept_external_ids or []) if e in ext_to_dept ] else: primary_dept = None @@ -147,7 +147,9 @@ class LoginSyncService: ) user, full_department_override = await cls._upsert_user( - payload, request_ip=request_ip, row_source=row_source, + payload, + request_ip=request_ip, + row_source=row_source, ) if full_department_override: @@ -159,11 +161,11 @@ class LoginSyncService: ) elif primary_dept is not None: await cls._ensure_primary( - user.user_id, primary_dept.id, row_source=row_source, - ) - reconcile_secondary = ( - 'secondary_dept_external_ids' in payload.model_fields_set + user.user_id, + primary_dept.id, + row_source=row_source, ) + reconcile_secondary = "secondary_dept_external_ids" in payload.model_fields_set await cls._ensure_secondaries( user.user_id, [d.id for d in secondary_depts], @@ -185,21 +187,32 @@ class LoginSyncService: return LoginSyncResponse( user_id=int(user.user_id or 0), leaf_tenant_id=ROOT_TENANT_ID, - token='', + token="", ) - leaf_tenant = await UserTenantSyncService.sync_user( - user.user_id, trigger=UserTenantSyncTrigger.LOGIN, + # Guest fallback: an SSO user with no HR department (payload + # carried none and no membership survived above) joins the guest + # department, mirroring self-registration so the account is never + # left department-less. Runs after the disable short-circuit so + # disabled placeholder users are not given a guest membership. + await cls._reconcile_guest_membership( + user.user_id, + row_source=row_source, ) - if leaf_tenant.status != 'active': + leaf_tenant = await UserTenantSyncService.sync_user( + user.user_id, + trigger=UserTenantSyncTrigger.LOGIN, + ) + + if leaf_tenant.status != "active": logger.warning( - 'F014 login blocked: user %s leaf tenant %s status=%s', - user.user_id, leaf_tenant.id, leaf_tenant.status, - ) - raise SsoTenantDisabledError.http_exception( - f'tenant {leaf_tenant.id} status={leaf_tenant.status}' + "F014 login blocked: user %s leaf tenant %s status=%s", + user.user_id, + leaf_tenant.id, + leaf_tenant.status, ) + raise SsoTenantDisabledError.http_exception(f"tenant {leaf_tenant.id} status={leaf_tenant.status}") guard = await UserService._reject_login_if_user_has_no_usable_access(user) if guard is not None: @@ -207,6 +220,7 @@ class LoginSyncService: UserNoRoleForLoginError, UserNoWebMenuForLoginError, ) + if guard.status_code == UserNoRoleForLoginError.Code: raise UserNoRoleForLoginError() raise UserNoWebMenuForLoginError() @@ -214,7 +228,8 @@ class LoginSyncService: auth_jwt = AuthJwt() token_version = await UserDao.aget_token_version(user.user_id) access_token = LoginUser.create_access_token( - user, auth_jwt, + user, + auth_jwt, tenant_id=leaf_tenant.id, token_version=token_version, ) @@ -237,7 +252,7 @@ class LoginSyncService: payload: LoginSyncRequest, request_ip: str, row_source: str, - ) -> Tuple[User, bool]: + ) -> tuple[User, bool]: ext = payload.external_user_id attrs = payload.user_attrs full_department_override = False @@ -245,7 +260,7 @@ class LoginSyncService: if user is None: legacy = await UserDao.aget_by_external_id(ext) if legacy is not None: - if int(getattr(legacy, 'delete', 0) or 0) == 1: + if int(getattr(legacy, "delete", 0) or 0) == 1: # Do not re-adopt disabled rows unless the sync payload # explicitly states account state (e.g. WeCom enable/disable). if payload.account_disabled is None: @@ -259,7 +274,7 @@ class LoginSyncService: else: legacy.source = row_source write_migration_audit = True - full_department_override = old_source == 'local' + full_department_override = old_source == "local" user = legacy cls._apply_user_attrs(user, attrs) cls._touch_user_sync_time(user) @@ -270,13 +285,13 @@ class LoginSyncService: operator_id=0, operator_tenant_id=ROOT_TENANT_ID, action=TenantAuditAction.USER_SOURCE_MIGRATED.value, - target_type='user', + target_type="user", target_id=str(legacy.user_id), metadata={ - 'old_source': old_source, - 'new_source': row_source, - 'external_id': ext, - 'via': 'sso_realtime', + "old_source": old_source, + "new_source": row_source, + "external_id": ext, + "via": "sso_realtime", }, ip_address=request_ip, ) @@ -284,12 +299,12 @@ class LoginSyncService: new_delete = 1 if payload.account_disabled is True else 0 ds = cls._disable_source_for_row(row_source, new_delete) new_user = User( - user_name=(attrs.name.strip() if attrs.name else '') or ext, + user_name=(attrs.name.strip() if attrs.name else "") or ext, email=cls._normalize_contact_field(attrs.email), phone_number=cls._normalize_contact_field(attrs.phone), external_id=ext, source=row_source, - password='', + password="", delete=new_delete, disable_source=ds, ) @@ -310,26 +325,31 @@ class LoginSyncService: # Old (migrated) users avoid this because F011's backfill # set is_active=1 for them. from bisheng.database.models.tenant import UserTenantDao + activated = await UserTenantDao.aactivate_user_tenant( - user.user_id, ROOT_TENANT_ID, + user.user_id, + ROOT_TENANT_ID, ) logger.info( - 'SSO new user created with active user_tenant: ' - 'user_id=%s external_id=%s source=%s tenant_id=%s', - user.user_id, ext, row_source, activated.tenant_id, + "SSO new user created with active user_tenant: " + "user_id=%s external_id=%s source=%s tenant_id=%s", + user.user_id, + ext, + row_source, + activated.tenant_id, ) except Exception as e: # pragma: no cover — rare integrity race logger.error( - 'F014 could not create SSO user %s: %s', ext, e, - ) - raise SsoCrossSourceUserError.http_exception( - f'failed to create user for external_id={ext}: {e}' + "F014 could not create SSO user %s: %s", + ext, + e, ) + raise SsoCrossSourceUserError.http_exception(f"failed to create user for external_id={ext}: {e}") else: # WeCom (and Gateway) send explicit ``account_disabled``; when False, # the row below must flip ``delete`` back to 0. Unconditional forbid # here blocked re-enable after 企微禁用 → 再启用 (delete stayed 1). - if int(getattr(user, 'delete', 0) or 0) == 1: + if int(getattr(user, "delete", 0) or 0) == 1: if payload.account_disabled is None: raise UserForbiddenError.http_exception() cls._apply_user_attrs(user, attrs) @@ -339,17 +359,17 @@ class LoginSyncService: # Gateway org sync: optional explicit account enable/disable if payload.account_disabled is not None: want = 1 if payload.account_disabled else 0 - if int(getattr(user, 'delete', 0) or 0) != want: + if int(getattr(user, "delete", 0) or 0) != want: user.delete = want user.disable_source = cls._disable_source_for_row(row_source, want) await UserDao.aupdate_user(user) - if int(getattr(user, 'delete', 0) or 0) == 1 and payload.account_disabled is not True: + if int(getattr(user, "delete", 0) or 0) == 1 and payload.account_disabled is not True: raise UserForbiddenError.http_exception() return user, full_department_override @staticmethod - def _normalize_contact_field(val: Optional[str]) -> Optional[str]: + def _normalize_contact_field(val: str | None) -> str | None: """Strip; empty string → None. ``None`` means omit (do not overwrite in apply).""" if val is None: return None @@ -381,9 +401,66 @@ class LoginSyncService: # Helper: UserDepartment primary + secondary management. # ----------------------------------------------------------------------- + @classmethod + async def _reconcile_guest_membership( + cls, + user_id: int, + *, + row_source: str, + ) -> None: + """Keep guest-department membership consistent with the invariant + "a user belongs to the guest department iff they have no other + department". + + Mirrors self-registration (``UserService.user_register``): an SSO user + whose payload carried no HR department — and who has no surviving + membership — is placed in the guest department (临时访客) as primary, so + the account is never left department-less. Conversely, once a real + department has been assigned (e.g. by a later org-sync that demotes the + guest placeholder to a secondary row), the guest membership is vacated. + + Guest is a ``source='local'`` department mounted under the root tenant, + so making it primary keeps the leaf tenant at ``ROOT_TENANT_ID`` — the + same tenant a department-less SSO user already resolved to. Idempotent + and best-effort: a missing guest department is logged and skipped, never + fatal to login. + """ + guest = await DepartmentDao.aget_by_dept_id(cls.GUEST_DEPT_ID) + if guest is None or getattr(guest, "status", "") != "active": + logger.warning( + "guest department {} missing/inactive; skip SSO guest fallback for user {}", + cls.GUEST_DEPT_ID, + user_id, + ) + return + + guest_id = int(guest.id) + memberships = await UserDepartmentDao.aget_user_departments(user_id) + has_guest = any(int(m.department_id) == guest_id for m in memberships) + has_real = any(int(m.department_id) != guest_id for m in memberships) + + if not memberships: + # Orphan → join the guest department as primary. Track the row as a + # bisheng-internal placeholder (source='local'), matching + # self-registration, so provider-scoped reconcile never touches it. + await UserDepartmentDao.aadd_member( + user_id, + guest_id, + is_primary=1, + source="local", + ) + await cls._sync_department_member_tuples(user_id, [guest_id]) + elif has_guest and has_real: + # A real department now exists → vacate the guest placeholder. + await cls._remove_department_membership(user_id, guest_id) + @classmethod async def _ensure_primary( - cls, user_id: int, dept_id: int, *, row_source: str, + cls, + user_id: int, + dept_id: int, + *, + row_source: str, ) -> None: """Make (user_id, dept_id) the primary department, demoting any previous primary to ``is_primary=0``. Idempotent.""" @@ -395,16 +472,23 @@ class LoginSyncService: # Demote old primary in place instead of deleting to preserve # membership history; F012 sync_user reads only the flag. await UserDepartmentDao.aset_primary_flag( - user_id, current.department_id, is_primary=0, + user_id, + current.department_id, + is_primary=0, ) existing = await UserDepartmentDao.aget_membership(user_id, dept_id) if existing is not None: await UserDepartmentDao.aset_primary_flag( - user_id, dept_id, is_primary=1, + user_id, + dept_id, + is_primary=1, ) else: await UserDepartmentDao.aadd_member( - user_id, dept_id, is_primary=1, source=row_source, + user_id, + dept_id, + is_primary=1, + source=row_source, ) await cls._sync_department_member_tuples(user_id, [dept_id]) @@ -412,16 +496,14 @@ class LoginSyncService: async def _replace_departments_full( cls, user_id: int, - primary_dept_id: Optional[int], + primary_dept_id: int | None, secondary_dept_ids: list[int], *, row_source: str, ) -> None: """Replace all department memberships from the imported payload.""" desired_secondary_ids = [ - int(did) - for did in secondary_dept_ids - if did is not None and int(did) != int(primary_dept_id or 0) + int(did) for did in secondary_dept_ids if did is not None and int(did) != int(primary_dept_id or 0) ] desired_dept_ids: list[int] = [] if primary_dept_id is not None: @@ -430,9 +512,7 @@ class LoginSyncService: desired_dept_ids = list(dict.fromkeys(desired_dept_ids)) current_memberships = await UserDepartmentDao.aget_user_departments(user_id) - current_dept_ids = list(dict.fromkeys( - int(row.department_id) for row in current_memberships - )) + current_dept_ids = list(dict.fromkeys(int(row.department_id) for row in current_memberships)) await cls._replace_department_scoped_roles( user_id, @@ -445,11 +525,17 @@ class LoginSyncService: if primary_dept_id is not None: await UserDepartmentDao.aadd_member( - user_id, int(primary_dept_id), is_primary=1, source=row_source, + user_id, + int(primary_dept_id), + is_primary=1, + source=row_source, ) for department_id in desired_secondary_ids: await UserDepartmentDao.aadd_member( - user_id, int(department_id), is_primary=0, source=row_source, + user_id, + int(department_id), + is_primary=0, + source=row_source, ) await cls._sync_department_member_tuples(user_id, desired_dept_ids) @@ -473,9 +559,9 @@ class LoginSyncService: revoke_role_ids = { int(role.id) for role in role_rows - if getattr(role, 'id', None) is not None - and int(getattr(role, 'department_id', 0) or 0) in revoke_scope - and int(role.id) != AdminRole + if getattr(role, "id", None) is not None + and int(getattr(role, "department_id", 0) or 0) in revoke_scope + and int(role.id) != AdminRole } target_role_ids -= revoke_role_ids @@ -484,7 +570,7 @@ class LoginSyncService: default_role_ids = { int(role_id) for dept in dept_rows - for role_id in (getattr(dept, 'default_role_ids', None) or []) + for role_id in (getattr(dept, "default_role_ids", None) or []) if role_id is not None and int(role_id) != AdminRole } target_role_ids.update(default_role_ids) @@ -504,7 +590,9 @@ class LoginSyncService: @classmethod async def _sync_department_member_tuples( - cls, user_id: int, dept_ids: list[int], + cls, + user_id: int, + dept_ids: list[int], ) -> None: """Best-effort OpenFGA department membership repair for SSO login. @@ -523,7 +611,7 @@ class LoginSyncService: async def _sync_department_admin_tuples( cls, user_id: int, - admin_dept_external_ids: Optional[List[str]], + admin_dept_external_ids: list[str] | None, *, row_source: str, ) -> None: @@ -544,32 +632,33 @@ class LoginSyncService: depts = await DepartmentDao.aget_by_ids(dept_ids) if dept_ids else [] dept_by_id = {int(d.id): d for d in depts if d.id is not None} - reconcile_dept_ids: List[int] = [] + reconcile_dept_ids: list[int] = [] for row in memberships: dept = dept_by_id.get(int(row.department_id)) - if dept is None or getattr(dept, 'source', '') != row_source: + if dept is None or getattr(dept, "source", "") != row_source: continue - ext_raw = getattr(dept, 'external_id', None) + ext_raw = getattr(dept, "external_id", None) if not ext_raw or not str(ext_raw).strip(): continue reconcile_dept_ids.append(int(dept.id)) grants = await DepartmentAdminGrantDao.aget_by_user_and_departments( - user_id, reconcile_dept_ids, + user_id, + reconcile_dept_ids, ) grant_by_dept = {int(g.department_id): g for g in grants} ops = [] - upsert_sso_dept_ids: List[int] = [] - delete_grant_dept_ids: List[int] = [] + upsert_sso_dept_ids: list[int] = [] + delete_grant_dept_ids: list[int] = [] for row in memberships: dept = dept_by_id.get(int(row.department_id)) if dept is None: continue - if getattr(dept, 'source', '') != row_source: + if getattr(dept, "source", "") != row_source: continue - ext_raw = getattr(dept, 'external_id', None) + ext_raw = getattr(dept, "external_id", None) if not ext_raw: continue ext_key = str(ext_raw).strip() @@ -579,27 +668,15 @@ class LoginSyncService: marker = grant_by_dept.get(did) if ext_key in want: - if getattr(dept, 'status', '') != 'active': + if getattr(dept, "status", "") != "active": continue - if ( - marker is not None - and getattr(marker, 'grant_source', '') - == DEPARTMENT_ADMIN_GRANT_SOURCE_MANUAL - ): + if marker is not None and getattr(marker, "grant_source", "") == DEPARTMENT_ADMIN_GRANT_SOURCE_MANUAL: continue - ops.extend( - DepartmentChangeHandler.on_admin_set(did, [user_id]) - ) + ops.extend(DepartmentChangeHandler.on_admin_set(did, [user_id])) upsert_sso_dept_ids.append(did) else: - if ( - marker is not None - and getattr(marker, 'grant_source', '') - == DEPARTMENT_ADMIN_GRANT_SOURCE_SSO - ): - ops.extend( - DepartmentChangeHandler.on_admin_removed(did, [user_id]) - ) + if marker is not None and getattr(marker, "grant_source", "") == DEPARTMENT_ADMIN_GRANT_SOURCE_SSO: + ops.extend(DepartmentChangeHandler.on_admin_removed(did, [user_id])) delete_grant_dept_ids.append(did) if ops: @@ -607,7 +684,9 @@ class LoginSyncService: for did in dict.fromkeys(upsert_sso_dept_ids): await DepartmentAdminGrantDao.aupsert( - user_id, did, DEPARTMENT_ADMIN_GRANT_SOURCE_SSO, + user_id, + did, + DEPARTMENT_ADMIN_GRANT_SOURCE_SSO, ) for did in dict.fromkeys(delete_grant_dept_ids): await DepartmentAdminGrantDao.adelete(user_id, did) @@ -619,7 +698,8 @@ class LoginSyncService: ) await DepartmentKnowledgeSpaceService.cleanup_removed_department_admins( - department_id=did, user_ids=[user_id], + department_id=did, + user_ids=[user_id], ) @classmethod @@ -642,10 +722,9 @@ class LoginSyncService: ) -> None: """Remove a department membership and its FGA/admin markers.""" await UserDepartmentDao.aremove_member(user_id, department_id) - ops = ( - DepartmentChangeHandler.on_member_removed(department_id, user_id) - + DepartmentChangeHandler.on_admin_removed(department_id, [user_id]) - ) + ops = DepartmentChangeHandler.on_member_removed( + department_id, user_id + ) + DepartmentChangeHandler.on_admin_removed(department_id, [user_id]) await DepartmentChangeHandler.execute_async(ops) await DepartmentAdminGrantDao.adelete(user_id, department_id) # Clear the derived knowledge-space binding (space_channel_member row + @@ -655,7 +734,8 @@ class LoginSyncService: ) await DepartmentKnowledgeSpaceService.cleanup_removed_department_admins( - department_id=department_id, user_ids=[user_id], + department_id=department_id, + user_ids=[user_id], ) @classmethod @@ -668,9 +748,9 @@ class LoginSyncService: ) -> None: """Drop secondary rows for ``source=row_source`` departments not in ``want``.""" memberships = await UserDepartmentDao.aget_user_departments(user_id) - to_drop: List[int] = [] + to_drop: list[int] = [] for row in memberships: - if int(getattr(row, 'is_primary', 0) or 0) != 0: + if int(getattr(row, "is_primary", 0) or 0) != 0: continue did = int(row.department_id) if did in want_secondary_ids: @@ -685,7 +765,7 @@ class LoginSyncService: dept = dept_by_id.get(did) if dept is None: continue - if getattr(dept, 'source', '') != row_source: + if getattr(dept, "source", "") != row_source: continue await cls._remove_sso_secondary_membership(user_id, did) @@ -711,18 +791,24 @@ class LoginSyncService: want_ids = {int(x) for x in dept_ids if x is not None} if reconcile_remove: await cls._reconcile_remove_sso_secondary_memberships( - user_id, want_ids, row_source=row_source, + user_id, + want_ids, + row_source=row_source, ) if not dept_ids: return existing_rows = await UserDepartmentDao.aget_memberships_in_depts( - user_id, dept_ids, + user_id, + dept_ids, ) existing_ids = {row.department_id for row in existing_rows} to_add = [d for d in dept_ids if d not in existing_ids] for dept_id in to_add: await UserDepartmentDao.aadd_member( - user_id, dept_id, is_primary=0, source=row_source, + user_id, + dept_id, + is_primary=0, + source=row_source, ) await cls._sync_department_member_tuples(user_id, dept_ids) @@ -731,9 +817,11 @@ class LoginSyncService: # Module-level helper: Redis SETNX-based per-user login lock. # ----------------------------------------------------------------------- + @asynccontextmanager async def _acquire_user_lock( - lock_key: str, ttl: int = 30, + lock_key: str, + ttl: int = 30, ) -> AsyncIterator[bool]: """SETNX + TTL in a single Redis roundtrip (``SET key value NX EX ttl``). @@ -750,12 +838,16 @@ async def _acquire_user_lock( # Atomic SETNX + EX — avoids the two-step (setnx + expire) race # where a crash between the two leaves a TTL-less lock. result = await redis.async_connection.set( - lock_key, b'1', nx=True, ex=ttl, + lock_key, + b"1", + nx=True, + ex=ttl, ) acquired = bool(result) except Exception as e: logger.warning( - 'F014 Redis lock acquire failed (%s); proceeding without lock', e, + "F014 Redis lock acquire failed (%s); proceeding without lock", + e, ) acquired = True redis = None @@ -766,4 +858,4 @@ async def _acquire_user_lock( try: await redis.adelete(lock_key) except Exception as e: # pragma: no cover - logger.warning('F014 Redis lock release failed: %s', e) + logger.warning("F014 Redis lock release failed: %s", e) diff --git a/src/backend/bisheng/worker/information/article.py b/src/backend/bisheng/worker/information/article.py index 8575d6f51..bb6d0b634 100644 --- a/src/backend/bisheng/worker/information/article.py +++ b/src/backend/bisheng/worker/information/article.py @@ -1,19 +1,26 @@ -from bisheng.worker._asyncio_utils import run_async_task +import asyncio from datetime import datetime -from typing import Dict, List, Optional, Tuple from loguru import logger -from sqlmodel import select, func +from sqlmodel import select +from bisheng.core.context.tenant import ( + DEFAULT_TENANT_ID, + current_tenant_id, + set_current_tenant_id, +) from bisheng.core.database.dialect_helpers import json_array_contains +from bisheng.worker._asyncio_utils import run_async_task def _db_dialect() -> str: try: from bisheng.core.database.manager import sync_get_database_connection + return sync_get_database_connection().engine.dialect.name except Exception: - return 'mysql' + return "mysql" + from bisheng.channel.domain.models.channel import Channel from bisheng.channel.domain.models.channel_info_source import ChannelInfoSource @@ -21,8 +28,9 @@ from bisheng.channel.domain.models.channel_knowledge_sync import ( ChannelKnowledgeSync, ChannelKnowledgeSyncDao, ) -from bisheng.channel.domain.repositories.implementations.channel_info_source_repository_impl import \ - ChannelInfoSourceRepositoryImpl +from bisheng.channel.domain.repositories.implementations.channel_info_source_repository_impl import ( + ChannelInfoSourceRepositoryImpl, +) from bisheng.channel.domain.schemas.article_schema import ArticleDocument from bisheng.channel.domain.schemas.channel_manager_schema import ( AddArticlesToKnowledgeSpaceRequest, @@ -30,8 +38,9 @@ from bisheng.channel.domain.schemas.channel_manager_schema import ( from bisheng.channel.domain.services.article_es_service import ArticleEsService from bisheng.channel.domain.services.channel_service import ChannelService from bisheng.core.database import get_sync_db_session -from bisheng.core.external.bisheng_information_client.bisheng_information_manager import \ - get_bisheng_information_client_sync +from bisheng.core.external.bisheng_information_client.bisheng_information_manager import ( + get_bisheng_information_client_sync, +) from bisheng.core.logger import trace_id_var from bisheng.utils import generate_uuid from bisheng.worker.main import bisheng_celery @@ -40,13 +49,46 @@ from bisheng.worker.main import bisheng_celery @bisheng_celery.task def sync_information_article(information_id: str = None): trace_id_var.set(f"sync_all_information_articles_{generate_uuid()}") + # Celery Beat fires this once with no request/tenant context. The + # information-source tables are tenant-aware, so iterate every active tenant + # and run the sync under that tenant's context (mirrors reconcile_all_tenants); + # otherwise the first SELECT on channel_info_source raises NoTenantContextError + # and nothing ever syncs on a multi-tenant deploy. + for tenant_id in _active_tenant_ids_sync(): + token = set_current_tenant_id(tenant_id) + try: + _sync_information_article_for_tenant(information_id) + except Exception: + # Isolate per-tenant failures so one bad tenant never blocks the rest. + logger.exception(f"sync_information_article failed for tenant={tenant_id}") + finally: + current_tenant_id.reset(token) + + +def _active_tenant_ids_sync() -> list[int]: + """Active tenant ids to sync — sync counterpart of reconcile._active_tenant_ids. + + Single-tenant deployments behave as tenant_id=1. + """ + from bisheng.common.services.config_service import settings + + if not settings.multi_tenant.enabled: + return [DEFAULT_TENANT_ID] + + from bisheng.database.models.tenant import ROOT_TENANT_ID, TenantDao + + child_ids = TenantDao.get_children_ids_active() + return [ROOT_TENANT_ID, *child_ids] + + +def _sync_information_article_for_tenant(information_id: str = None): logger.debug(f"Starting to sync information articles for {information_id}.") article_service = ArticleEsService() article_service.ensure_index_sync() need_update_informations = [] # v2.5 Module D: record article ids indexed during THIS worker run so the # knowledge-space sync hook below can push just the fresh ones. - indexed_by_source: Dict[str, List[str]] = {} + indexed_by_source: dict[str, list[str]] = {} with get_sync_db_session() as session: channel_info_repository = ChannelInfoSourceRepositoryImpl(session) page, page_size = 1, 1000 @@ -57,10 +99,12 @@ def sync_information_article(information_id: str = None): for one in information_list: try: logger.debug(f"Syncing information for {one.id} - {one.source_name}") - if (one.update_time.strftime("%Y-%m-%d") == datetime.now().strftime("%Y-%m-%d") - ) and (one.update_time.strftime("%Y-%m-%d %H:%M") != one.create_time.strftime("%Y-%m-%d %H:%M")): + if (one.update_time.strftime("%Y-%m-%d") == datetime.now().strftime("%Y-%m-%d")) and ( + one.update_time.strftime("%Y-%m-%d %H:%M") != one.create_time.strftime("%Y-%m-%d %H:%M") + ): logger.debug( - f"Skip information for {one.id} - {one.source_name}, because it has already been updated today.") + f"Skip information for {one.id} - {one.source_name}, because it has already been updated today." + ) continue need_update_informations.append(one.id) @@ -72,7 +116,6 @@ def sync_information_article(information_id: str = None): except Exception as e: logger.exception(f"Failed to sync information article for source {one.id}: {e}") - page += 1 logger.debug("Finished syncing information articles") @@ -91,7 +134,7 @@ def sync_information_article(information_id: str = None): ) -def _update_channels_by_source_id(source_ids: List[str]): +def _update_channels_by_source_id(source_ids: list[str]): """Update latest_article_update_time for channels that use the specified source_id.""" with get_sync_db_session() as session: dialect = session.bind.dialect.name if session.bind else _db_dialect() @@ -117,31 +160,32 @@ def _sync_one_information_article(information: ChannelInfoSource, article_servic information_client = get_bisheng_information_client_sync() page, page_size, current = 1, 10, 0 - all_new_ids: List[str] = [] + all_new_ids: list[str] = [] while True: - resp = information_client.get_information_articles(information.id, False, - min_create_time=latest_create_time, - page=page, - page_size=page_size) + resp = information_client.get_information_articles( + information.id, False, min_create_time=latest_create_time, page=page, page_size=page_size + ) articles = [] doc_ids = [] for article in resp.articles: - articles.append(ArticleDocument( - source_type=0 if information.source_type == "wechat" else 1, - source_id=information.id, - title=article.title, - content=article.markdown_content, - content_html=article.html_content, - cover_image=article.icon, - publish_time=datetime.fromisoformat(article.publish_date), - source_url=article.original_url, - create_time=datetime.fromisoformat(article.create_time), - update_time=datetime.fromisoformat(article.update_time), - )) + articles.append( + ArticleDocument( + source_type=0 if information.source_type == "wechat" else 1, + source_id=information.id, + title=article.title, + content=article.markdown_content, + content_html=article.html_content, + cover_image=article.icon, + publish_time=datetime.fromisoformat(article.publish_date), + source_url=article.original_url, + create_time=datetime.fromisoformat(article.create_time), + update_time=datetime.fromisoformat(article.update_time), + ) + ) doc_ids.append(article.id) try: article_service.bulk_index_articles_sync(articles, doc_ids) - except Exception as e: + except Exception: # if es timeout or over memory change to one by one for tmp_index, tmp_one in enumerate(articles): article_service.index_article(tmp_one, doc_ids[tmp_index]) @@ -175,10 +219,11 @@ def _sync_one_information_article(information: ChannelInfoSource, article_servic def _find_sub_channel_filter_group( - channel: Channel, sub_channel_name: str, -) -> Optional[Dict]: + channel: Channel, + sub_channel_name: str, +) -> dict | None: """Return the ChannelFilterRules group for a sub-channel, or None.""" - for g in (channel.filter_rules or []): + for g in channel.filter_rules or []: if not isinstance(g, dict): continue if g.get("channel_type") == "sub" and g.get("name") == sub_channel_name: @@ -189,12 +234,12 @@ def _find_sub_channel_filter_group( def _resolve_article_ids_for_config( config: ChannelKnowledgeSync, channel: Channel, - indexed_by_source: Dict[str, List[str]], + indexed_by_source: dict[str, list[str]], article_service: ArticleEsService, -) -> List[str]: +) -> list[str]: """Compute the article ids this config should receive this run.""" channel_sources = [str(s) for s in (channel.source_list or [])] - new_ids: List[str] = [] + new_ids: list[str] = [] for sid in channel_sources: new_ids.extend(indexed_by_source.get(sid, [])) if not new_ids: @@ -219,24 +264,18 @@ def _resolve_article_ids_for_config( filter_rules=[group], ) except Exception as exc: - logger.exception( - f"Filter evaluation failed for config {config.id}: {exc}" - ) + logger.exception(f"Filter evaluation failed for config {config.id}: {exc}") return [] def _drop_dead_target_configs( - configs: List[ChannelKnowledgeSync], -) -> List[ChannelKnowledgeSync]: + configs: list[ChannelKnowledgeSync], +) -> list[ChannelKnowledgeSync]: """Filter out configs whose knowledge_space or folder has been deleted.""" space_ids = { - int(c.knowledge_space_id) for c in configs - if c.knowledge_space_id and str(c.knowledge_space_id).isdigit() - } - folder_ids = { - int(c.folder_id) for c in configs - if c.folder_id and str(c.folder_id).isdigit() + int(c.knowledge_space_id) for c in configs if c.knowledge_space_id and str(c.knowledge_space_id).isdigit() } + folder_ids = {int(c.folder_id) for c in configs if c.folder_id and str(c.folder_id).isdigit()} if not space_ids and not folder_ids: return list(configs) @@ -247,42 +286,32 @@ def _drop_dead_target_configs( existing_folders = set() with get_sync_db_session() as session: if space_ids: - for row in session.exec( - select(Knowledge.id).where(Knowledge.id.in_(space_ids)) - ).all(): + for row in session.exec(select(Knowledge.id).where(Knowledge.id.in_(space_ids))).all(): kid = row[0] if isinstance(row, tuple) else row existing_spaces.add(int(kid)) if folder_ids: - for row in session.exec( - select(KnowledgeFile.id).where(KnowledgeFile.id.in_(folder_ids)) - ).all(): + for row in session.exec(select(KnowledgeFile.id).where(KnowledgeFile.id.in_(folder_ids))).all(): fid = row[0] if isinstance(row, tuple) else row existing_folders.add(int(fid)) - survivors: List[ChannelKnowledgeSync] = [] + survivors: list[ChannelKnowledgeSync] = [] for c in configs: sid = c.knowledge_space_id if sid and str(sid).isdigit() and int(sid) not in existing_spaces: - logger.warning( - f"Sync config {c.id}: knowledge_space {sid} no longer exists; " - f"skipping." - ) + logger.warning(f"Sync config {c.id}: knowledge_space {sid} no longer exists; skipping.") continue fid_val = c.folder_id if fid_val and str(fid_val).isdigit() and int(fid_val) not in existing_folders: - logger.warning( - f"Sync config {c.id}: folder {fid_val} no longer exists; " - f"skipping." - ) + logger.warning(f"Sync config {c.id}: folder {fid_val} no longer exists; skipping.") continue survivors.append(c) return survivors def _sync_new_articles_to_knowledge_spaces( - indexed_by_source: Dict[str, List[str]], + indexed_by_source: dict[str, list[str]], information_id: str = None, - article_service: Optional[ArticleEsService] = None, + article_service: ArticleEsService | None = None, ) -> None: if not indexed_by_source: logger.debug("No freshly-indexed articles; skipping knowledge-space sync hook.") @@ -294,9 +323,7 @@ def _sync_new_articles_to_knowledge_spaces( if information_id: dialect = session.bind.dialect.name if session.bind else _db_dialect() channels = session.exec( - select(Channel).where( - json_array_contains(Channel.source_list, f'"{information_id}"', dialect) - ) + select(Channel).where(json_array_contains(Channel.source_list, f'"{information_id}"', dialect)) ).all() else: channels = session.exec(select(Channel)).all() @@ -320,13 +347,10 @@ def _sync_new_articles_to_knowledge_spaces( return channel_by_id = {c.id: c for c in channels} - logger.info( - f"Knowledge-space sync hook: {len(sync_configs)} enabled configs " - f"across {len(channel_ids)} channels." - ) + logger.info(f"Knowledge-space sync hook: {len(sync_configs)} enabled configs across {len(channel_ids)} channels.") # Build a list of dispatch tasks, skipping configs that have nothing to send. - pending: List[Tuple[ChannelKnowledgeSync, AddArticlesToKnowledgeSpaceRequest]] = [] + pending: list[tuple[ChannelKnowledgeSync, AddArticlesToKnowledgeSpaceRequest]] = [] for config in sync_configs: try: channel = channel_by_id.get(config.channel_id) @@ -334,7 +358,10 @@ def _sync_new_articles_to_knowledge_spaces( continue article_ids = _resolve_article_ids_for_config( - config, channel, indexed_by_source, article_service, + config, + channel, + indexed_by_source, + article_service, ) if not article_ids: continue @@ -343,29 +370,21 @@ def _sync_new_articles_to_knowledge_spaces( kid = int(config.knowledge_space_id) except (TypeError, ValueError): logger.warning( - f"Config {config.id}: non-int knowledge_space_id " - f"{config.knowledge_space_id!r}, skipping." + f"Config {config.id}: non-int knowledge_space_id {config.knowledge_space_id!r}, skipping." ) continue req = AddArticlesToKnowledgeSpaceRequest( knowledge_id=kid, article_ids=article_ids, - parent_id=( - int(config.folder_id) - if config.folder_id and str(config.folder_id).isdigit() - else None - ), + parent_id=(int(config.folder_id) if config.folder_id and str(config.folder_id).isdigit() else None), # Background sync is best-effort: missing ES docs and duplicate # file names in the target space must not abort the batch. skip_missing_and_duplicates=True, ) pending.append((config, req)) except Exception as exc: - logger.exception( - f"Failed to prepare sync for config {config.id} " - f"(channel {config.channel_id}): {exc}" - ) + logger.exception(f"Failed to prepare sync for config {config.id} (channel {config.channel_id}): {exc}") if not pending: return @@ -380,7 +399,7 @@ def _sync_new_articles_to_knowledge_spaces( async def _dispatch_all( - pending: List[Tuple[ChannelKnowledgeSync, AddArticlesToKnowledgeSpaceRequest]], + pending: list[tuple[ChannelKnowledgeSync, AddArticlesToKnowledgeSpaceRequest]], ) -> None: async def _one(config: ChannelKnowledgeSync, req: AddArticlesToKnowledgeSpaceRequest): try: @@ -392,16 +411,14 @@ async def _dispatch_all( f"(config {config.id})" ) except Exception as exc: - logger.exception( - f"Failed to sync config {config.id} " - f"(channel {config.channel_id}): {exc}" - ) + logger.exception(f"Failed to sync config {config.id} (channel {config.channel_id}): {exc}") await asyncio.gather(*(_one(c, r) for c, r in pending), return_exceptions=True) async def _async_add_articles_to_knowledge( - req: AddArticlesToKnowledgeSpaceRequest, user_id: int, + req: AddArticlesToKnowledgeSpaceRequest, + user_id: int, ): """Run add_articles_to_knowledge_space in an async context. diff --git a/src/backend/bisheng/workstation/domain/schemas/workstation_schema.py b/src/backend/bisheng/workstation/domain/schemas/workstation_schema.py index 0dd0f81f3..149ef629f 100644 --- a/src/backend/bisheng/workstation/domain/schemas/workstation_schema.py +++ b/src/backend/bisheng/workstation/domain/schemas/workstation_schema.py @@ -31,6 +31,10 @@ class WorkstationMessage(BaseModel): # to the execution detail for lazy-loading. Both absent on normal turns. category: str | None = None linsightSessionVersionId: str | None = None + # like/dislike echo: 0 none / 1 up / 2 down, plus the dislike reason. The + # frontend re-highlights the rated state on reload. + liked: int | None = None + remark: str | None = None @field_validator("messageId", mode="before") @classmethod @@ -70,6 +74,8 @@ class WorkstationMessage(BaseModel): source=message.source, category=message.category, linsightSessionVersionId=extra.get("linsight_session_version_id"), + liked=message.liked, + remark=message.remark, ) diff --git a/src/backend/bisheng/workstation/domain/services/chat_helpers.py b/src/backend/bisheng/workstation/domain/services/chat_helpers.py index ddbeb8e6f..36b74a1ea 100644 --- a/src/backend/bisheng/workstation/domain/services/chat_helpers.py +++ b/src/backend/bisheng/workstation/domain/services/chat_helpers.py @@ -219,6 +219,11 @@ def _message_base_fields(msg: ChatMessage) -> dict: "flow_id": msg.flow_id, "source": msg.source, "sender": msg.sender, + # like/dislike echo: 0 none / 1 up / 2 down, plus the dislike reason. The + # frontend re-highlights the rated state on reload. Daily answers and task + # results are both ChatMessage rows, so they share this one path. + "liked": msg.liked, + "remark": msg.remark, "create_time": msg.create_time.isoformat() if msg.create_time else None, } diff --git a/src/backend/bisheng/workstation/domain/services/chat_service.py b/src/backend/bisheng/workstation/domain/services/chat_service.py index 9fab44534..bb45aefcc 100644 --- a/src/backend/bisheng/workstation/domain/services/chat_service.py +++ b/src/backend/bisheng/workstation/domain/services/chat_service.py @@ -536,6 +536,18 @@ def _build_tool_meta(tool: BaseTool) -> dict: } +def _is_nested_tool_event(ev: dict, visible_tool_run_ids: set[str]) -> bool: + """Return True when a tool callback belongs to a visible parent tool. + + DailyChatCitationToolWrapper is itself a BaseTool and invokes the wrapped + tool internally. LangChain emits callbacks for both layers with the same + name/input and different run_ids; only the outer tool call is part of the + user-facing ReAct step. + """ + parent_ids = ev.get("parent_ids") or [] + return any(str(parent_id) in visible_tool_run_ids for parent_id in parent_ids) + + async def _build_web_search_tool(user_id: int, tool_id: int | None = None) -> tuple[BaseTool | None, str | None]: """Return (tool, error_msg). A non-None error_msg means the agent should surface the failure to the user (e.g. missing provider config) rather @@ -1443,7 +1455,8 @@ async def _agent_stream_chat_completion( ) tool_meta_map = {t.name: _build_tool_meta(t) for t in langchain_tools} - inflight: dict[str, dict] = {} + visible_tool_run_ids: set[str] = set() + ignored_tool_run_ids: set[str] = set() max_iter = await _get_agent_max_iterations() async for ev in agent.astream_events( @@ -1501,6 +1514,11 @@ async def _agent_stream_chat_completion( ) elif et == "on_tool_start": + tc_id = str(ev.get("run_id") or f"call_{uuid4().hex[:12]}") + if _is_nested_tool_event(ev, visible_tool_run_ids): + ignored_tool_run_ids.add(tc_id) + continue + # Close any in-flight thinking before the tool call # so each ReAct round gets its own collapsible block. d = close_thinking() @@ -1513,7 +1531,6 @@ async def _agent_stream_chat_completion( ) tool_name = name - tc_id = str(ev.get("run_id") or f"call_{uuid4().hex[:12]}") meta = tool_meta_map.get( tool_name, { @@ -1532,6 +1549,7 @@ async def _agent_stream_chat_completion( } events.append(tool_event) inflight_tool_idx[tc_id] = len(events) - 1 + visible_tool_run_ids.add(tc_id) # SSE payload uses the bare tool_call shape (no type:). yield _sse_resp( "agent_tool_call", @@ -1542,7 +1560,13 @@ async def _agent_stream_chat_completion( elif et == "on_tool_end": tc_id = str(ev.get("run_id") or "") + if tc_id in ignored_tool_run_ids: + ignored_tool_run_ids.discard(tc_id) + continue + if _is_nested_tool_event(ev, visible_tool_run_ids) and tc_id not in inflight_tool_idx: + continue idx = inflight_tool_idx.pop(tc_id, None) + visible_tool_run_ids.discard(tc_id) raw_output = (ev.get("data") or {}).get("output") ended_ms = int(time.time() * 1000) if idx is not None: @@ -1573,6 +1597,43 @@ async def _agent_stream_chat_completion( events.append(tool_event) payload = {k: v for k, v in tool_event.items() if k != "type"} yield _sse_resp("agent_tool_call", "end", payload, conversation_id) + elif et == "on_tool_error": + tc_id = str(ev.get("run_id") or "") + if tc_id in ignored_tool_run_ids: + ignored_tool_run_ids.discard(tc_id) + continue + if _is_nested_tool_event(ev, visible_tool_run_ids) and tc_id not in inflight_tool_idx: + continue + idx = inflight_tool_idx.pop(tc_id, None) + visible_tool_run_ids.discard(tc_id) + ended_ms = int(time.time() * 1000) + err = (ev.get("data") or {}).get("error") + err_msg = str(err) if err is not None else "tool execution failed" + if idx is not None: + tool_event = events[idx] + tool_event["results"] = [] + tool_event["error"] = err_msg + tool_event["ended_at"] = ended_ms + if tool_event.get("started_at") is not None: + tool_event["duration_ms"] = max(0, ended_ms - tool_event["started_at"]) + payload = {k: v for k, v in tool_event.items() if k != "type"} + else: + tool_event = { + "type": "tool_call", + "tool_call_id": tc_id, + "tool_name": name, + "display_name": name, + "tool_type": "tool", + "args": (ev.get("data") or {}).get("input", {}), + "results": [], + "error": err_msg, + "started_at": ended_ms, + "ended_at": ended_ms, + "duration_ms": 0, + } + events.append(tool_event) + payload = {k: v for k, v in tool_event.items() if k != "type"} + yield _sse_resp("agent_tool_call", "end", payload, conversation_id) # other events (on_chain_start/on_chain_end/etc.) ignored else: # No tools → direct streaming still in new SSE format @@ -1645,7 +1706,7 @@ async def _agent_stream_chat_completion( # render as perpetually "in-flight" on history reload. if inflight_tool_idx: now_ms = int(time.time() * 1000) - for tc_id, idx in inflight_tool_idx.items(): + for _tc_id, idx in inflight_tool_idx.items(): if 0 <= idx < len(events): ev = events[idx] if ev.get("ended_at") is None: diff --git a/src/backend/test/channel/test_sync_information_article_tenant.py b/src/backend/test/channel/test_sync_information_article_tenant.py new file mode 100644 index 000000000..6324c64b3 --- /dev/null +++ b/src/backend/test/channel/test_sync_information_article_tenant.py @@ -0,0 +1,78 @@ +"""Regression: sync_information_article must run under per-tenant context. + +The information-source tables (channel_info_source / channel / ...) are +tenant-aware. Celery Beat fires the task with no request context, so the task +body must iterate every active tenant and set its context before querying — +otherwise the first SELECT raises NoTenantContextError and nothing syncs on a +multi-tenant deploy. Mirrors test_information_reconcile_worker. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import bisheng.worker.information.article as article_mod + + +def test_sync_iterates_each_active_tenant_under_context(): + """Each active tenant's context is set and the per-tenant sync runs once.""" + tenant_seen: list[int] = [] + synced_under: list[int] = [] + + with ( + patch.object(article_mod, "_active_tenant_ids_sync", return_value=[1, 2, 3]), + patch.object(article_mod, "set_current_tenant_id", side_effect=lambda t: tenant_seen.append(t) or f"tok-{t}"), + patch.object(article_mod, "current_tenant_id", MagicMock()), + patch.object( + article_mod, + "_sync_information_article_for_tenant", + side_effect=lambda info: synced_under.append(tenant_seen[-1]), + ), + ): + article_mod.sync_information_article.run() + + assert tenant_seen == [1, 2, 3] + assert synced_under == [1, 2, 3] + + +def test_sync_isolates_per_tenant_failure_and_resets_context(): + """One tenant failing does not stop the rest; context is reset every time.""" + tenant_seen: list[int] = [] + reset_tokens: list = [] + + ctx_mock = MagicMock() + ctx_mock.reset.side_effect = lambda tok: reset_tokens.append(tok) + + def _body(info): + if tenant_seen[-1] == 2: + raise RuntimeError("boom") + + with ( + patch.object(article_mod, "_active_tenant_ids_sync", return_value=[1, 2, 3]), + patch.object(article_mod, "set_current_tenant_id", side_effect=lambda t: tenant_seen.append(t) or f"tok-{t}"), + patch.object(article_mod, "current_tenant_id", ctx_mock), + patch.object(article_mod, "_sync_information_article_for_tenant", side_effect=_body), + ): + article_mod.sync_information_article.run() + + assert tenant_seen == [1, 2, 3] + # finally-block reset fires once per tenant, even for the one that raised. + assert reset_tokens == ["tok-1", "tok-2", "tok-3"] + + +def test_active_tenant_ids_single_tenant_fallback(): + """Single-tenant deploy syncs only the default tenant.""" + fake_settings = SimpleNamespace(multi_tenant=SimpleNamespace(enabled=False)) + with patch("bisheng.common.services.config_service.settings", fake_settings): + assert article_mod._active_tenant_ids_sync() == [article_mod.DEFAULT_TENANT_ID] + + +def test_active_tenant_ids_multi_tenant_includes_root_and_children(): + """Multi-tenant deploy syncs root + every active child tenant.""" + fake_settings = SimpleNamespace(multi_tenant=SimpleNamespace(enabled=True)) + with ( + patch("bisheng.common.services.config_service.settings", fake_settings), + patch("bisheng.database.models.tenant.TenantDao.get_children_ids_active", return_value=[2, 3]), + ): + assert article_mod._active_tenant_ids_sync() == [1, 2, 3] diff --git a/src/backend/test/llm/test_llm_service_config.py b/src/backend/test/llm/test_llm_service_config.py new file mode 100644 index 000000000..d8665fc95 --- /dev/null +++ b/src/backend/test/llm/test_llm_service_config.py @@ -0,0 +1,40 @@ +import unittest + +from bisheng.llm.domain.services.llm import LLMService + + +class TestStripConfigWhitespace(unittest.TestCase): + def test_api_key_trailing_whitespace_stripped(self): + config = {'openai_api_key': 'sk-xxx ', 'api_key': ' sk-yyy\n'} + result = LLMService.strip_config_whitespace(config) + self.assertEqual(result['openai_api_key'], 'sk-xxx') + self.assertEqual(result['api_key'], 'sk-yyy') + + def test_url_and_endpoint_fields_stripped(self): + config = { + 'openai_api_base': ' https://api.openai.com/v1 ', + 'base_url': 'https://example.com/v1\t', + 'azure_endpoint': ' https://xx.openai.azure.com', + 'openai_proxy': ' http://127.0.0.1:7890 ', + } + result = LLMService.strip_config_whitespace(config) + self.assertEqual(result['openai_api_base'], 'https://api.openai.com/v1') + self.assertEqual(result['base_url'], 'https://example.com/v1') + self.assertEqual(result['azure_endpoint'], 'https://xx.openai.azure.com') + self.assertEqual(result['openai_proxy'], 'http://127.0.0.1:7890') + + def test_nested_config_stripped(self): + config = {'inner': {'api_key': 'sk-xxx '}} + result = LLMService.strip_config_whitespace(config) + self.assertEqual(result['inner']['api_key'], 'sk-xxx') + + def test_other_fields_untouched(self): + config = {'description': ' keep me ', 'streaming': True, 'max_tokens': 4096, 'voice': None} + result = LLMService.strip_config_whitespace(config) + self.assertEqual(result['description'], ' keep me ') + self.assertEqual(result['streaming'], True) + self.assertEqual(result['max_tokens'], 4096) + self.assertIsNone(result['voice']) + + def test_none_config(self): + self.assertIsNone(LLMService.strip_config_whitespace(None)) diff --git a/src/backend/test/llm/test_llm_share_fallback.py b/src/backend/test/llm/test_llm_share_fallback.py index 77fe386d5..b1b476485 100644 --- a/src/backend/test/llm/test_llm_share_fallback.py +++ b/src/backend/test/llm/test_llm_share_fallback.py @@ -14,6 +14,7 @@ The helpers must: so callers see a deterministic "deleted" error rather than a leaked cross-tenant row """ +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -354,6 +355,46 @@ async def test_validate_accepts_string_model_ids_from_ws_model(): await avalidate_system_model_refs(['7', None, ''], target_tenant_id=ROOT_TENANT_ID) +@pytest.mark.asyncio +async def test_workbench_getter_sanitizes_inherited_stale_model_refs(): + """Inherited Root workbench config must not echo deleted or Child-owned + model ids back to the UI; otherwise the next POST repeats the stale + payload and fails write-side validation.""" + from bisheng.llm.domain.schemas import WSModel + from bisheng.llm.domain.services.llm import LLMService + + payload = { + "models": [ + WSModel(id="10", name="root-model").model_dump(), + WSModel(id="20", name="child-model").model_dump(), + WSModel(id="30", name="deleted-model").model_dump(), + ], + "linsight_default_model_id": "20", + "embedding_model": WSModel(id="10", name="root-embedding").model_dump(), + "asr_model": WSModel(id="30", name="deleted-asr").model_dump(), + "tts_model": WSModel(id="20", name="child-tts").model_dump(), + } + root_row = MagicMock(id=10, tenant_id=ROOT_TENANT_ID) + child_row = MagicMock(id=20, tenant_id=5) + + with patch( + 'bisheng.llm.domain.services.llm.TenantSystemModelConfigDao.aresolve', + new=AsyncMock(return_value=(json.dumps(payload), True, False)), + ), patch( + 'bisheng.llm.domain.services.llm.LLMDao.aget_model_by_ids', + new=AsyncMock(return_value=[root_row, child_row]), + ): + config, inherited, blocked = await LLMService.aget_workbench_llm_with_meta(tenant_id=5) + + assert inherited is True + assert blocked is False + assert [one.id for one in (config.models or [])] == ["10"] + assert config.linsight_default_model_id is None + assert config.embedding_model and config.embedding_model.id == "10" + assert config.asr_model is None + assert config.tts_model is None + + # --- LLMService.update_*_llm setter integration ----------------------------- diff --git a/src/backend/test/permission/test_permission_api_integration.py b/src/backend/test/permission/test_permission_api_integration.py index f57650340..9c1fd82dc 100644 --- a/src/backend/test/permission/test_permission_api_integration.py +++ b/src/backend/test/permission/test_permission_api_integration.py @@ -319,7 +319,9 @@ class TestPermissionApiIntegration: assert body["status_code"] == 19000 mock_authorize.assert_not_awaited() - def test_authorize_api_blocks_self_owner_revoke_when_it_is_the_last_owner(self): + def test_authorize_api_blocks_self_owner_revoke(self): + """Self-modification backstop: you cannot revoke your OWN owner (would lock + you out of the dialog), regardless of how many owners remain.""" app = _make_app(_ViewerUser) with ( @@ -387,12 +389,15 @@ class TestPermissionApiIntegration: ) body = resp.json() - # Owner/creator decoupled: removing the last owner is now refused with the - # dedicated PermissionLastOwnerError (19007) instead of a generic denial. - assert body["status_code"] == 19007 + # Self owner revoke is blocked by the self-modification guard (19000), + # which runs before the last-owner check. + assert body["status_code"] == 19000 mock_authorize.assert_not_awaited() - def test_authorize_api_allows_self_owner_revoke_when_another_owner_remains(self): + def test_authorize_api_allows_owner_revoke_of_another_user_when_another_owner_remains(self): + """Revoking ANOTHER user's owner is allowed while an owner remains (caller + is _ViewerUser=7; we revoke user 9, leaving 7). Self-revoke is covered by + test_authorize_api_blocks_self_owner_revoke.""" app = _make_app(_ViewerUser) with ( @@ -462,7 +467,7 @@ class TestPermissionApiIntegration: "revokes": [ { "subject_type": "user", - "subject_id": 7, + "subject_id": 9, "relation": "owner", } ], @@ -816,6 +821,27 @@ class TestPermissionApiIntegration: assert body["status_code"] == 200 mock_authorize.assert_awaited_once() + def test_authorize_blocks_modifying_own_permission(self): + """A user cannot change their OWN permission via the member dialog — a + self-downgrade (owner→editor) would strip management access and lock them + out. Backstop for every resource type; managing others is unaffected.""" + app = _make_app(_AdminUser) # user_id = 1 + with patch( + "bisheng.permission.domain.services.permission_service.PermissionService.authorize", + new_callable=AsyncMock, + ) as mock_authorize: + with TestClient(app) as client: + resp = client.post( + "/api/v1/permissions/resources/workflow/wf-1/authorize", + json={ + "grants": [{"subject_type": "user", "subject_id": 1, "relation": "editor"}], + "revokes": [{"subject_type": "user", "subject_id": 1, "relation": "owner"}], + }, + ) + body = resp.json() + assert body["status_code"] == 19000 + mock_authorize.assert_not_awaited() + def test_permissions_list_requires_can_edit_on_resource(self): app = _make_app(_ViewerUser) diff --git a/src/backend/test/sso/test_sso_login_sync_service.py b/src/backend/test/sso/test_sso_login_sync_service.py index 843eec144..03ec35841 100644 --- a/src/backend/test/sso/test_sso_login_sync_service.py +++ b/src/backend/test/sso/test_sso_login_sync_service.py @@ -14,12 +14,12 @@ and makes the ordering contract explicit via ``assert_*_called`` checks. from __future__ import annotations +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest - # ========================================================================= # Helpers # ========================================================================= @@ -28,15 +28,21 @@ _PAYLOAD_OMIT_SECONDARY = object() def _payload( - external_user_id='u1', primary='D1', + external_user_id="u1", + primary="D1", secondary=_PAYLOAD_OMIT_SECONDARY, - ts=1000, name='Alice', email='a@x.com', phone=None, + ts=1000, + name="Alice", + email="a@x.com", + phone=None, tenant_mapping=None, account_disabled=None, ): from bisheng.sso_sync.domain.schemas.payloads import ( - LoginSyncRequest, UserAttrsDTO, + LoginSyncRequest, + UserAttrsDTO, ) + kw = dict( external_user_id=external_user_id, primary_dept_external_id=primary, @@ -46,23 +52,30 @@ def _payload( account_disabled=account_disabled, ) if secondary is not _PAYLOAD_OMIT_SECONDARY: - kw['secondary_dept_external_ids'] = secondary + kw["secondary_dept_external_ids"] = secondary return LoginSyncRequest(**kw) -def _user(user_id=7, delete=0, source='sso', external_id='u1', - user_name='Alice', email='a@x.com', phone_number=None): +def _user(user_id=7, delete=0, source="sso", external_id="u1", user_name="Alice", email="a@x.com", phone_number=None): return SimpleNamespace( - user_id=user_id, delete=delete, source=source, - external_id=external_id, user_name=user_name, email=email, + user_id=user_id, + delete=delete, + source=source, + external_id=external_id, + user_name=user_name, + email=email, phone_number=phone_number, ) -def _dept(ext, *, id=1, path='/', is_deleted=0, is_tenant_root=0, - mounted_tenant_id=None, source='sso', status='active'): +def _dept( + ext, *, id=1, path="/", is_deleted=0, is_tenant_root=0, mounted_tenant_id=None, source="sso", status="active" +): return SimpleNamespace( - id=id, external_id=ext, path=path, is_deleted=is_deleted, + id=id, + external_id=ext, + path=path, + is_deleted=is_deleted, is_tenant_root=is_tenant_root, mounted_tenant_id=mounted_tenant_id, source=source, @@ -70,7 +83,7 @@ def _dept(ext, *, id=1, path='/', is_deleted=0, is_tenant_root=0, ) -def _tenant(tid=1, status='active'): +def _tenant(tid=1, status="active"): return SimpleNamespace(id=tid, status=status) @@ -87,90 +100,127 @@ def patches(monkeypatch): redis_mock.async_connection = MagicMock() redis_mock.async_connection.set = AsyncMock(return_value=True) redis_mock.adelete = AsyncMock(return_value=None) - monkeypatch.setattr(m, 'get_redis_client', AsyncMock(return_value=redis_mock)) + monkeypatch.setattr(m, "get_redis_client", AsyncMock(return_value=redis_mock)) # Parent chain — default: identity mapping of the primary ext. - assert_chain = AsyncMock(name='assert_parent_chain_exists') + assert_chain = AsyncMock(name="assert_parent_chain_exists") monkeypatch.setattr( - m.DeptUpsertService, 'assert_parent_chain_exists', assert_chain, + m.DeptUpsertService, + "assert_parent_chain_exists", + assert_chain, ) # tenant_mapping — default: noop - tmh_process = AsyncMock(name='TenantMappingHandler.process') - monkeypatch.setattr(m.TenantMappingHandler, 'process', tmh_process) + tmh_process = AsyncMock(name="TenantMappingHandler.process") + monkeypatch.setattr(m.TenantMappingHandler, "process", tmh_process) # User DAO — no existing user by default. monkeypatch.setattr( - m.UserDao, 'aget_by_source_external_id', + m.UserDao, + "aget_by_source_external_id", AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDao, 'aget_by_external_id', + m.UserDao, + "aget_by_external_id", AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDao, 'add_user_and_default_role', - AsyncMock(side_effect=lambda u: _user(user_id=7, user_name=u.user_name, - email=u.email, source='sso', - external_id=u.external_id)), + m.UserDao, + "add_user_and_default_role", + AsyncMock( + side_effect=lambda u: _user( + user_id=7, user_name=u.user_name, email=u.email, source="sso", external_id=u.external_id + ) + ), ) monkeypatch.setattr( - m.UserDao, 'aupdate_user', AsyncMock(return_value=None), + m.UserDao, + "aupdate_user", + AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDao, 'aget_token_version', AsyncMock(return_value=0), + m.UserDao, + "aget_token_version", + AsyncMock(return_value=0), ) monkeypatch.setattr( - m.DepartmentDao, 'aget_by_ids', AsyncMock(return_value=[]), + m.DepartmentDao, + "aget_by_ids", + AsyncMock(return_value=[]), + ) + # Guest department lookup — default: not seeded in the mock world, so the + # guest-department fallback no-ops. Guest-fallback tests override this. + monkeypatch.setattr( + m.DepartmentDao, + "aget_by_dept_id", + AsyncMock(return_value=None), ) # UserDepartment DAO (post-simplify: helpers live in the DAO now) monkeypatch.setattr( - m.UserDepartmentDao, 'aget_user_primary_department', + m.UserDepartmentDao, + "aget_user_primary_department", AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDepartmentDao, 'aadd_member', AsyncMock(return_value=None), + m.UserDepartmentDao, + "aadd_member", + AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDepartmentDao, 'aget_membership', AsyncMock(return_value=None), + m.UserDepartmentDao, + "aget_membership", + AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDepartmentDao, 'aset_primary_flag', AsyncMock(return_value=None), + m.UserDepartmentDao, + "aset_primary_flag", + AsyncMock(return_value=None), ) monkeypatch.setattr( - m.UserDepartmentDao, 'aget_memberships_in_depts', + m.UserDepartmentDao, + "aget_memberships_in_depts", AsyncMock(return_value=[]), ) monkeypatch.setattr( - m.UserDepartmentDao, 'aget_user_departments', + m.UserDepartmentDao, + "aget_user_departments", AsyncMock(return_value=[]), ) monkeypatch.setattr( - m.UserDepartmentDao, 'aremove_member', AsyncMock(return_value=None), + m.UserDepartmentDao, + "aremove_member", + AsyncMock(return_value=None), ) monkeypatch.setattr( - m.DepartmentAdminGrantDao, 'adelete', AsyncMock(return_value=None), + m.DepartmentAdminGrantDao, + "adelete", + AsyncMock(return_value=None), ) from bisheng.department.domain.services import department_change_handler as dch + dept_execute = AsyncMock() - monkeypatch.setattr(dch.DepartmentChangeHandler, 'execute_async', dept_execute) + monkeypatch.setattr(dch.DepartmentChangeHandler, "execute_async", dept_execute) # AuditLog for cross-source migration monkeypatch.setattr( - m.AuditLogDao, 'ainsert_v2', AsyncMock(return_value=None), + m.AuditLogDao, + "ainsert_v2", + AsyncMock(return_value=None), ) # sync_user — returns an active leaf tenant by default. - sync_user = AsyncMock(return_value=_tenant(tid=15, status='active')) - monkeypatch.setattr(m.UserTenantSyncService, 'sync_user', sync_user) + sync_user = AsyncMock(return_value=_tenant(tid=15, status="active")) + monkeypatch.setattr(m.UserTenantSyncService, "sync_user", sync_user) # JWT signer - monkeypatch.setattr(m, 'AuthJwt', MagicMock(return_value=MagicMock())) + monkeypatch.setattr(m, "AuthJwt", MagicMock(return_value=MagicMock())) monkeypatch.setattr( - m.LoginUser, 'create_access_token', - MagicMock(return_value='jwt-token-xyz'), + m.LoginUser, + "create_access_token", + MagicMock(return_value="jwt-token-xyz"), ) return SimpleNamespace( @@ -187,67 +237,68 @@ def patches(monkeypatch): # T8 core flow # ========================================================================= + @pytest.mark.asyncio class TestNewUserHappyPath: - async def test_returns_user_leaf_tenant_and_token(self, patches): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} - resp = await LoginSyncService.execute(_payload(), request_ip='1.2.3.4') + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} + + resp = await LoginSyncService.execute(_payload(), request_ip="1.2.3.4") assert resp.user_id == 7 assert resp.leaf_tenant_id == 15 - assert resp.token == 'jwt-token-xyz' + assert resp.token == "jwt-token-xyz" async def test_sync_user_called_with_login_trigger(self, patches): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) from bisheng.tenant.domain.constants import UserTenantSyncTrigger - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} - await LoginSyncService.execute(_payload(), request_ip='1.2.3.4') + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} + + await LoginSyncService.execute(_payload(), request_ip="1.2.3.4") call_args = patches.sync_user.await_args # sync_user(user_id, trigger=LOGIN) assert call_args.args[0] == 7 - assert call_args.kwargs['trigger'] == UserTenantSyncTrigger.LOGIN + assert call_args.kwargs["trigger"] == UserTenantSyncTrigger.LOGIN async def test_repairs_department_member_tuples_for_primary_and_secondary( - self, patches, + self, + patches, ): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) - primary = _dept('D1', id=11) - secondary = _dept('D2', id=12) - patches.assert_chain.return_value = {'D1': primary, 'D2': secondary} - await LoginSyncService.execute(_payload(secondary=['D2']), request_ip='') + primary = _dept("D1", id=11) + secondary = _dept("D2", id=12) + patches.assert_chain.return_value = {"D1": primary, "D2": secondary} - ops = [ - op - for call in patches.dept_execute.await_args_list - for op in call.args[0] - ] - assert ('write', 'user:7', 'member', 'department:11') in [ + await LoginSyncService.execute(_payload(secondary=["D2"]), request_ip="") + + ops = [op for call in patches.dept_execute.await_args_list for op in call.args[0]] + assert ("write", "user:7", "member", "department:11") in [ (op.action, op.user, op.relation, op.object) for op in ops ] - assert ('write', 'user:7', 'member', 'department:12') in [ + assert ("write", "user:7", "member", "department:12") in [ (op.action, op.user, op.relation, op.object) for op in ops ] @pytest.mark.asyncio class TestSecondaryDeptReconcileRemove: - async def test_explicit_empty_secondaries_removes_sso_secondary_rows( - self, patches, monkeypatch, + self, + patches, + monkeypatch, ): from bisheng.sso_sync.domain.schemas.payloads import ( LoginSyncRequest, @@ -256,37 +307,40 @@ class TestSecondaryDeptReconcileRemove: from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module ud_rem = AsyncMock() - monkeypatch.setattr(m.UserDepartmentDao, 'aremove_member', ud_rem) + monkeypatch.setattr(m.UserDepartmentDao, "aremove_member", ud_rem) ag_del = AsyncMock() - monkeypatch.setattr(m.DepartmentAdminGrantDao, 'adelete', ag_del) + monkeypatch.setattr(m.DepartmentAdminGrantDao, "adelete", ag_del) m.UserDepartmentDao.aget_user_departments = AsyncMock( return_value=[ SimpleNamespace(department_id=12, is_primary=0), ], ) m.DepartmentDao.aget_by_ids = AsyncMock( - return_value=[_dept('DX', id=12)], + return_value=[_dept("DX", id=12)], ) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} payload = LoginSyncRequest( - external_user_id='u1', - primary_dept_external_id='D1', + external_user_id="u1", + primary_dept_external_id="D1", secondary_dept_external_ids=[], - user_attrs=UserAttrsDTO(name='Alice', email='a@x.com'), + user_attrs=UserAttrsDTO(name="Alice", email="a@x.com"), ts=1000, ) - await LoginSyncService.execute(payload, request_ip='') + await LoginSyncService.execute(payload, request_ip="") ud_rem.assert_awaited_once_with(7, 12) ag_del.assert_any_await(7, 12) async def test_local_source_secondary_not_removed_on_reconcile( - self, patches, monkeypatch, + self, + patches, + monkeypatch, ): from bisheng.sso_sync.domain.schemas.payloads import ( LoginSyncRequest, @@ -295,29 +349,30 @@ class TestSecondaryDeptReconcileRemove: from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module ud_rem = AsyncMock() - monkeypatch.setattr(m.UserDepartmentDao, 'aremove_member', ud_rem) + monkeypatch.setattr(m.UserDepartmentDao, "aremove_member", ud_rem) m.UserDepartmentDao.aget_user_departments = AsyncMock( return_value=[ SimpleNamespace(department_id=12, is_primary=0), ], ) m.DepartmentDao.aget_by_ids = AsyncMock( - return_value=[_dept('LOC', id=12, source='local')], + return_value=[_dept("LOC", id=12, source="local")], ) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} payload = LoginSyncRequest( - external_user_id='u1', - primary_dept_external_id='D1', + external_user_id="u1", + primary_dept_external_id="D1", secondary_dept_external_ids=[], - user_attrs=UserAttrsDTO(name='Alice', email='a@x.com'), + user_attrs=UserAttrsDTO(name="Alice", email="a@x.com"), ts=1000, ) - await LoginSyncService.execute(payload, request_ip='') + await LoginSyncService.execute(payload, request_ip="") ud_rem.assert_not_awaited() @@ -326,29 +381,30 @@ class TestSecondaryDeptReconcileRemove: from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) - m = patches.module - aget_ud = AsyncMock(side_effect=AssertionError('should not load memberships')) - monkeypatch.setattr(m.UserDepartmentDao, 'aget_user_departments', aget_ud) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} - await LoginSyncService.execute(_payload(), request_ip='') + m = patches.module + aget_ud = AsyncMock(side_effect=AssertionError("should not load memberships")) + monkeypatch.setattr(m.UserDepartmentDao, "aget_user_departments", aget_ud) + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} + + await LoginSyncService.execute(_payload(), request_ip="") aget_ud.assert_not_awaited() @pytest.mark.asyncio class TestPrimaryDeptMissingFallback: - async def test_empty_primary_skips_parent_chain(self, patches): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + # Root tenant returned by sync_user in the fallback case. - patches.sync_user.return_value = _tenant(tid=1, status='active') + patches.sync_user.return_value = _tenant(tid=1, status="active") payload = _payload(primary=None) - resp = await LoginSyncService.execute(payload, request_ip='1.2.3.4') + resp = await LoginSyncService.execute(payload, request_ip="1.2.3.4") patches.assert_chain.assert_not_awaited() assert resp.leaf_tenant_id == 1 @@ -356,7 +412,6 @@ class TestPrimaryDeptMissingFallback: @pytest.mark.asyncio class TestParentChainMissing: - async def test_missing_parent_bubbles_19312(self, patches): from bisheng.common.errcode.sso_sync import SsoDeptParentMissingError from bisheng.sso_sync.domain.services.login_sync_service import ( @@ -364,70 +419,69 @@ class TestParentChainMissing: ) # Simulate DeptUpsertService raising - patches.assert_chain.side_effect = ( - SsoDeptParentMissingError.http_exception('missing') - ) + patches.assert_chain.side_effect = SsoDeptParentMissingError.http_exception("missing") with pytest.raises(Exception) as exc_info: - await LoginSyncService.execute(_payload(), request_ip='1.2.3.4') - assert '19312' in str(exc_info.value) or getattr( - exc_info.value, 'status_code', 0 - ) == SsoDeptParentMissingError.Code + await LoginSyncService.execute(_payload(), request_ip="1.2.3.4") + assert ( + "19312" in str(exc_info.value) + or getattr(exc_info.value, "status_code", 0) == SsoDeptParentMissingError.Code + ) @pytest.mark.asyncio class TestUserLockBusy: - async def test_setnx_false_returns_19311(self, patches): from bisheng.common.errcode.sso_sync import SsoUserLockBusyError from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + patches.redis.async_connection.set = AsyncMock(return_value=None) with pytest.raises(Exception) as exc_info: - await LoginSyncService.execute(_payload(), request_ip='1.2.3.4') + await LoginSyncService.execute(_payload(), request_ip="1.2.3.4") - assert getattr(exc_info.value, 'status_code', 0) == \ - SsoUserLockBusyError.Code + assert getattr(exc_info.value, "status_code", 0) == SsoUserLockBusyError.Code # sync_user must NOT have been called — flow aborted before it. patches.sync_user.assert_not_awaited() @pytest.mark.asyncio class TestDisabledSsoUser: - async def test_existing_disabled_user_raises_forbidden(self, patches): from bisheng.common.errcode.user import UserForbiddenError from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module # Existing same-source user with delete=1 m.UserDao.aget_by_source_external_id = AsyncMock( return_value=_user(user_id=7, delete=1), ) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} with pytest.raises(Exception) as exc_info: - await LoginSyncService.execute(_payload(), request_ip='1.2.3.4') - assert getattr(exc_info.value, 'status_code', 0) == \ - UserForbiddenError.Code + await LoginSyncService.execute(_payload(), request_ip="1.2.3.4") + assert getattr(exc_info.value, "status_code", 0) == UserForbiddenError.Code async def test_wecom_explicit_re_enable_clears_delete(self, patches): """Gateway sends account_disabled=false after 企微 re-enable; bisheng must flip delete.""" from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module - row = _user(user_id=7, delete=1, source='wecom', external_id='u1') - row.disable_source = 'wecom_org_sync' + row = _user(user_id=7, delete=1, source="wecom", external_id="u1") + row.disable_source = "wecom_org_sync" m.UserDao.aget_by_source_external_id = AsyncMock(return_value=row) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} await LoginSyncService.execute( - _payload(account_disabled=False), request_ip='1.2.3.4', + _payload(account_disabled=False), + request_ip="1.2.3.4", ) assert row.delete == 0 @@ -438,53 +492,57 @@ class TestDisabledSsoUser: # T14 blocked branches # ========================================================================= + @pytest.mark.asyncio class TestTenantStatusBlocks: - - @pytest.mark.parametrize('status', ['disabled', 'archived', 'orphaned']) + @pytest.mark.parametrize("status", ["disabled", "archived", "orphaned"]) async def test_non_active_leaf_returns_19303(self, patches, status): from bisheng.common.errcode.sso_sync import SsoTenantDisabledError from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} patches.sync_user.return_value = _tenant(tid=15, status=status) with pytest.raises(Exception) as exc_info: - await LoginSyncService.execute(_payload(), request_ip='1.2.3.4') + await LoginSyncService.execute(_payload(), request_ip="1.2.3.4") - assert getattr(exc_info.value, 'status_code', 0) == \ - SsoTenantDisabledError.Code + assert getattr(exc_info.value, "status_code", 0) == SsoTenantDisabledError.Code # ========================================================================= # Existing-user update path (attributes dirty) # ========================================================================= + @pytest.mark.asyncio class TestDepartmentAdminFgaReconcile: - async def test_omitted_admin_field_skips_membership_query(self, patches, monkeypatch): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module aget_ud = AsyncMock(return_value=[]) m.UserDepartmentDao.aget_user_departments = aget_ud monkeypatch.setattr( - m.DepartmentAdminGrantDao, 'aget_by_user_and_departments', - AsyncMock(side_effect=AssertionError('grant query should not run')), + m.DepartmentAdminGrantDao, + "aget_by_user_and_departments", + AsyncMock(side_effect=AssertionError("grant query should not run")), ) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} - await LoginSyncService.execute(_payload(), request_ip='') + await LoginSyncService.execute(_payload(), request_ip="") aget_ud.assert_not_awaited() async def test_empty_admin_list_removes_fga_admin_on_sso_member_dept( - self, patches, monkeypatch, + self, + patches, + monkeypatch, ): from bisheng.department.domain.services import ( department_change_handler as dch, @@ -496,48 +554,54 @@ class TestDepartmentAdminFgaReconcile: from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module m.UserDepartmentDao.aget_user_departments = AsyncMock( return_value=[SimpleNamespace(department_id=11)], ) m.DepartmentDao.aget_by_ids = AsyncMock( - return_value=[_dept('D1', id=11)], + return_value=[_dept("D1", id=11)], ) monkeypatch.setattr( - m.DepartmentAdminGrantDao, 'aget_by_user_and_departments', - AsyncMock(return_value=[ - SimpleNamespace(department_id=11, grant_source='sso'), - ]), + m.DepartmentAdminGrantDao, + "aget_by_user_and_departments", + AsyncMock( + return_value=[ + SimpleNamespace(department_id=11, grant_source="sso"), + ] + ), ) adelete = AsyncMock() - monkeypatch.setattr(m.DepartmentAdminGrantDao, 'adelete', adelete) + monkeypatch.setattr(m.DepartmentAdminGrantDao, "adelete", adelete) exec_mock = AsyncMock() - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} payload = LoginSyncRequest( - external_user_id='u1', - primary_dept_external_id='D1', + external_user_id="u1", + primary_dept_external_id="D1", secondary_dept_external_ids=[], department_admin_external_ids=[], - user_attrs=UserAttrsDTO(name='Alice', email='a@x.com'), + user_attrs=UserAttrsDTO(name="Alice", email="a@x.com"), ts=1000, ) - with patch.object(dch.DepartmentChangeHandler, 'execute_async', exec_mock): - await LoginSyncService.execute(payload, request_ip='') + with patch.object(dch.DepartmentChangeHandler, "execute_async", exec_mock): + await LoginSyncService.execute(payload, request_ip="") ops = [op for call in exec_mock.await_args_list for op in call.args[0]] - admin_ops = [op for op in ops if op.relation == 'admin'] + admin_ops = [op for op in ops if op.relation == "admin"] assert len(admin_ops) == 1 op = admin_ops[0] - assert op.action == 'delete' - assert op.user == 'user:7' - assert op.object == 'department:11' + assert op.action == "delete" + assert op.user == "user:7" + assert op.object == "department:11" adelete.assert_awaited_once_with(7, 11) async def test_manual_admin_marker_skips_fga_on_empty_leader_list( - self, patches, monkeypatch, + self, + patches, + monkeypatch, ): from bisheng.department.domain.services import ( department_change_handler as dch, @@ -549,37 +613,41 @@ class TestDepartmentAdminFgaReconcile: from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module m.UserDepartmentDao.aget_user_departments = AsyncMock( return_value=[SimpleNamespace(department_id=11)], ) m.DepartmentDao.aget_by_ids = AsyncMock( - return_value=[_dept('D1', id=11)], + return_value=[_dept("D1", id=11)], ) monkeypatch.setattr( - m.DepartmentAdminGrantDao, 'aget_by_user_and_departments', - AsyncMock(return_value=[ - SimpleNamespace(department_id=11, grant_source='manual'), - ]), + m.DepartmentAdminGrantDao, + "aget_by_user_and_departments", + AsyncMock( + return_value=[ + SimpleNamespace(department_id=11, grant_source="manual"), + ] + ), ) exec_mock = AsyncMock() - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} payload = LoginSyncRequest( - external_user_id='u1', - primary_dept_external_id='D1', + external_user_id="u1", + primary_dept_external_id="D1", secondary_dept_external_ids=[], department_admin_external_ids=[], - user_attrs=UserAttrsDTO(name='Alice', email='a@x.com'), + user_attrs=UserAttrsDTO(name="Alice", email="a@x.com"), ts=1000, ) - with patch.object(dch.DepartmentChangeHandler, 'execute_async', exec_mock): - await LoginSyncService.execute(payload, request_ip='') + with patch.object(dch.DepartmentChangeHandler, "execute_async", exec_mock): + await LoginSyncService.execute(payload, request_ip="") ops = [op for call in exec_mock.await_args_list for op in call.args[0]] - assert [op for op in ops if op.relation == 'admin'] == [] + assert [op for op in ops if op.relation == "admin"] == [] async def test_leader_dept_grants_admin_tuple(self, patches, monkeypatch): from bisheng.department.domain.services import ( @@ -592,85 +660,259 @@ class TestDepartmentAdminFgaReconcile: from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module m.UserDepartmentDao.aget_user_departments = AsyncMock( return_value=[SimpleNamespace(department_id=11)], ) m.DepartmentDao.aget_by_ids = AsyncMock( - return_value=[_dept('D1', id=11)], + return_value=[_dept("D1", id=11)], ) monkeypatch.setattr( - m.DepartmentAdminGrantDao, 'aget_by_user_and_departments', + m.DepartmentAdminGrantDao, + "aget_by_user_and_departments", AsyncMock(return_value=[]), ) aupsert = AsyncMock() - monkeypatch.setattr(m.DepartmentAdminGrantDao, 'aupsert', aupsert) + monkeypatch.setattr(m.DepartmentAdminGrantDao, "aupsert", aupsert) exec_mock = AsyncMock() - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} payload = LoginSyncRequest( - external_user_id='u1', - primary_dept_external_id='D1', + external_user_id="u1", + primary_dept_external_id="D1", secondary_dept_external_ids=[], - department_admin_external_ids=['D1'], - user_attrs=UserAttrsDTO(name='Alice', email='a@x.com'), + department_admin_external_ids=["D1"], + user_attrs=UserAttrsDTO(name="Alice", email="a@x.com"), ts=1000, ) - with patch.object(dch.DepartmentChangeHandler, 'execute_async', exec_mock): - await LoginSyncService.execute(payload, request_ip='') + with patch.object(dch.DepartmentChangeHandler, "execute_async", exec_mock): + await LoginSyncService.execute(payload, request_ip="") ops = [op for call in exec_mock.await_args_list for op in call.args[0]] - admin_ops = [op for op in ops if op.relation == 'admin'] + admin_ops = [op for op in ops if op.relation == "admin"] assert len(admin_ops) == 1 op = admin_ops[0] - assert op.action == 'write' + assert op.action == "write" from bisheng.database.models.department_admin_grant import ( DEPARTMENT_ADMIN_GRANT_SOURCE_SSO, ) + aupsert.assert_awaited_once_with(7, 11, DEPARTMENT_ADMIN_GRANT_SOURCE_SSO) @pytest.mark.asyncio class TestExistingUserAttrUpdate: - async def test_name_change_triggers_update(self, patches): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) - m = patches.module - existing = _user(user_id=7, user_name='OldName', email='old@x.com', - source='sso', external_id='u1') - m.UserDao.aget_by_source_external_id = AsyncMock(return_value=existing) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} - await LoginSyncService.execute(_payload(name='NewName', email='new@x.com'), - request_ip='1.2.3.4') + m = patches.module + existing = _user(user_id=7, user_name="OldName", email="old@x.com", source="sso", external_id="u1") + m.UserDao.aget_by_source_external_id = AsyncMock(return_value=existing) + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} + + await LoginSyncService.execute(_payload(name="NewName", email="new@x.com"), request_ip="1.2.3.4") m.UserDao.aupdate_user.assert_awaited_once() updated_user = m.UserDao.aupdate_user.await_args.args[0] - assert updated_user.user_name == 'NewName' - assert updated_user.email == 'new@x.com' + assert updated_user.user_name == "NewName" + assert updated_user.email == "new@x.com" async def test_successful_existing_user_sync_touches_update_time(self, patches): from bisheng.sso_sync.domain.services.login_sync_service import ( LoginSyncService, ) + m = patches.module - existing = _user(user_id=7, user_name='Alice', email='a@x.com', - phone_number='13800000000', - source='sso', external_id='u1') + existing = _user( + user_id=7, user_name="Alice", email="a@x.com", phone_number="13800000000", source="sso", external_id="u1" + ) m.UserDao.aget_by_source_external_id = AsyncMock(return_value=existing) - primary = _dept('D1', id=11) - patches.assert_chain.return_value = {'D1': primary} + primary = _dept("D1", id=11) + patches.assert_chain.return_value = {"D1": primary} await LoginSyncService.execute( - _payload(phone='13800000000'), - request_ip='1.2.3.4', + _payload(phone="13800000000"), + request_ip="1.2.3.4", ) m.UserDao.aupdate_user.assert_awaited_once() updated_user = m.UserDao.aupdate_user.await_args.args[0] assert updated_user.update_time is not None + + +# ========================================================================= +# Guest-department fallback (SSO users without an HR department) +# ========================================================================= + + +def _udept(department_id, is_primary=0): + """A minimal UserDepartment row for the guest-fallback tests.""" + return SimpleNamespace(department_id=department_id, is_primary=is_primary) + + +_GUEST_ID = 99 + + +def _wire_reconcile(monkeypatch, *, memberships, guest_status="active", guest=True): + """Patch only the collaborators ``_reconcile_guest_membership`` touches and + return the (add, remove, execute, dept_lookup) mocks for assertions. + + Unit-level: the full ``execute`` path needs live middleware, so the guest + reconcile is exercised in isolation (mirrors test_admin_grant_binding_cleanup). + """ + from bisheng.sso_sync.domain.services import login_sync_service as mod + + guest_dept = _dept("BS@guest", id=_GUEST_ID, source="local", status=guest_status) if guest else None + monkeypatch.setattr(mod.DepartmentDao, "aget_by_dept_id", AsyncMock(return_value=guest_dept)) + dept_lookup = AsyncMock(return_value=memberships) + monkeypatch.setattr(mod.UserDepartmentDao, "aget_user_departments", dept_lookup) + add = AsyncMock() + remove = AsyncMock() + execute = AsyncMock() + monkeypatch.setattr(mod.UserDepartmentDao, "aadd_member", add) + monkeypatch.setattr(mod.UserDepartmentDao, "aremove_member", remove) + monkeypatch.setattr(mod.DepartmentChangeHandler, "execute_async", execute) + # `_remove_department_membership` (vacate path) touches these too. + monkeypatch.setattr(mod.DepartmentAdminGrantDao, "adelete", AsyncMock()) + from bisheng.knowledge.domain.services import ( + department_knowledge_space_service as ks_module, + ) + + monkeypatch.setattr( + ks_module.DepartmentKnowledgeSpaceService, + "cleanup_removed_department_admins", + AsyncMock(), + ) + return SimpleNamespace(add=add, remove=remove, execute=execute, dept_lookup=dept_lookup) + + +class TestGuestMembershipReconcile: + """`_reconcile_guest_membership` keeps the invariant "a user is in the guest + department iff they have no other department".""" + + def test_orphan_joins_guest_as_primary(self, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + mocks = _wire_reconcile(monkeypatch, memberships=[]) + + asyncio.run(LoginSyncService._reconcile_guest_membership(7, row_source="sso")) + + # Guest added as primary, tracked as a bisheng-internal placeholder. + mocks.add.assert_awaited_once_with(7, _GUEST_ID, is_primary=1, source="local") + mocks.remove.assert_not_awaited() + # Guest membership tuple repaired. + ops = [ + (op.action, op.user, op.relation, op.object) + for call in mocks.execute.await_args_list + for op in call.args[0] + ] + assert ("write", "user:7", "member", f"department:{_GUEST_ID}") in ops + + def test_real_department_only_is_noop(self, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + mocks = _wire_reconcile(monkeypatch, memberships=[_udept(11, is_primary=1)]) + + asyncio.run(LoginSyncService._reconcile_guest_membership(7, row_source="sso")) + + mocks.add.assert_not_awaited() + mocks.remove.assert_not_awaited() + + def test_guest_plus_real_vacates_guest(self, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + # Guest lingering as a demoted secondary alongside a real primary. + mocks = _wire_reconcile( + monkeypatch, + memberships=[_udept(_GUEST_ID, is_primary=0), _udept(11, is_primary=1)], + ) + + asyncio.run(LoginSyncService._reconcile_guest_membership(7, row_source="sso")) + + mocks.remove.assert_awaited_once_with(7, _GUEST_ID) + mocks.add.assert_not_awaited() + + def test_guest_only_stays(self, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + # Already in guest and nothing else → leave as-is (idempotent). + mocks = _wire_reconcile(monkeypatch, memberships=[_udept(_GUEST_ID, is_primary=1)]) + + asyncio.run(LoginSyncService._reconcile_guest_membership(7, row_source="sso")) + + mocks.add.assert_not_awaited() + mocks.remove.assert_not_awaited() + + def test_missing_guest_department_is_noop(self, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + mocks = _wire_reconcile(monkeypatch, memberships=[], guest=False) + + asyncio.run(LoginSyncService._reconcile_guest_membership(7, row_source="sso")) + + # Returns before even reading memberships; never writes. + mocks.dept_lookup.assert_not_awaited() + mocks.add.assert_not_awaited() + mocks.remove.assert_not_awaited() + + def test_inactive_guest_department_is_noop(self, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + mocks = _wire_reconcile(monkeypatch, memberships=[], guest_status="archived") + + asyncio.run(LoginSyncService._reconcile_guest_membership(7, row_source="sso")) + + mocks.dept_lookup.assert_not_awaited() + mocks.add.assert_not_awaited() + mocks.remove.assert_not_awaited() + + +@pytest.mark.asyncio +class TestGuestFallbackPlacement: + """The reconcile runs after the account-disabled short-circuit, so disabled + placeholder users are not given a guest membership.""" + + async def test_disabled_user_skips_guest_reconcile(self, patches, monkeypatch): + from bisheng.sso_sync.domain.services.login_sync_service import ( + LoginSyncService, + ) + + m = patches.module + # Existing user → _upsert_user existing branch avoids new-user DB writes. + existing = _user(user_id=7, source="sso", external_id="u1") + m.UserDao.aget_by_source_external_id = AsyncMock(return_value=existing) + aget_by_dept_id = AsyncMock(return_value=_dept("BS@guest", id=_GUEST_ID, source="local")) + monkeypatch.setattr(m.DepartmentDao, "aget_by_dept_id", aget_by_dept_id) + monkeypatch.setattr( + m.UserService, + "ainvalidate_jwt_after_account_disabled", + AsyncMock(return_value=None), + ) + + resp = await LoginSyncService.execute( + _payload(primary=None, account_disabled=True), + request_ip="", + ) + + # Short-circuits before guest reconcile — guest never looked up or added. + aget_by_dept_id.assert_not_awaited() + m.UserDepartmentDao.aadd_member.assert_not_awaited() + assert resp.token == "" diff --git a/src/backend/test/workstation/test_agent_tool_error_handling.py b/src/backend/test/workstation/test_agent_tool_error_handling.py index f7dc31622..8087b7787 100644 --- a/src/backend/test/workstation/test_agent_tool_error_handling.py +++ b/src/backend/test/workstation/test_agent_tool_error_handling.py @@ -18,6 +18,7 @@ from langgraph.prebuilt import ToolNode, create_react_agent from bisheng.workstation.domain.services.chat_service import ( _extract_tool_error, _handle_agent_tool_error, + _is_nested_tool_event, ) @@ -89,3 +90,18 @@ def test_extract_tool_error_none_on_success(): assert _extract_tool_error(tm) is None assert _extract_tool_error("plain string") is None assert _extract_tool_error(None) is None + + +def test_nested_tool_event_detected_from_parent_ids(): + """Wrapped citation tools should only surface the outer tool callback.""" + visible_tool_run_ids = {"outer-run"} + + assert _is_nested_tool_event( + {"parent_ids": ["graph-run", "tools-node-run", "outer-run"]}, + visible_tool_run_ids, + ) + assert not _is_nested_tool_event( + {"parent_ids": ["graph-run", "tools-node-run"]}, + visible_tool_run_ids, + ) + assert not _is_nested_tool_event({}, visible_tool_run_ids) diff --git a/src/backend/test/workstation/test_workstation_config_pattern.py b/src/backend/test/workstation/test_workstation_config_pattern.py new file mode 100644 index 000000000..1052e648e --- /dev/null +++ b/src/backend/test/workstation/test_workstation_config_pattern.py @@ -0,0 +1,54 @@ +"""Regression tests for WorkstationConfig application-center text fields. + +`applicationCenterWelcomeMessage` / `applicationCenterDescription` shipped with +`default=""` but a `pattern` requiring at least one char (`...]+$`). Any tenant +whose stored config left them empty or unset (the default) made +`GET /api/v1/workstation/config` raise a pydantic ValidationError -> HTTP 500, +so the client rendered its full-screen "system maintenance" overlay and +`/workspace/c/new` looked broken. + +The pattern was a character whitelist that could not actually stop XSS (it +allowed `<>/"'&` ...) yet rejected the empty default plus emoji / non-CJK text. +Real XSS protection lives in the frontend (React escapes text nodes; none of +the render sites use dangerouslySetInnerHTML), so the whitelist was dead weight. +Aligned with feat/2.6.0 (commit 72fd1e8f0 "fix: unused pattern"): the pattern +is removed entirely, matching how these fields are validated on the main branch. +""" + +from bisheng.api.v1.schemas import WorkstationConfig + + +def test_missing_fields_fall_back_to_empty_default(): + # Existing installs whose stored config predates these two fields: + # WorkstationConfig(**raw) must not raise when they are absent. + cfg = WorkstationConfig() + assert cfg.applicationCenterWelcomeMessage == "" + assert cfg.applicationCenterDescription == "" + + +def test_explicit_empty_strings_are_valid(): + # Admin cleared the text in 构建 -> 工作台; empty is a legal value. + cfg = WorkstationConfig( + applicationCenterWelcomeMessage="", + applicationCenterDescription="", + ) + assert cfg.applicationCenterWelcomeMessage == "" + assert cfg.applicationCenterDescription == "" + + +def test_normal_cn_en_content_is_valid(): + cfg = WorkstationConfig( + applicationCenterWelcomeMessage="欢迎使用应用中心 Welcome!", + applicationCenterDescription="这里是描述, description.", + ) + assert "欢迎" in cfg.applicationCenterWelcomeMessage + assert "description" in cfg.applicationCenterDescription + + +def test_arbitrary_chars_accepted_after_pattern_removed(): + # The character whitelist has been removed (aligned with feat/2.6.0); input + # the old pattern rejected (emoji, non-CJK scripts) must now pass. Only + # max_length still constrains the field. + text = "こんにちは 🚀 Привет" + cfg = WorkstationConfig(applicationCenterWelcomeMessage=text) + assert cfg.applicationCenterWelcomeMessage == text diff --git a/src/frontend/client/BRAND-THEME-HANDOFF.md b/src/frontend/client/BRAND-THEME-HANDOFF.md index 7c87cd641..ff8e78aef 100644 --- a/src/frontend/client/BRAND-THEME-HANDOFF.md +++ b/src/frontend/client/BRAND-THEME-HANDOFF.md @@ -123,7 +123,7 @@ blue: { 50:'rgb(var(--brand-50) / )', ... 900:'rgb(var(--brand-900) | `ListWebLinkIllustration` | 列表网页链接 | | | `CrawlingIllustration` | 爬取中 | mask uid 已唯一化 | | `SuccessIllustration` | 成功态 | **跟随品牌主题**(用户已确认,非固定语义成功绿) | -| `SystemMaintenanceIllustration` | 系统维护 | 数据库+扳手;无 mask;2026-06-24 新增,组件已建未接槽位 | +| `SystemMaintenanceIllustration` | 系统维护 | 放大镜+小虫(2026-06 换过图);无 mask;用于 `SystemMaintenanceOverlay`(后端 500 全屏维护弹层)。含 6 档绿,按明度归到 illus-500/300/100 | **通用空状态已替换(2026-06-24)**:10 处通用 `assets/channel/empty.png` 的 `` 已换成 ``(去掉对内联 SVG 无意义的 `object-contain`):ChannelMemberManagementPanel、ChannelMemberDialog、KnowledgeSpaceMemberManagementPanel、KnowledgeSpaceMemberDialog、ChannelSquare、Subscription/index、knowledge/index、KnowledgeSquare、SpaceDetail/index、apps/AppEmptyState。 diff --git a/src/frontend/client/src/api/approval.test.ts b/src/frontend/client/src/api/approval.test.ts deleted file mode 100644 index 38fb18c82..000000000 --- a/src/frontend/client/src/api/approval.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import request from "~/api/request"; -import { - applyMenuAccessApi, - decideApprovalTaskApi, - getApprovalInstanceDetailApi, - getMyApprovalTaskDetailApi, - listApprovalRequestsApi, - listMyApprovalRequestsApi, - listMyApprovalTasksApi, - revokeMenuAccessGrantApi, - withdrawApprovalInstanceApi, -} from "./approval"; - -jest.mock("~/api/request", () => ({ - __esModule: true, - default: { - get: jest.fn(), - post: jest.fn(), - paramsSerializer: jest.fn(), - }, -})); - -const mockGet = request.get as jest.Mock; -const mockPost = request.post as jest.Mock; - -describe("approval api", () => { - beforeEach(() => { - mockGet.mockReset(); - mockPost.mockReset(); - }); - - it("uses repeated query params for legacy approval request status arrays", async () => { - mockGet.mockResolvedValue({ data: [], total: 0 }); - - await listApprovalRequestsApi({ - space_id: 1, - statuses: ["pending_review", "rejected", "finalize_failed"], - page: 1, - page_size: 100, - }); - - expect(mockGet).toHaveBeenCalledWith("/api/v1/approval/requests", { - params: { - space_id: 1, - statuses: ["pending_review", "rejected", "finalize_failed"], - page: 1, - page_size: 100, - }, - paramsSerializer: request.paramsSerializer, - }); - }); - - it("unwraps my-task list payloads from approval center", async () => { - mockGet.mockResolvedValue({ - status_code: 200, - data: { data: [{ task_id: 11, business_name: "知识库订阅" }], total: 1 }, - }); - - await expect(listMyApprovalTasksApi()).resolves.toEqual({ - data: [{ task_id: 11, business_name: "知识库订阅" }], - total: 1, - }); - }); - - it("loads task detail from approval center endpoint", async () => { - mockGet.mockResolvedValue({ - status_code: 200, - data: { task_id: 11, status: "pending" }, - }); - - await expect(getMyApprovalTaskDetailApi(11)).resolves.toEqual({ - task_id: 11, - status: "pending", - }); - }); - - it("submits task decisions to approval center endpoint", async () => { - mockPost.mockResolvedValue({ - status_code: 200, - data: { task_id: 11, status: "approved" }, - }); - - await expect(decideApprovalTaskApi(11, { action: "approve", comment: "ok" })).resolves.toEqual({ - task_id: 11, - status: "approved", - }); - expect(mockPost).toHaveBeenCalledWith("/api/v1/approval/tasks/11/decision", { - action: "approve", - comment: "ok", - }); - }); - - it("unwraps my-request list payloads from approval center", async () => { - mockGet.mockResolvedValue({ - status_code: 200, - data: { data: [{ instance_id: 21, business_name: "频道订阅" }], total: 1 }, - }); - - await expect(listMyApprovalRequestsApi()).resolves.toEqual({ - data: [{ instance_id: 21, business_name: "频道订阅" }], - total: 1, - }); - }); - - it("loads approval instance detail", async () => { - mockGet.mockResolvedValue({ - status_code: 200, - data: { instance_id: 21, status: "approved" }, - }); - - await expect(getApprovalInstanceDetailApi(21)).resolves.toEqual({ - instance_id: 21, - status: "approved", - }); - }); - - it("submits withdraw and revoke grant actions", async () => { - mockPost - .mockResolvedValueOnce({ - status_code: 200, - data: { instance_id: 21, status: "withdrawn" }, - }) - .mockResolvedValueOnce({ - status_code: 200, - data: { instance_id: 21, revoked_keys: ["knowledge"] }, - }); - - await expect(withdrawApprovalInstanceApi(21, { reason: "cancel" })).resolves.toEqual({ - instance_id: 21, - status: "withdrawn", - }); - await expect(revokeMenuAccessGrantApi(21, { reason: "cleanup" })).resolves.toEqual({ - instance_id: 21, - revoked_keys: ["knowledge"], - }); - - expect(mockPost).toHaveBeenNthCalledWith(1, "/api/v1/approval/instances/21/withdraw", { - reason: "cancel", - }); - expect(mockPost).toHaveBeenNthCalledWith(2, "/api/v1/approval/menu-access/21/revoke-grant", { - reason: "cleanup", - }); - }); - - it("submits menu access applications", async () => { - mockPost.mockResolvedValue({ - status_code: 200, - data: { decision: "pending", instance_id: 31 }, - }); - - await expect(applyMenuAccessApi({ - menu_key: "knowledge_space", - menu_name: "知识库", - })).resolves.toEqual({ - decision: "pending", - instance_id: 31, - }); - - expect(mockPost).toHaveBeenCalledWith("/api/v1/approval/menu-access/apply", { - menu_key: "knowledge_space", - menu_name: "知识库", - }); - }); -}); diff --git a/src/frontend/client/src/api/channels.test.ts b/src/frontend/client/src/api/channels.test.ts deleted file mode 100644 index 4f66ec400..000000000 --- a/src/frontend/client/src/api/channels.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import request from "~/api/request"; -import { - authorizeChannelApi, - canEditChannelSettings, - canManageChannelPermissions, - ChannelRole, - getChannelsApi, - getChannelGrantSubjectsUsersApi, - getChannelPermissionsApi, - SortType, -} from "./channels"; - -jest.mock("~/api/request", () => ({ - __esModule: true, - default: { - get: jest.fn(), - post: jest.fn(), - }, -})); - -const mockGet = request.get as jest.Mock; -const mockPost = request.post as jest.Mock; - -describe("channel permission APIs", () => { - beforeEach(() => { - mockGet.mockReset(); - mockPost.mockReset(); - }); - - it("uses channel manager permissions endpoint", async () => { - mockGet.mockResolvedValue({ - status_code: 200, - data: { - data: [ - { - subject_type: "user", - subject_id: 2, - subject_name: "Alice", - relation: "viewer", - }, - ], - }, - }); - - await expect(getChannelPermissionsApi("channel-1")).resolves.toHaveLength(1); - expect(mockGet).toHaveBeenCalledWith( - "/api/v1/channel/manager/channel-1/permissions", - { skip403Redirect: true }, - ); - }); - - it("uses channel manager authorize endpoint", async () => { - mockPost.mockResolvedValue({ status_code: 200, data: null }); - - await authorizeChannelApi("channel-1", { - grants: [{ subject_type: "user", subject_id: 2, relation: "viewer" }], - revokes: [], - }); - - expect(mockPost).toHaveBeenCalledWith( - "/api/v1/channel/manager/channel-1/authorize", - { - grants: [{ subject_type: "user", subject_id: 2, relation: "viewer" }], - revokes: [], - }, - { skip403Redirect: true }, - ); - }); - - it("uses channel manager grant subjects endpoint", async () => { - mockGet.mockResolvedValue({ - status_code: 200, - data: { data: [{ user_id: 2, user_name: "Alice" }] }, - }); - - await expect( - getChannelGrantSubjectsUsersApi( - "channel-1", - { keyword: "ali", page: 2, page_size: 50 }, - { signal: undefined }, - ), - ).resolves.toEqual([{ user_id: 2, user_name: "Alice" }]); - expect(mockGet).toHaveBeenCalledWith( - "/api/v1/channel/manager/channel-1/grant-subjects/users", - { - params: { keyword: "ali", page: 2, page_size: 50 }, - skip403Redirect: true, - signal: undefined, - }, - ); - }); - - it("maps channel relation ahead of legacy user role", async () => { - mockGet.mockResolvedValue({ - data: [ - { - id: "channel-1", - name: "资讯频道", - source_list: [], - visibility: "public", - is_released: true, - user_role: "member", - relation: "editor", - permission_ids: ["view_channel", "edit_channel"], - is_pinned: false, - create_time: "2026-05-28T00:00:00Z", - latest_article_update_time: "2026-05-28T01:00:00Z", - unread_count: 0, - }, - ], - }); - - const channels = await getChannelsApi({ - type: "subscribed", - sortBy: SortType.RECENT_UPDATE, - }); - - expect(channels[0].role).toBe("editor"); - expect(channels[0].permissionIds).toEqual(["view_channel", "edit_channel"]); - expect(mockGet).toHaveBeenCalledWith( - "/api/v1/channel/manager/my_channels", - { - params: { - query_type: "followed", - sort_by: SortType.RECENT_UPDATE, - }, - }, - ); - }); -}); - -describe("channel relation helpers", () => { - it("allows editor to edit channel settings without managing permissions", () => { - expect(canEditChannelSettings("owner")).toBe(true); - expect(canEditChannelSettings("manager")).toBe(true); - expect(canEditChannelSettings("editor")).toBe(true); - expect(canEditChannelSettings(ChannelRole.CREATOR)).toBe(true); - expect(canEditChannelSettings(ChannelRole.ADMIN)).toBe(true); - expect(canEditChannelSettings("viewer")).toBe(false); - expect(canEditChannelSettings(ChannelRole.MEMBER)).toBe(false); - }); - - it("uses permission ids ahead of role for channel settings", () => { - expect(canEditChannelSettings("manager", ["view_channel"])).toBe(false); - expect(canEditChannelSettings("viewer", ["view_channel", "edit_channel"])).toBe(true); - }); - - it("allows new owner/manager and legacy creator/admin to manage permissions", () => { - expect(canManageChannelPermissions("owner")).toBe(true); - expect(canManageChannelPermissions("manager")).toBe(true); - expect(canManageChannelPermissions(ChannelRole.CREATOR)).toBe(true); - expect(canManageChannelPermissions(ChannelRole.ADMIN)).toBe(true); - expect(canManageChannelPermissions("editor")).toBe(false); - expect(canManageChannelPermissions("viewer")).toBe(false); - expect(canManageChannelPermissions(ChannelRole.MEMBER)).toBe(false); - }); - - it("uses permission ids ahead of role for member management", () => { - expect(canManageChannelPermissions("manager", ["view_channel", "edit_channel"])).toBe(false); - expect(canManageChannelPermissions("viewer", ["view_channel", "manage_channel_user"])).toBe(true); - }); -}); diff --git a/src/frontend/client/src/api/chatApi.ts b/src/frontend/client/src/api/chatApi.ts index f84cf4a92..2c88e39d2 100644 --- a/src/frontend/client/src/api/chatApi.ts +++ b/src/frontend/client/src/api/chatApi.ts @@ -137,6 +137,9 @@ export interface ChatMessage { references?: ReferenceSource[]; citations?: ChatCitation[] | null; files?: any[]; + /** Persisted 点赞/点踩 verdict on this answer row: 0 none / 1 up / 2 down. + Seeds the feedback buttons' highlight on history reload. */ + liked?: number; // --- v2.5 Agent-mode native fields --- /** One of question / agent_answer / agent_thinking / agent_tool_call / task / legacy answer. */ category?: string; @@ -246,6 +249,7 @@ function mapAgentResponseItem(row: any): ChatMessage { category, files: Array.isArray(row.files) ? row.files : [], citations: Array.isArray(row.citations) ? row.citations : null, + liked: row.liked, }; if (category === "question" && raw && typeof raw === "object") { @@ -494,6 +498,7 @@ export function parseStreamHistoryItem(raw: StreamHistoryItem): ChatMessage { createdAt: raw.create_time, error: false, flow_name: raw.flow_name, + liked: raw.liked, }; } diff --git a/src/frontend/client/src/api/index.ts b/src/frontend/client/src/api/index.ts index 19e153ed4..1aa2859b7 100644 --- a/src/frontend/client/src/api/index.ts +++ b/src/frontend/client/src/api/index.ts @@ -18,10 +18,15 @@ export async function getVoice2TextApi(data: any): Promise { /** * 文字转语音 + * + * skip403Redirect opts out of the global 403 redirect AND routes a non-200 + * business error (e.g. TTS synthesis failure, code 10026) through the + * interceptor's translate-and-toast path instead of surfacing as a silent + * malformed success — see request.ts's skip403Redirect branch. */ export const textToSpeech = (text: string): Promise<{ audio: string }> => { // const encodedText = encodeURIComponent(text); - return request.post(`/api/v1/llm/workbench/tts`, { text }); + return request.post(`/api/v1/llm/workbench/tts`, { text }, { skip403Redirect: true } as any); }; diff --git a/src/frontend/client/src/api/knowledge.test.ts b/src/frontend/client/src/api/knowledge.test.ts deleted file mode 100644 index d983862cb..000000000 --- a/src/frontend/client/src/api/knowledge.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import request from "~/api/request"; -import { batchDeleteApi, batchDownloadApi, createFolderApi, deleteFolderApi, getSquareSpacesApi, renameFolderApi, VisibilityType } from "./knowledge"; - -jest.mock("~/api/request", () => ({ - __esModule: true, - default: { - get: jest.fn(), - post: jest.fn(), - postMultiPart: jest.fn(), - put: jest.fn(), - delete: jest.fn(), - }, -})); - -const mockGet = request.get as jest.Mock; -const mockPost = request.post as jest.Mock; -const mockPostMultiPart = request.postMultiPart as jest.Mock; -const mockPut = request.put as jest.Mock; -const mockDelete = request.delete as jest.Mock; - -describe("getSquareSpacesApi", () => { - it("maps pending square items from is_pending when subscription_status is absent", async () => { - mockGet.mockResolvedValue({ - data: { - total: 1, - data: [ - { - space: { - id: 101, - name: "Pending space", - auth_type: VisibilityType.APPROVAL, - user_id: 7, - user_name: "owner", - is_released: true, - }, - is_pending: true, - file_num: 3, - follower_num: 2, - }, - ], - }, - }); - - const result = await getSquareSpacesApi(); - - expect(result.data[0]).toMatchObject({ - id: "101", - isPending: true, - isFollowed: false, - squareStatus: "pending", - }); - }); -}); - -describe("subscribeSpaceApi", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("returns backend subscription status", async () => { - const { subscribeSpaceApi } = await import("./knowledge"); - mockPost.mockResolvedValue({ - status_code: 200, - data: { - status: "pending", - space_id: 101, - }, - }); - - await expect(subscribeSpaceApi("101")).resolves.toEqual({ - status: "pending", - spaceId: "101", - }); - }); -}); - -describe("unsubscribeSpaceApi", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("returns backend response so callers can handle business status codes", async () => { - const { unsubscribeSpaceApi } = await import("./knowledge"); - mockPost.mockResolvedValue({ - status_code: 18071, - status_message: "本空间通过部门/用户组授权给你,暂无法退出", - data: null, - }); - - await expect(unsubscribeSpaceApi("101")).resolves.toEqual({ - status_code: 18071, - status_message: "本空间通过部门/用户组授权给你,暂无法退出", - data: null, - }); - }); -}); - -describe("createFolderApi", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("rejects backend business errors", async () => { - mockPost.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(createFolderApi("101", { name: "New folder" })).rejects.toThrow("Permission denied"); - }); -}); - -describe("renameFolderApi", () => { - beforeEach(() => { - mockPut.mockReset(); - }); - - it("rejects backend business errors", async () => { - mockPut.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(renameFolderApi("101", "202", "Renamed")).rejects.toThrow("Permission denied"); - }); -}); - -describe("deleteFolderApi", () => { - beforeEach(() => { - mockDelete.mockReset(); - }); - - it("rejects backend business errors", async () => { - mockDelete.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(deleteFolderApi("101", "202")).rejects.toThrow("Permission denied"); - }); -}); - -describe("batchDeleteApi", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("rejects backend business errors", async () => { - mockPost.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(batchDeleteApi("101", { folder_ids: [202] })).rejects.toThrow("Permission denied"); - }); -}); - -describe("batchDownloadApi", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("rejects backend business errors", async () => { - mockPost.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(batchDownloadApi("101", { folder_ids: [202] })).rejects.toThrow("Permission denied"); - }); -}); - -describe("uploadFileToServerApi", () => { - beforeEach(() => { - mockPostMultiPart.mockReset(); - }); - - it("rejects backend business errors", async () => { - const { uploadFileToServerApi } = await import("./knowledge"); - mockPostMultiPart.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(uploadFileToServerApi("101", new File(["x"], "doc.txt"))).rejects.toThrow("Permission denied"); - }); -}); - -describe("addFilesApi", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("rejects backend business errors", async () => { - const { addFilesApi } = await import("./knowledge"); - mockPost.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect(addFilesApi("101", { file_path: ["/tmp/doc.txt"] })).rejects.toThrow("Permission denied"); - }); -}); diff --git a/src/frontend/client/src/api/permission.test.ts b/src/frontend/client/src/api/permission.test.ts deleted file mode 100644 index d384fda09..000000000 --- a/src/frontend/client/src/api/permission.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import request from "~/api/request"; -import { authorizeResource } from "./permission"; - -jest.mock("~/api/request", () => ({ - __esModule: true, - default: { - get: jest.fn(), - post: jest.fn(), - }, -})); - -const mockPost = request.post as jest.Mock; - -describe("permission API", () => { - beforeEach(() => { - mockPost.mockReset(); - }); - - it("rejects business error envelopes from authorizeResource", async () => { - mockPost.mockResolvedValue({ - status_code: 19000, - status_message: "Permission denied", - data: null, - }); - - await expect( - authorizeResource( - "knowledge_space", - "1", - [{ subject_type: "user", subject_id: 2, relation: "viewer" }], - [], - ), - ).rejects.toThrow("Permission denied"); - }); -}); diff --git a/src/frontend/client/src/components/Chat/AiChatInput.tsx b/src/frontend/client/src/components/Chat/AiChatInput.tsx index bd09eebeb..379c45b39 100644 --- a/src/frontend/client/src/components/Chat/AiChatInput.tsx +++ b/src/frontend/client/src/components/Chat/AiChatInput.tsx @@ -27,6 +27,7 @@ import { ArrowDown } from "lucide-react"; import { SendIcon } from "~/components/svg"; import { Button, TextareaAutosize } from "~/components/ui"; import SpeechToTextComponent from "~/components/Voice/SpeechToText"; +import { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from "~/hooks"; import { useGetWorkbenchModelsQuery } from "~/hooks/queries/data-provider"; import InputFiles from "~/pages/appChat/components/InputFiles"; import { useFileDropAndPaste } from "~/pages/appChat/useFileDropAndPaste"; @@ -154,6 +155,10 @@ const AiChatInput = memo( // so reading it from bsConfig would silently fall back to 50MB. const envConfig = useRecoilValue(bishengConfState); + // Collapse toolbar labels to icons when the toolbar's own width (not the + // viewport's) runs short — e.g. once the sidebar opens on a mid-size screen. + const { ref: toolbarRef, compact: toolbarCompact } = useContainerCompact(TOOLBAR_COMPACT_THRESHOLD); + // F035 (PRD §4.1.3): daily "+ → 添加 Skill" picks a skill into the fresh // task session ('new'), then enters task mode (/linsight/new) where the // selection is refilled as a chip. Keyed 'new' to match the landing page. @@ -432,7 +437,7 @@ const AiChatInput = memo(
{/* Toolbar:flex-1 + overflow-hidden,避免与右侧语音/发送横向重叠 */} -
+
{/* "+" menu — v2.5: combines file upload + knowledge space + org knowledge base. Renders in place of ChatKnowledge when agent mode is active (which is the v2.5 default). */} @@ -489,6 +494,7 @@ const AiChatInput = memo( variant="knowledge" config={bsConfig} disabled={!!disabled} + compact={toolbarCompact} value={selectedOrgKbs} onChange={(val) => { onSelectedOrgKbsChange(val); @@ -504,11 +510,13 @@ const AiChatInput = memo( )} {tools && !agentMode && onSearchTypeChange && ( { onSearchTypeChange(type); @@ -528,6 +536,7 @@ const AiChatInput = memo( {taskMode && ( navigate('/c/new')} /> )} diff --git a/src/frontend/client/src/components/Chat/AiChatMessages.tsx b/src/frontend/client/src/components/Chat/AiChatMessages.tsx index 84691580c..201418a7b 100644 --- a/src/frontend/client/src/components/Chat/AiChatMessages.tsx +++ b/src/frontend/client/src/components/Chat/AiChatMessages.tsx @@ -83,6 +83,7 @@ function MessageTreeNode({ onRegenerate, knowledgeChatLayout, allowExport, + allowFeedback, onOpenCitationPanel, activeCitationMessageId, onPreviewFile, @@ -95,6 +96,7 @@ function MessageTreeNode({ onPreviewFile?: (file: ArtifactFile) => void; knowledgeChatLayout?: boolean; allowExport?: boolean; + allowFeedback?: boolean; onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void; activeCitationMessageId?: string | null; }) { @@ -140,6 +142,7 @@ function MessageTreeNode({ setSiblingIdx={setSiblingIdx} knowledgeChatLayout={knowledgeChatLayout} allowExport={allowExport} + allowFeedback={allowFeedback} onOpenCitationPanel={onOpenCitationPanel} activeCitationMessageId={activeCitationMessageId} onPreviewFile={onPreviewFile} @@ -154,6 +157,7 @@ function MessageTreeNode({ onRegenerate={onRegenerate} knowledgeChatLayout={knowledgeChatLayout} allowExport={allowExport} + allowFeedback={allowFeedback} onOpenCitationPanel={onOpenCitationPanel} activeCitationMessageId={activeCitationMessageId} onPreviewFile={onPreviewFile} @@ -283,6 +287,10 @@ export default function AiChatMessages({ const hasMessages = messages.length > 0; + // 点赞/点踩 is offered on every real chat surface; the read-only anonymous + // share view (which carries a shareToken) opts out. + const allowFeedback = !shareToken; + // --- Empty state --- if (!hasMessages && !isLoading && !hideEmptyState) { return ( @@ -408,6 +416,7 @@ export default function AiChatMessages({ } knowledgeChatLayout={knowledgeChatLayout} allowExport={allowExport} + allowFeedback={allowFeedback} onOpenCitationPanel={onOpenCitationPanel} activeCitationMessageId={activeCitationMessageId} onPreviewFile={onPreviewFile} @@ -425,6 +434,7 @@ export default function AiChatMessages({ onRegenerate={onRegenerate} knowledgeChatLayout={knowledgeChatLayout} allowExport={allowExport} + allowFeedback={allowFeedback} onOpenCitationPanel={onOpenCitationPanel} activeCitationMessageId={activeCitationMessageId} onPreviewFile={onPreviewFile} diff --git a/src/frontend/client/src/components/Chat/AiMessageBubble.tsx b/src/frontend/client/src/components/Chat/AiMessageBubble.tsx index ac343db8c..4ee1e9fe4 100644 --- a/src/frontend/client/src/components/Chat/AiMessageBubble.tsx +++ b/src/frontend/client/src/components/Chat/AiMessageBubble.tsx @@ -22,6 +22,8 @@ import type { ArtifactFile } from "~/components/Linsight/Artifacts/artifactUtils import { Avatar, AvatarImage, AvatarName } from "~/components/ui/Avatar"; import { TextToSpeechButton } from "~/components/Voice/TextToSpeechButton"; import { ServiceBusyNotice } from "~/components/ServiceBusyNotice"; +import { MessageFeedbackButtons } from "~/components/Chat/MessageFeedbackButtons"; +import { likeChatApi, disLikeCommentApi } from "~/api/apps"; import { useGetBsConfig } from "~/hooks/queries/data-provider"; import { useAuthContext, useLocalize } from "~/hooks"; import { useMessageSelection } from "~/hooks/useMessageSelection"; @@ -141,6 +143,9 @@ interface AiMessageBubbleProps { homepage/task chat opts in; the lightweight knowledge/file/article docks and the share view leave it off. */ allowExport?: boolean; + /** Show the 点赞/点踩 feedback buttons under assistant answers. Default true; + the read-only anonymous share view passes false. */ + allowFeedback?: boolean; onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void; activeCitationMessageId?: string | null; /** F035: preview a task-turn document in the inline workspace panel (ChatView @@ -335,6 +340,7 @@ const AiMessageBubble = memo( setSiblingIdx, knowledgeChatLayout, allowExport, + allowFeedback = true, onOpenCitationPanel, activeCitationMessageId, onPreviewFile, @@ -363,6 +369,7 @@ const AiMessageBubble = memo( setSiblingIdx={setSiblingIdx} knowledgeChatLayout={knowledgeChatLayout} allowExport={allowExport} + allowFeedback={allowFeedback} onOpenCitationPanel={onOpenCitationPanel} activeCitationMessageId={activeCitationMessageId} onPreviewFile={onPreviewFile} @@ -490,6 +497,7 @@ function AssistantBubble({ setSiblingIdx, knowledgeChatLayout, allowExport, + allowFeedback = true, onOpenCitationPanel, activeCitationMessageId, onPreviewFile, @@ -503,6 +511,7 @@ function AssistantBubble({ setSiblingIdx?: (idx: number) => void; knowledgeChatLayout?: boolean; allowExport?: boolean; + allowFeedback?: boolean; onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void; activeCitationMessageId?: string | null; onPreviewFile?: (file: ArtifactFile) => void; @@ -608,6 +617,8 @@ function AssistantBubble({
+ {/* 点赞/点踩 — the answer persists as a chatmessage row, so + reuse the existing /liked + /chat/comment endpoints keyed + by message_id. Hidden on the read-only share view. */} + {allowFeedback && message.messageId && ( + likeChatApi(message.messageId, liked)} + onDislikeComment={(comment) => + disLikeCommentApi(message.messageId, comment) + } + /> + )} } /> diff --git a/src/frontend/client/src/components/Chat/AiModelSelect.tsx b/src/frontend/client/src/components/Chat/AiModelSelect.tsx index 939e918bd..d04b09e2f 100644 --- a/src/frontend/client/src/components/Chat/AiModelSelect.tsx +++ b/src/frontend/client/src/components/Chat/AiModelSelect.tsx @@ -76,10 +76,20 @@ const AiModelSelect = memo( very long ones. `auto` (see SelectContent) keeps the popup from being forced to the trigger's width. No flash on open: the model list is already in memory via `options`. */} - + {uniqueOptions.map((opt) => ( - - {opt.displayName} + +
+ {opt.displayName} + {opt.description && ( + <> + + + {opt.description} + + + )} +
))}
diff --git a/src/frontend/client/src/components/Chat/ChatView.tsx b/src/frontend/client/src/components/Chat/ChatView.tsx index 2747b8cb6..0713cde4d 100644 --- a/src/frontend/client/src/components/Chat/ChatView.tsx +++ b/src/frontend/client/src/components/Chat/ChatView.tsx @@ -321,11 +321,11 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? landingResizeObserverRef.current = ro; }, []); - // Mobile only: the landing parent is `h-full` (= the visible scroll-area - // height, header/banner already excluded). We center the welcome block at - // its 50% mark and place the apps `parentH/2 + blockH/2 + 40px` from the top - // — i.e. exactly 40px below the centered input — instead of `vh`, which on - // mobile resolves below the visual center (layout-viewport relative). + // H5 shell (≤767) only: the landing parent is a definite `h-full` box (= the + // visible scroll-area height; MobileNav + Banner already excluded). We can't + // use `vh` here, and a `%` paddingTop resolves against WIDTH, so the apps + // offset is computed in px from this measured height: apps sit 40px below the + // welcome block, whose center is pinned at 40% of the region. const landingParentObserverRef = useRef(null); const [landingParentHeight, setLandingParentHeight] = useState(0); const landingParentRef = useCallback((el: HTMLDivElement | null) => { @@ -341,6 +341,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? landingParentObserverRef.current = ro; }, []); + // F035: task mode is a ROLE permission. The backend folds each role's // menu_ids into web_menu → client `user.plugins`; `linsight_task_mode` is the // workbench-home sub-capability toggled per role in the admin console. When @@ -586,11 +587,12 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? return (
{/* Content area: Split into Chat Main and Citation Sidebar */} {isLoading && conversationId !== 'new' ? ( @@ -796,33 +798,26 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? {citationPanelElement}
) : ( - /* Landing page branch — welcome + input are pinned to viewport - vertical center via absolute positioning (top:50% + translateY). - Recommended apps sit exactly 40px below the input by using - `paddingTop: calc(50vh + landingHalfHeight + 40px)`, where - `landingBlockHeight` is measured live with a ResizeObserver. - When total content exceeds viewport, the parent's - overflow-y-auto handles scrolling — the centered block scrolls - up with the document as expected. + /* Landing page branch — the welcome block is pinned to the + region's vertical center via absolute positioning, so its + position is INDEPENDENT of whether recommended apps exist + (apps just flow below it and scroll if they overflow). - Mobile (touch-mobile, ≤1023px) can NOT use `vh`: the MobileNav - header + Banner sit above this scroll container, so `45vh` - (layout-viewport relative) resolves below the visible center. - Instead the parent gets a definite `h-full` (= the visible - scroll-area height) so the welcome block sits at 45% of it - (`top-[45%]`, slightly above true center — looks more - balanced than dead-center), and the apps sit 40px below the - input via a JS-measured offset (see landingParentHeight). */ -
- {/* Centered: welcome message + input. `top: 50vh` (viewport - height), NOT `top: 50%` — the parent's effective height - gets stretched by the apps' paddingTop below, so a - percentage would resolve to a non-viewport midpoint. - On mobile the parent is a fixed `h-full` box, so `top-[45%]` - resolves against the visible height (45vh-equivalent). */} + ≥768 (desktop): center at 45vh; apps sit 40px below via + `paddingTop: calc(45vh + halfBlock + 40px)`. + + ≤767 (H5 shell): can't use `vh` (MobileNav + Banner sit above + this scroll container). The parent is a definite `h-full` box, + so the block centers at 35% of it (`top-[35%]`) and the apps + offset is computed in px from the measured region height + (landingParentHeight) — a `%` paddingTop would resolve against + width, not height. */ +
+ {/* Welcome message + input, absolutely centered. ≥768 at 45vh, + ≤767 at 40% of the definite-height region. */}
{/* F035 Track H (P5): daily/task mode switch removed — task mode is reached via the sidebar "new task" entry @@ -831,7 +826,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? {/* Input area for landing page */} {!shareToken && ( -
+
- {/* Recommended apps: 40px below the centered block. The - paddingTop pushes apps to (viewport midpoint) + (landing - half-height) + 40px = (landing block bottom) + 40px. - On mobile `vh` is replaced by the measured visible height - (landingParentHeight) so the gap stays exactly 40px below - the centered input rather than landing on the next screen. */} + {/* Recommended apps: 40px below the centered block, so the + block's own position never shifts when apps appear. + offset = (block center) + (half block height) + 40px. + ≥768 block center = 45vh; ≤767 = 40% of the measured region + height (px, since `vh`/`%`-padding don't work here). */}
diff --git a/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx b/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx index 5f0c52ae2..899aaa69a 100644 --- a/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx +++ b/src/frontend/client/src/components/Chat/Input/AgentToolSelector.tsx @@ -40,6 +40,8 @@ export interface AvailableToolGroup { interface Props { availableTools: AvailableToolGroup[]; disabled?: boolean; + /** Toolbar out of room (see useContainerCompact): collapse label to icon. */ + compact?: boolean; } function iconForGroup(group: AvailableToolGroup) { @@ -48,7 +50,7 @@ function iconForGroup(group: AvailableToolGroup) { return ; } -export default function AgentToolSelector({ availableTools, disabled }: Props) { +export default function AgentToolSelector({ availableTools, disabled, compact = false }: Props) { const localize = useLocalize(); const [selected, setSelected] = useRecoilState(store.selectedAgentTools); const [initialized, setInitialized] = useRecoilState(store.agentToolsInitialized); @@ -129,11 +131,12 @@ export default function AgentToolSelector({ availableTools, disabled }: Props) {
- {/* Mobile: collapse to icon + chevron only to save horizontal space. */} - - {localize("com_tools_title")} - {/* {isActive ? ` (${activeCount})` : ""} */} - + {/* Compact: collapse to icon + chevron only to save horizontal space. */} + {!compact && ( + + {localize("com_tools_title")} + + )}
diff --git a/src/frontend/client/src/components/Chat/Input/ChatFormTools.tsx b/src/frontend/client/src/components/Chat/Input/ChatFormTools.tsx index ae4ef0437..a49f22422 100644 --- a/src/frontend/client/src/components/Chat/Input/ChatFormTools.tsx +++ b/src/frontend/client/src/components/Chat/Input/ChatFormTools.tsx @@ -28,11 +28,14 @@ export const ChatToolDown = ({ searchType, setSearchType, disabled, + compact = false, }: { config?: BsConfig; searchType: string; setSearchType: (type: string) => void; disabled: boolean; + /** Toolbar out of room (see useContainerCompact): collapse label to icon. */ + compact?: boolean; }) => { const localize = useLocalize(); @@ -42,21 +45,25 @@ export const ChatToolDown = ({ - -
+ +
- + {localize("com_tools_title")}
diff --git a/src/frontend/client/src/components/Chat/Input/ChatKnowledge.tsx b/src/frontend/client/src/components/Chat/Input/ChatKnowledge.tsx index 8fbe96fef..36d7cda47 100644 --- a/src/frontend/client/src/components/Chat/Input/ChatKnowledge.tsx +++ b/src/frontend/client/src/components/Chat/Input/ChatKnowledge.tsx @@ -318,6 +318,7 @@ export const ChatKnowledge = ({ renderSkillSubmenu, taskModeActive = false, skillSelected = false, + compact = false, }: { /** Controls the trigger button and which menu sections render: * - 'plus' → "+" trigger; file-upload + task-mode (+ optional add-skill) sections. @@ -342,6 +343,8 @@ export const ChatKnowledge = ({ taskModeActive?: boolean; /** F035: tint the "添加技能" icon brand-blue once at least one skill is picked. */ skillSelected?: boolean; + /** Toolbar out of room (see useContainerCompact): collapse label to icon. */ + compact?: boolean; }) => { const localize = useLocalize(); const PAGE_SIZE = 20; @@ -608,9 +611,9 @@ export const ChatKnowledge = ({ }} />
- {/* Mobile: collapse to icon + chevron only to save horizontal - space in the input toolbar. */} - {localize('com_ui_knowledge_space')} + {/* Compact: collapse to icon + chevron only to save + horizontal space in the input toolbar. */} + {!compact && {localize('com_ui_knowledge_space')}} ) : ( diff --git a/src/frontend/client/src/components/Chat/Landing.tsx b/src/frontend/client/src/components/Chat/Landing.tsx index 8c7a0c83b..145d91ceb 100644 --- a/src/frontend/client/src/components/Chat/Landing.tsx +++ b/src/frontend/client/src/components/Chat/Landing.tsx @@ -29,29 +29,29 @@ export default function Landing({ Header, isNew, hideSubtitle = false }: { return (
{Header != null ? Header : null}
-
- {/* Hero: stack vertically on 576 稿 */} -
+
+ {/* Hero: row on ≥768 (matches the desktop shell), stacked only on the H5 shell (≤767) */} +
{bsConfig?.assistantIcon?.image && ( )} -

+

{bsConfig?.welcomeMessage}

{!hideSubtitle && ( -
+
{bsConfig?.functionDescription}
)} {/* Conversation starters */} {conversation_starters.length > 0 && ( -
+
{conversation_starters .slice(0, Constants.MAX_CONVO_STARTERS) .map((text: string, index: number) => ( diff --git a/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx b/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx new file mode 100644 index 000000000..be0dd4aef --- /dev/null +++ b/src/frontend/client/src/components/Chat/MessageFeedbackButtons.tsx @@ -0,0 +1,116 @@ +/** + * Shared 点赞/点踩 (thumbs up / down) feedback control. + * + * Reused by every AI answer surface (daily chat, knowledge-space 知源, channel + * subscription via AiMessageBubble; linsight task mode via ResultPanel; appChat + * workflow/assistant via MessageButtons). The button visuals match the + * AiMessageBubble action row (size-6 hit area, 14px bisheng-icons Outlined + * glyph, #818181 idle / brand-500 active) so the whole action row reads as one + * consistent set. + * + * Dislike is deferred: clicking thumbs-down only opens the reason dialog + * (shared shell: ui/CommentDialog, which resets the draft on open) — + * nothing is persisted or highlighted until the user hits submit (the comment + * itself is optional). Cancel/close discards the dislike entirely. Thumbs-up + * and un-toggling persist immediately. `liked` seeds the initial highlight and + * re-syncs when history reload delivers the stored value. + */ +import { useEffect, useState } from "react"; +import { Outlined } from "bisheng-icons"; +import { CommentDialog } from "~/components"; +import { useLocalize } from "~/hooks"; +import { cn } from "~/utils"; + +// 0 = unrated / 1 = thumbs up / 2 = thumbs down (mirrors chatmessage.liked) +type ThumbsState = 0 | 1 | 2; + +const ACTION_BTN = + "flex size-6 items-center justify-center rounded-[6px] transition-colors hover:bg-[#F7F7F7]"; + +interface MessageFeedbackButtonsProps { + /** Initial / persisted verdict: 0 none, 1 up, 2 down. */ + liked?: number; + /** Persist the new verdict (0/1/2). Dislike is only sent on dialog submit. */ + onLike: (liked: number) => void; + /** Persist the free-text reason when the user submits a non-empty dislike comment. */ + onDislikeComment?: (comment: string) => void; + className?: string; +} + +export function MessageFeedbackButtons({ + liked = 0, + onLike, + onDislikeComment, + className, +}: MessageFeedbackButtonsProps) { + const localize = useLocalize(); + const [state, setState] = useState(liked as ThumbsState); + const [commentOpen, setCommentOpen] = useState(false); + + // Re-sync when the persisted value arrives/changes (e.g. history reload). + useEffect(() => { + setState(liked as ThumbsState); + }, [liked]); + + const handleClick = (type: ThumbsState) => { + // Newly disliking with a reason dialog available: defer — no persist, + // no highlight until the dialog is submitted. + if (type === 2 && state !== 2 && onDislikeComment) { + setCommentOpen(true); + return; + } + const next: ThumbsState = state === type ? 0 : type; + setState(next); + onLike(next); + }; + + const handleSubmitComment = (comment: string) => { + setState(2); + onLike(2); + if (comment) onDislikeComment?.(comment); + setCommentOpen(false); + }; + + return ( + <> +
+ + +
+ + {onDislikeComment && ( + + )} + + ); +} diff --git a/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx b/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx index 34ddfbc4d..52b89b626 100644 --- a/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx @@ -10,7 +10,7 @@ */ import { ArrowRight, Check, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { Outlined } from 'bisheng-icons'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Textarea } from '~/components/ui'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -168,6 +168,19 @@ function ClarifyCardInteractive({ data, disabled = false, onSubmit }: ClarifyCar return () => window.removeEventListener('keydown', onKey); }, []); + // The custom-answer -
- - -
-
- - -}); - -export default MessageFeedbackForm; diff --git a/src/frontend/client/src/pages/apps/explore.tsx b/src/frontend/client/src/pages/apps/explore.tsx index c4f5edb26..43dfc6fb2 100644 --- a/src/frontend/client/src/pages/apps/explore.tsx +++ b/src/frontend/client/src/pages/apps/explore.tsx @@ -157,12 +157,13 @@ export default function ExplorePlaza() {
's + // overflow-y-auto becomes the scroller (also gives empty/loading states a + // real height to center against). + 'max-[767px]:h-[100dvh]', )} > {/* 顶部横幅:与知识广场一致 — 跟随主题的品牌色渐变底(brand-50 → white) */} diff --git a/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.test.tsx b/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.test.tsx deleted file mode 100644 index 0dee45b20..000000000 --- a/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.test.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { useState } from "react"; -import { KnowledgeSpacePreviewDrawer } from "./KnowledgeSpacePreviewDrawer"; -import { SpaceRole, VisibilityType, getJoinedSpacesApi, getSpaceChildrenApi, getSpaceInfoApi, subscribeSpaceApi } from "~/api/knowledge"; - -jest.mock("~/Providers", () => ({ - useToastContext: () => ({ - showToast: jest.fn(), - }), -})); - -jest.mock("~/hooks", () => ({ - useLocalize: () => (key: string) => { - const dict: Record = { - "com_knowledge.loading": "加载中", - "com_knowledge.join": "加入", - "com_knowledge.joined": "已加入", - "com_knowledge.space_invalid_or_deleted": "该知识空间已失效或被删除", - "com_knowledge.collapse_drawer": "收起", - "com_knowledge.close": "关闭", - "com_knowledge.articles_count": "篇内容", - "com_knowledge.users_count": "用户", - "com_knowledge.space_view_requires_join": "加入后可查看详情", - "com_knowledge.exit_space_short": "退出空间", - "com_knowledge.withdraw_application": "撤回申请", - "com_knowledge.reapply": "重新申请", - }; - return dict[key] || key; - }, - usePrefersMobileLayout: () => false, -})); - -jest.mock("./SpaceDetail/FileCard", () => ({ - FileCard: () =>
, -})); - -jest.mock("~/components/ui/Sheet", () => ({ - Sheet: ({ open, children }: any) => (open ?
{children}
: null), - SheetContent: ({ children }: any) =>
{children}
, - SheetHeader: ({ children }: any) =>
{children}
, - SheetTitle: ({ children }: any) =>
{children}
, -})); - -jest.mock("~/components/ui/Tooltip2", () => ({ - Tooltip: ({ children }: any) => <>{children}, - TooltipTrigger: ({ children }: any) => <>{children}, - TooltipContent: ({ children }: any) =>
{children}
, -})); - -jest.mock("~/components/ui/Button", () => ({ - Button: ({ children, ...props }: any) => , -})); - -jest.mock("~/api/knowledge", () => ({ - SpaceRole: { - CREATOR: "creator", - ADMIN: "admin", - MEMBER: "member", - }, - VisibilityType: { - PUBLIC: "public", - PRIVATE: "private", - APPROVAL: "approval", - }, - SPACE_CHILDREN_STATUS_SUCCESS_ONLY: [2], - getJoinedSpacesApi: jest.fn(), - getSpaceChildrenApi: jest.fn(), - getSpaceInfoApi: jest.fn(), - subscribeSpaceApi: jest.fn(), - unsubscribeSpaceApi: jest.fn(), -})); - -describe("KnowledgeSpacePreviewDrawer", () => { - test("keeps fallback detail visible for unjoined square spaces when info fetch is denied", async () => { - const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi); - const mockedGetSpaceChildrenApi = jest.mocked(getSpaceChildrenApi); - mockedGetSpaceChildrenApi.mockResolvedValue({ data: [], total: 0 }); - mockedGetSpaceInfoApi.mockRejectedValue(new Error("permission denied")); - - const baseSpace = { - id: "space-1", - name: "未加入空间", - description: "这是广场卡片上的摘要", - icon: "", - visibility: VisibilityType.PUBLIC, - creator: "Zhou", - creatorId: "u-1", - memberCount: 3, - fileCount: 8, - totalFileCount: 8, - role: SpaceRole.MEMBER, - isPinned: false, - createdAt: "", - updatedAt: "", - tags: [], - isReleased: true, - isFollowed: false, - isPending: false, - }; - - function Wrapper() { - const [statusMap, setStatusMap] = useState>({}); - - return ( - undefined} - onSquareStatusChange={(id, status) => { - setStatusMap((prev) => ({ - ...prev, - [id]: status, - })); - }} - /> - ); - } - - render(); - - await waitFor(() => { - expect(screen.getByText("未加入空间")).toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getAllByText("这是广场卡片上的摘要").length).toBeGreaterThan(0); - }); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(mockedGetSpaceInfoApi).toHaveBeenCalledTimes(1); - }); - - test("loads files for unjoined public square spaces", async () => { - const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi); - const mockedGetSpaceChildrenApi = jest.mocked(getSpaceChildrenApi); - const publicSpace = { - id: "space-public", - name: "公开空间", - description: "公开可浏览", - icon: "", - visibility: VisibilityType.PUBLIC, - creator: "Zhou", - creatorId: "u-1", - memberCount: 3, - fileCount: 1, - totalFileCount: 1, - role: SpaceRole.MEMBER, - isPinned: false, - createdAt: "", - updatedAt: "", - tags: [], - isReleased: true, - isFollowed: false, - isPending: false, - }; - - mockedGetSpaceInfoApi.mockResolvedValue(publicSpace as any); - mockedGetSpaceChildrenApi.mockResolvedValue({ - data: [ - { - id: "file-1", - name: "公开文件.pdf", - type: "pdf", - tags: [], - path: "公开文件.pdf", - spaceId: "space-public", - createdAt: "", - updatedAt: "", - }, - ], - total: 1, - } as any); - - render( - undefined} - /> - ); - - await waitFor(() => { - expect(mockedGetSpaceChildrenApi).toHaveBeenCalledWith( - expect.objectContaining({ - space_id: "space-public", - file_status: [2], - }) - ); - expect(screen.getByTestId("file-card")).toBeInTheDocument(); - }); - - expect(screen.queryByText("加入后可查看详情")).not.toBeInTheDocument(); - }); - - test("allows reapplying from rejected preview state", async () => { - const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi); - const mockedGetJoinedSpacesApi = jest.mocked(getJoinedSpacesApi); - const mockedSubscribeSpaceApi = jest.mocked(subscribeSpaceApi); - const rejectedSpace = { - id: "space-2", - name: "审批空间", - description: "需要审批", - icon: "", - visibility: VisibilityType.APPROVAL, - creator: "Zhou", - creatorId: "u-1", - memberCount: 3, - fileCount: 8, - totalFileCount: 8, - role: SpaceRole.MEMBER, - isPinned: false, - createdAt: "", - updatedAt: "", - tags: [], - isReleased: true, - isFollowed: false, - isPending: false, - subscriptionStatus: "rejected", - }; - - mockedGetSpaceInfoApi.mockResolvedValue(rejectedSpace as any); - mockedGetJoinedSpacesApi.mockResolvedValue([]); - mockedSubscribeSpaceApi.mockResolvedValue({ status: "pending", spaceId: "space-2" }); - - render( - undefined} - /> - ); - - fireEvent.click(await screen.findByRole("button", { name: "重新申请" })); - - await waitFor(() => { - expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-2"); - }); - }); -}); diff --git a/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.tsx b/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.tsx index 38a5b6e4c..512ad4fb7 100644 --- a/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.tsx +++ b/src/frontend/client/src/pages/knowledge/KnowledgeSpacePreviewDrawer.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { NoPermissionIllustration } from "~/components/illustrations"; +import { EmptyStateIllustration, NoPermissionIllustration } from "~/components/illustrations"; import { ChevronRight, X } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/Sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/Tooltip2"; @@ -21,6 +21,7 @@ import { } from "~/api/knowledge"; import { checkPermission } from "~/api/permission"; import { cn } from "~/utils"; +import { LoadingIcon } from "~/components/ui/icon/Loading"; import { useLocalize, usePrefersMobileLayout, useScrollRevealRef } from "~/hooks"; import { useEffectiveQuota } from "~/hooks/useEffectiveQuota"; @@ -58,6 +59,12 @@ export function KnowledgeSpacePreviewDrawer({ const [childrenPage, setChildrenPage] = useState(1); const [childrenTotal, setChildrenTotal] = useState(0); const [loadingChildrenMore, setLoadingChildrenMore] = useState(false); + // True while the FIRST page of the file list is in flight, so the empty + // state is not flashed before data arrives. + const [loadingFiles, setLoadingFiles] = useState(false); + // Guards against out-of-order responses when the effect re-fires (space / + // folder switch) while a previous first-page request is still pending. + const filesRequestSeqRef = useRef(0); // F027: cursor for the next page of `getSpaceChildrenApi`. null on first // page (and after a parent/space switch). Backend `next_cursor` advances // it as the user scrolls. @@ -197,17 +204,21 @@ export function KnowledgeSpacePreviewDrawer({ // Load file preview list for spaces that are visible to the current user useEffect(() => { if (!space || !canViewFiles) { + filesRequestSeqRef.current += 1; setFilesPreview([]); setChildrenTotal(0); setChildrenPage(1); + setLoadingFiles(false); return; } // Reset + initial load + const requestSeq = ++filesRequestSeqRef.current; setFilesPreview([]); setChildrenPage(1); setChildrenTotal(0); setLoadingChildrenMore(false); + setLoadingFiles(true); // F027: reset cursor so the first request after a parent/space switch // fetches page 1 (cursor=null) instead of inheriting a stale token. preview_next_cursor_ref.current = null; @@ -221,6 +232,7 @@ export function KnowledgeSpacePreviewDrawer({ ...(fileStatusFilter ? { file_status: fileStatusFilter } : {}), }) .then(res => { + if (requestSeq !== filesRequestSeqRef.current) return; setFilesPreview(res.data); // F027: derive a count surrogate from `has_more` since `total` // is gone (used only to decide "load more" visibility). @@ -228,8 +240,13 @@ export function KnowledgeSpacePreviewDrawer({ preview_next_cursor_ref.current = res.next_cursor ?? null; }) .catch(() => { + if (requestSeq !== filesRequestSeqRef.current) return; setFilesPreview([]); setChildrenTotal(0); + }) + .finally(() => { + if (requestSeq !== filesRequestSeqRef.current) return; + setLoadingFiles(false); }); // Include join/subscription signals so file list loads when async info maps to joined without subscription_status. // canViewApprovalContent is resolved asynchronously (checkPermission) for APPROVAL spaces; without it as a @@ -511,7 +528,7 @@ export function KnowledgeSpacePreviewDrawer({ }} > {canViewFiles ? ( -
+
-
+
{/* Tags Input Box */}
document.getElementById("tag-input")?.focus()} > {selectedTags.map((tag) => ( @@ -261,6 +261,7 @@ export function EditTagsModal({ setInputValue(e.target.value)} onKeyDown={handleKeyDown} @@ -269,7 +270,7 @@ export function EditTagsModal({ ? localize("com_knowledge.input_tags_placeholder") : "" } - className="flex-1 min-w-[120px] bg-transparent outline-none text-sm leading-[22px] text-[#212121] placeholder-[#86909c] min-h-[22px]" + className="flex-1 min-w-[120px] bg-transparent outline-none text-sm leading-[22px] text-[#212121] placeholder-[#999] min-h-[22px]" // maxLength={8} /> @@ -284,7 +285,7 @@ export function EditTagsModal({
{localize("com_knowledge.existing_tags")}
{spaceTags.length === 0 && ( - {localize("com_knowledge.no_tags")} + {localize("com_knowledge.no_tags")} )} {spaceTags.map((tag) => { const isSelected = selectedTagIds.has(tag.id); @@ -318,16 +319,16 @@ export function EditTagsModal({
- + , - Checkbox: ({ checked, onCheckedChange }: any) => ( - , -})); - -const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels); - -describe("KnowledgeSpaceShareDialog", () => { - beforeEach(() => { - jest.clearAllMocks(); - mockedGetGrantableRelationModels.mockResolvedValue([ - { - id: "viewer", - name: "Viewer", - relation: "viewer", - permissions: [], - is_system: true, - }, - ]); - }); - - it("renders a single permission list tab instance for the active subject type", async () => { - render( - , - ); - - await waitFor(() => { - expect(mockedGetGrantableRelationModels).toHaveBeenCalledTimes(1); - }); - expect(screen.getAllByText("list:knowledge_space:space-59:user")).toHaveLength(1); - expect(screen.queryByText("list:knowledge_space:space-59:department")).not.toBeInTheDocument(); - expect(screen.queryByText("list:knowledge_space:space-59:user_group")).not.toBeInTheDocument(); - }); - - it("passes the include-children toggle state into the grant form", async () => { - render( - , - ); - - await waitFor(() => { - expect(mockedGetGrantableRelationModels).toHaveBeenCalledTimes(1); - }); - - const grantDepartmentTab = screen.getAllByRole("button", { - name: "com_permission.subject_department", - }).at(-1); - expect(grantDepartmentTab).toBeTruthy(); - fireEvent.click(grantDepartmentTab!); - expect(await screen.findByText("grant:knowledge_space:space-59:department:include")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("checkbox")); - expect(await screen.findByText("grant:knowledge_space:space-59:department:exclude")).toBeInTheDocument(); - }); - - it("can manage a file resource with the same grant dialog", async () => { - render( - , - ); - - await waitFor(() => { - expect(mockedGetGrantableRelationModels).toHaveBeenCalledWith("knowledge_file", "file-9"); - }); - expect(screen.getByText("list:knowledge_file:file-9:user")).toBeInTheDocument(); - expect(screen.getByText("grant:knowledge_file:file-9:user:include")).toBeInTheDocument(); - }); - - it("can manage a folder resource with the same grant dialog", async () => { - render( - , - ); - - await waitFor(() => { - expect(mockedGetGrantableRelationModels).toHaveBeenCalledWith("folder", "folder-9"); - }); - expect(screen.getByText("list:folder:folder-9:user")).toBeInTheDocument(); - expect(screen.getByText("grant:folder:folder-9:user:include")).toBeInTheDocument(); - }); -}); diff --git a/src/frontend/client/src/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog.tsx b/src/frontend/client/src/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog.tsx index 7257ba2b2..b0f4f0623 100644 --- a/src/frontend/client/src/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog.tsx +++ b/src/frontend/client/src/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog.tsx @@ -14,6 +14,8 @@ import { TabsTrigger, } from "~/components/ui"; import { useLocalize } from "~/hooks"; +import { useRecoilValue } from "recoil"; +import store from "~/store"; import { getGrantableRelationModels } from "~/api/permission"; import type { RelationModel, ResourceType } from "~/api/permission"; @@ -41,6 +43,7 @@ export function KnowledgeSpaceShareDialog({ isDepartmentSpace = false, }: KnowledgeSpaceShareDialogProps) { const localize = useLocalize(); + const currentUser = useRecoilValue(store.user); const [refreshKey, setRefreshKey] = useState(0); const [currentSubjectType, setCurrentSubjectType] = useState<"user" | "department" | "user_group">("user"); const [grantDialogOpen, setGrantDialogOpen] = useState(false); @@ -144,6 +147,7 @@ export function KnowledgeSpaceShareDialog({ resourceId={resourceId} refreshKey={refreshKey} fixedSubjectType={currentSubjectType} + currentUserId={currentUser?.id} prefetchedGrantableModels={grantableModels} prefetchedGrantableModelsLoaded={grantableModelsLoaded} prefetchedUseDefaultModels={useDefaultModels} diff --git a/src/frontend/client/src/pages/knowledge/SpaceDetail/MoveToDialog.test.tsx b/src/frontend/client/src/pages/knowledge/SpaceDetail/MoveToDialog.test.tsx deleted file mode 100644 index 11eb1ca76..000000000 --- a/src/frontend/client/src/pages/knowledge/SpaceDetail/MoveToDialog.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; - -import { - FileType, - SpaceRole, - VisibilityType, - getDepartmentSpacesApi, - getJoinedSpacesApi, - getMineSpacesApi, - getSpaceChildrenApi, - type KnowledgeSpace, -} from "~/api/knowledge"; -import { listUploadableSpacesApi } from "~/api/messageExport"; -import { MoveToDialog } from "./MoveToDialog"; - -jest.mock("bisheng-icons", () => ({ - Outlined: { - City: (props: any) => , - Down: (props: any) => , - File: (props: any) => , - FileImage: (props: any) => , - FolderClose: (props: any) => , - Notebook: (props: any) => , - Right: (props: any) => , - }, -})); - -jest.mock("lucide-react", () => ({ - Loader2: (props: any) => , -})); - -jest.mock("~/components/ui/Button", () => ({ - Button: ({ children, ...props }: any) => , -})); - -jest.mock("~/components/ui/Dialog", () => ({ - Dialog: ({ open, children }: any) => (open ?
{children}
: null), - DialogContent: ({ children }: any) =>
{children}
, - DialogFooter: ({ children }: any) =>
{children}
, - DialogHeader: ({ children }: any) =>
{children}
, - DialogTitle: ({ children }: any) =>

{children}

, -})); - -jest.mock("~/components/ui/ExpandableSearchField", () => ({ - ExpandableSearchField: ({ value, onChange, placeholder }: any) => ( - onChange(event.target.value)} /> - ), -})); - -jest.mock("~/hooks", () => ({ - useLocalize: () => (key: string) => key, -})); - -jest.mock("../hooks/useDynamicEllipsis", () => ({ - useDynamicEllipsis: jest.fn(), -})); - -jest.mock("../sidebar/DynamicEllipsisName", () => ({ - DynamicEllipsisName: ({ name, trailing }: any) => ( - - {name} - {trailing} - - ), -})); - -jest.mock("./MoveToFolderTree", () => ({ - MoveToFolderTree: () =>
, -})); - -jest.mock("~/api/messageExport", () => ({ - listUploadableSpacesApi: jest.fn(), -})); - -jest.mock("~/api/knowledge", () => ({ - FileType: { - FOLDER: "folder", - PDF: "pdf", - }, - SpaceRole: { - CREATOR: "creator", - ADMIN: "admin", - MEMBER: "member", - }, - VisibilityType: { - PUBLIC: "public", - PRIVATE: "private", - APPROVAL: "approval", - }, - SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED: [2], - getDepartmentSpacesApi: jest.fn(), - getJoinedSpacesApi: jest.fn(), - getMineSpacesApi: jest.fn(), - getSpaceChildrenApi: jest.fn(), -})); - -function makeSpace(id: string, name: string): KnowledgeSpace { - return { - id, - name, - description: "", - icon: "", - visibility: VisibilityType.PRIVATE, - creator: "tester", - creatorId: "user-1", - memberCount: 1, - fileCount: 0, - totalFileCount: 0, - role: SpaceRole.MEMBER, - isPinned: false, - createdAt: "", - updatedAt: "", - tags: [], - isReleased: true, - }; -} - -function renderDialog() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - return render( - - undefined} - currentSpaceId="current-space" - currentSpaceName="Current Space" - onConfirm={() => undefined} - /> - , - ); -} - -describe("MoveToDialog", () => { - test("shows children for the first visible uploadable space when current space is not uploadable", async () => { - const targetSpace = makeSpace("target-space", "Target Space"); - jest.mocked(listUploadableSpacesApi).mockResolvedValue([ - { id: targetSpace.id, name: targetSpace.name }, - ]); - jest.mocked(getDepartmentSpacesApi).mockResolvedValue([]); - jest.mocked(getMineSpacesApi).mockResolvedValue([makeSpace("current-space", "Current Space")]); - jest.mocked(getJoinedSpacesApi).mockResolvedValue([targetSpace]); - jest.mocked(getSpaceChildrenApi).mockResolvedValue({ - data: [ - { - id: "folder-1", - name: "Target Folder", - type: FileType.FOLDER, - tags: [], - path: "Target Folder", - spaceId: targetSpace.id, - createdAt: "", - updatedAt: "", - }, - ], - page_size: 200, - has_more: false, - next_cursor: null, - } as any); - - renderDialog(); - - expect(screen.getByText("com_knowledge.move_empty_folder")).toBeInTheDocument(); - - await waitFor(() => { - expect(getSpaceChildrenApi).toHaveBeenCalledWith( - expect.objectContaining({ - space_id: targetSpace.id, - file_status: [2], - }), - ); - }); - - expect(getSpaceChildrenApi).not.toHaveBeenCalledWith( - expect.objectContaining({ space_id: "current-space" }), - ); - expect(await screen.findByText("Target Folder")).toBeInTheDocument(); - }); -}); diff --git a/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.test.tsx b/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.test.tsx deleted file mode 100644 index d208cf230..000000000 --- a/src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { NotificationSeverity } from "~/common"; -import type { KnowledgeSpace } from "~/api/knowledge"; -import { SpaceRole, SpaceSortType, VisibilityType } from "~/api/knowledge"; -import { useSpaceActions } from "./useSpaceActions"; - -const mockShowToast = jest.fn(); -const mockUnsubscribeSpaceApi = jest.fn(); - -const ORGANIZATION_GRANT_MESSAGE = "本空间通过部门/用户组授权给你,暂无法退出"; - -jest.mock("~/hooks", () => ({ - useLocalize: () => (key: string) => { - const labels: Record = { - "com_knowledge.exit_space_failed": "退出空间失败", - "com_knowledge.exited_space": "已退出空间", - "com_knowledge.organization_grant_exit_blocked": ORGANIZATION_GRANT_MESSAGE, - }; - return labels[key] ?? key; - }, -})); - -jest.mock("~/Providers", () => ({ - useToastContext: () => ({ - showToast: mockShowToast, - }), -})); - -jest.mock("~/api/knowledge", () => ({ - SpaceRole: { - CREATOR: "creator", - ADMIN: "admin", - MEMBER: "member", - }, - SpaceSortType: { - NAME: "name", - UPDATE_TIME: "update_time", - }, - VisibilityType: { - PUBLIC: "public", - PRIVATE: "private", - APPROVAL: "approval", - }, - updateSpaceApi: jest.fn(), - deleteSpaceApi: jest.fn(), - unsubscribeSpaceApi: (...args: unknown[]) => mockUnsubscribeSpaceApi(...args), - pinSpaceApi: jest.fn(), -})); - -function createSpace(id = "space-1"): KnowledgeSpace { - return { - id, - name: "知识空间", - description: "用于回归测试", - icon: "", - visibility: VisibilityType.PUBLIC, - creator: "owner", - creatorId: "1", - memberCount: 3, - fileCount: 5, - totalFileCount: 5, - role: SpaceRole.MEMBER, - isPinned: false, - createdAt: "2026-05-28T00:00:00Z", - updatedAt: "2026-05-28T00:00:00Z", - tags: [], - isReleased: true, - }; -} - -describe("useSpaceActions leave", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - mockShowToast.mockClear(); - mockUnsubscribeSpaceApi.mockReset(); - }); - - function wrapper({ children }: { children: ReactNode }) { - return {children}; - } - - it("shows organization grant message and keeps joined state when leave is blocked", async () => { - const space = createSpace(); - const onSpaceSelect = jest.fn(); - queryClient.setQueryData(["knowledgeSpaces", "joined", SpaceSortType.UPDATE_TIME], [space]); - const invalidateQueriesSpy = jest.spyOn(queryClient, "invalidateQueries"); - mockUnsubscribeSpaceApi.mockResolvedValue({ - status_code: 18071, - }); - - const { result } = renderHook(() => useSpaceActions({ - activeSpaceId: space.id, - createdSortBy: SpaceSortType.UPDATE_TIME, - joinedSortBy: SpaceSortType.UPDATE_TIME, - departmentSortBy: SpaceSortType.UPDATE_TIME, - createdSpaces: [], - joinedSpaces: [space], - departmentSpaces: [], - onSpaceSelect, - }), { wrapper }); - - await act(async () => { - await result.current.handleLeaveSpace(space.id); - }); - - await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith({ - message: ORGANIZATION_GRANT_MESSAGE, - severity: NotificationSeverity.ERROR, - }); - }); - expect(mockShowToast).not.toHaveBeenCalledWith(expect.objectContaining({ - message: "已退出空间", - })); - expect(queryClient.getQueryData(["knowledgeSpaces", "joined", SpaceSortType.UPDATE_TIME])).toEqual([space]); - expect(onSpaceSelect).not.toHaveBeenCalledWith(null); - expect(mockUnsubscribeSpaceApi).toHaveBeenCalledWith(space.id); - expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({ - queryKey: ["knowledgeSpaces", "joined"], - }); - }); -}); diff --git a/src/frontend/client/src/pages/knowledge/index.tsx b/src/frontend/client/src/pages/knowledge/index.tsx index eb200bc80..7cffad7dc 100644 --- a/src/frontend/client/src/pages/knowledge/index.tsx +++ b/src/frontend/client/src/pages/knowledge/index.tsx @@ -638,7 +638,11 @@ export default function Knowledge() { // Knowledge square view if (showKnowledgeSquare) { return ( -
+ // Mobile: the MainLayout shell is h-auto/overflow-visible and html/body + // scrolling is globally disabled (WebView bottom-strip fix in index.html), + // so `h-full` collapses and the square's inner scroller never scrolls. + // Pin the wrapper to 100dvh on mobile so the inner overflow-y-auto works. +
{ setShowKnowledgeSquare(false); diff --git a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeFolderTree.tsx b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeFolderTree.tsx index e8c497339..69134cd3b 100644 --- a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeFolderTree.tsx +++ b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeFolderTree.tsx @@ -60,6 +60,19 @@ function collectExpandedIds(nodes: TreeNode[], acc: Set): Set { return acc; } +/** Find a node anywhere in the tree by id, searching into loaded (but possibly + * collapsed) children too — a node's `children` array persists after collapse. */ +function findNode(nodes: TreeNode[], id: number): TreeNode | undefined { + for (const n of nodes) { + if (n.id === id) return n; + if (Array.isArray(n.children)) { + const found = findNode(n.children, id); + if (found) return found; + } + } + return undefined; +} + // ─── Single node row ────────────────────────────────────────────────────────── interface TreeNodeRowProps { @@ -166,6 +179,11 @@ export function KnowledgeFolderTree({ }: KnowledgeFolderTreeProps) { const [roots, setRoots] = useState([]); const [rootLoading, setRootLoading] = useState(false); + // True once the root list for the current (knowledgeId, fileStatus) has + // finished loading. Gates the deep-link effect below — unlike rootLoading + // (whose initial `false` would let that effect run against an empty tree + // on mount), this only flips true after real data is in. + const [rootsReady, setRootsReady] = useState(false); // Mirror the latest tree into a ref so refreshTree can read it without // becoming a new function on every state change. @@ -175,59 +193,31 @@ export function KnowledgeFolderTree({ }, [roots]); // Load root folders on mount or when knowledgeId / fileStatus changes. - // If a folder is currently selected (currentFolderId set), also fetch its - // ancestor chain and pre-expand every ancestor so the selected folder is - // visible without the user having to re-expand the tree manually after - // collapse → expand of the parent space. + // Deliberately independent of currentFolderId: selecting a folder (click or + // route change) must never rebuild the tree. The deep-link effect below + // handles the one case where the selected folder isn't in the tree yet. useEffect(() => { if (!knowledgeId) return; let cancelled = false; setRootLoading(true); + setRootsReady(false); (async () => { try { const { items } = await listKnowledgeFolders({ space_id: knowledgeId, parent_id: null, file_status: fileStatus, }); - if (cancelled) return; - let tree = mapToTree(items); - - if (currentFolderId) { - try { - const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId); - if (!cancelled && parentPath?.length > 0) { - const ancestorIds = new Set(parentPath.map(p => Number(p.id))); - // Walk the tree; for each ancestor, fetch its children - // and recurse so deeper ancestors also get expanded. - const expandChain = async (nodes: TreeNode[]): Promise => { - return Promise.all(nodes.map(async (n) => { - if (!ancestorIds.has(n.id)) return n; - try { - const { items: kids } = await listKnowledgeFolders({ - space_id: knowledgeId, parent_id: n.id, file_status: fileStatus, - }); - const children = await expandChain(mapToTree(kids)); - return { ...n, expanded: true, loading: false, children }; - } catch { - return { ...n, expanded: true, loading: false, children: [] }; - } - })); - }; - tree = await expandChain(tree); - } - } catch { - // ignore — fall through with collapsed tree - } - } - - if (!cancelled) setRoots(tree); + if (!cancelled) setRoots(mapToTree(items)); } catch { if (!cancelled) setRoots([]); } finally { - if (!cancelled) setRootLoading(false); + if (!cancelled) { + setRootLoading(false); + setRootsReady(true); + } } })(); return () => { cancelled = true; }; - }, [knowledgeId, fileStatus, currentFolderId]); + }, [knowledgeId, fileStatus]); /** Immutably update a node anywhere in the tree by id. */ const updateNode = useCallback(( @@ -244,12 +234,14 @@ export function KnowledgeFolderTree({ }); }, []); - const handleExpand = useCallback((node: TreeNode) => { - // Toggle collapse if already expanded - if (node.expanded) { - setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false }))); - return; - } + /** + * Expand a node without ever collapsing it. If its children were loaded + * before (even while collapsed) this is a pure state toggle — no request; + * only a never-loaded node fetches its own children (one level, same as + * the expand arrow). Shared by the arrow and by folder-row clicks. + */ + const ensureExpanded = useCallback((node: TreeNode) => { + if (node.expanded) return; // If children already loaded, just toggle open if (Array.isArray(node.children)) { @@ -282,10 +274,84 @@ export function KnowledgeFolderTree({ }); }, [knowledgeId, fileStatus, updateNode]); + const handleExpand = useCallback((node: TreeNode) => { + // Arrow keeps toggle semantics: collapse if already expanded. + if (node.expanded) { + setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false }))); + return; + } + ensureExpanded(node); + }, [ensureExpanded, updateNode]); + + // Clicking a folder row only selects it (route + highlight) — it never + // expands/collapses children; that is exclusively the arrow's job. No tree + // reload either: the row is already rendered, so its data is already local, + // and the highlight follows the currentFolderId prop on re-render. const handleSelect = useCallback((node: TreeNode) => { onSelectFolder({ id: String(node.id), name: node.name }); }, [onSelectFolder]); + // Deep-link catch-up: when currentFolderId points at a folder that is NOT in + // the local tree (direct URL visit, breadcrumb jump into a never-expanded + // branch), fetch its ancestor chain and expand just the missing levels. + // Folder-row clicks never enter here — a clickable row is already in the tree, + // so findNode succeeds and this effect exits with zero requests. + const handledDeepLinkRef = useRef(null); + useEffect(() => { + // Wait for the root list to be genuinely loaded — rootLoading's initial + // `false` on mount would otherwise let this run against an empty tree. + if (!knowledgeId || !currentFolderId || !rootsReady) return; + if (findNode(rootsRef.current, Number(currentFolderId))) return; + // One attempt per folder id — if the chain fetch fails (or the id is + // stale/deleted), don't refetch on every roots change. + if (handledDeepLinkRef.current === currentFolderId) return; + handledDeepLinkRef.current = currentFolderId; + + let cancelled = false; + let completed = false; + (async () => { + try { + const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId); + if (cancelled || !parentPath?.length) return; + const ancestorIds = new Set(parentPath.map((p) => Number(p.id))); + // Walk the current tree along the ancestor chain, reusing children + // that are already loaded and fetching only the missing levels. + const expandChain = async (nodes: TreeNode[]): Promise => { + return Promise.all(nodes.map(async (n) => { + if (!ancestorIds.has(n.id)) return n; + try { + let children = n.children; + if (!Array.isArray(children)) { + const { items } = await listKnowledgeFolders({ + space_id: knowledgeId, parent_id: n.id, file_status: fileStatus, + }); + children = mapToTree(items); + } + return { ...n, expanded: true, loading: false, children: await expandChain(children) }; + } catch { + return { ...n, expanded: true, loading: false, children: n.children ?? [] }; + } + })); + }; + const fresh = await expandChain(rootsRef.current); + if (!cancelled) setRoots(fresh); + } catch { + // ignore — leave the tree as is; the user can expand manually. + } finally { + completed = true; + } + })(); + return () => { + cancelled = true; + // A run cancelled mid-flight (deps changed, StrictMode double-mount) + // didn't actually expand anything — release the once-per-id mark so + // the next run for this folder id can try again. + if (!completed && handledDeepLinkRef.current === currentFolderId) { + handledDeepLinkRef.current = null; + } + }; + }, [knowledgeId, fileStatus, currentFolderId, rootsReady]); + // Re-fetch a freshly-loaded subtree, re-expanding nodes that were open before. const rebuildWithExpansion = useCallback(async ( nodes: TreeNode[], diff --git a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceCardItem.tsx b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceCardItem.tsx index 78080f832..368864dfd 100644 --- a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceCardItem.tsx +++ b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceCardItem.tsx @@ -18,6 +18,7 @@ import { } from "~/components/SidebarListMoreMenu"; import { useConfirm, useToastContext } from "~/Providers"; import { useLocalize } from "~/hooks"; +import { formatTimeCard } from "../knowledgeUtils"; interface KnowledgeSpaceCardItemProps { space: KnowledgeSpace; @@ -61,8 +62,6 @@ export default function KnowledgeSpaceCardItem({ const { showToast } = useToastContext(); const confirm = useConfirm(); - const itemCount = space.totalFileCount ?? space.fileCount ?? 0; - return (
) : null}
-
- {localize("com_knowledge_items_count", { count: itemCount })} -
+ {space.updatedAt && ( +
+ {formatTimeCard(space.updatedAt)} +
+ )}
{ setMenuOpen(open); if (open) onMenuOpen?.(); }}> diff --git a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx index 7c20ace5e..1c44d27af 100644 --- a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx +++ b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx @@ -1,5 +1,5 @@ import { Outlined } from "bisheng-icons"; -import { useEffect, useState, type MouseEvent } from "react"; +import { useState, type MouseEvent } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { KnowledgeSpace, SpaceRole, SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED } from "~/api/knowledge"; import { @@ -66,6 +66,9 @@ export default function KnowledgeSpaceItem({ }: KnowledgeSpaceItemProps) { const localize = useLocalize(); const [menuOpen, setMenuOpen] = useState(false); + // Initial expansion mirrors the mount-time active state (deep link / reload + // restores the tree). After mount, clicking the space row only selects it — + // expansion is exclusively the chevron's job (same rule as folder rows). const [expanded, setExpanded] = useState(isActive); // Right-click context menu mirrors the "..." action menu, positioned at the cursor. const [contextMenuOpen, setContextMenuOpen] = useState(false); @@ -78,11 +81,6 @@ export default function KnowledgeSpaceItem({ const treeEnabled = bsConfig?.knowledge_space?.tree_structured_directory_display ?? true; - // Auto-expand when this space becomes active - useEffect(() => { - if (isActive) setExpanded(true); - }, [isActive]); - // Only highlight the space row when this space is active AND no folder // inside it is selected — folders take over the active styling once chosen // so only one row in the tree appears active at a time. diff --git a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx index e47f9a3f1..98fa6a8e9 100644 --- a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx +++ b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx @@ -177,6 +177,12 @@ export function KnowledgeSpaceSidebar({ const departmentSpaceIds = new Set(departmentSpaces.map(s => s.id)); const filteredCreatedSpaces = createdSpaces.filter(s => !departmentSpaceIds.has(s.id)); const filteredJoinedSpaces = joinedSpaces.filter(s => !departmentSpaceIds.has(s.id)); + // An empty created/joined section stretches over the remaining list height + // and centers its empty-state text (both empty → 50/50 split). Compact + // dropdown keeps natural heights — it is a popover sized to content. + const createdEmpty = !filteredCreatedSpaces.length; + const joinedEmpty = !filteredJoinedSpaces.length; + const stretchEmptySections = (createdEmpty || joinedEmpty) && !compactMode; const permissionSpaceIds = useMemo( () => Array.from(new Set([ ...departmentSpaces.map(s => s.id), @@ -370,7 +376,7 @@ export function KnowledgeSpaceSidebar({ ) : null} {/* Top actions */}
{mobileDrawerMode ? ( @@ -418,7 +424,7 @@ export function KnowledgeSpaceSidebar({ scroll container's visible width — that lets sticky-left/top keep them pinned to the viewport edges even while items horizontally overflow. */} -
+
{compactMode ? ( /* File-page title dropdown: same 3-section tree as the PC sidebar, ordered 部门 → 我创建的 → 我加入的. Per-row "..." menus stay hidden @@ -460,7 +466,7 @@ export function KnowledgeSpaceSidebar({
{filteredCreatedSpaces.map(s => renderCompactItem(s, "created"))} {!filteredCreatedSpaces.length && ( -
{localize("com_knowledge.no_data")}
+
{localize("com_knowledge.no_data")}
)}
)} @@ -482,7 +488,7 @@ export function KnowledgeSpaceSidebar({
{filteredJoinedSpaces.map(s => renderCompactItem(s, "joined"))} {!filteredJoinedSpaces.length && ( -
{localize("com_knowledge.no_data")}
+
{localize("com_knowledge.no_data")}
)}
)} @@ -512,7 +518,7 @@ export function KnowledgeSpaceSidebar({ )} {/* My created */} -
+
{!createdCollapsed && ( -
+
{filteredCreatedSpaces.map(s => renderSpaceItem(s, "created"))} - {!filteredCreatedSpaces.length &&
{localize("com_knowledge.no_data")}
} + {createdEmpty && ( +
{localize("com_knowledge.no_data")}
+ )}
)}
{/* Joined */} -
+
{!joinedCollapsed && ( -
+
{filteredJoinedSpaces.map(s => renderSpaceItem(s, "joined"))} - {!filteredJoinedSpaces.length &&
{localize("com_knowledge.no_data")}
} + {joinedEmpty && ( +
{localize("com_knowledge.no_data")}
+ )}
)}
diff --git a/src/frontend/client/src/pages/standaloneChat/components/GuestConvoItem.tsx b/src/frontend/client/src/pages/standaloneChat/components/GuestConvoItem.tsx index bdedf4f0c..4d6dbd59c 100644 --- a/src/frontend/client/src/pages/standaloneChat/components/GuestConvoItem.tsx +++ b/src/frontend/client/src/pages/standaloneChat/components/GuestConvoItem.tsx @@ -7,8 +7,8 @@ import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import type { AppConversation } from '~/@types/app'; -import { DropdownPopup, OGDialog, Label } from '~/components'; -import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; +import { DropdownPopup } from '~/components'; +import { useConfirm } from '~/Providers'; import TodayItemIcon from '~/components/ui/icon/TodayItem'; type GuestConvoItemProps = { @@ -29,7 +29,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }: const [isPopoverActive, setIsPopoverActive] = useState(false); const [renaming, setRenaming] = useState(false); const [titleInput, setTitleInput] = useState(conv.title); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const confirm = useConfirm(); const inputRef = useRef(null); const menuId = useId(); @@ -83,10 +83,18 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }: [conv.title], ); - const confirmDelete = useCallback(() => { + const handleDeleteClick = useCallback(async () => { + const ok = await confirm({ + variant: 'destructive', + title: localize('com_ui_delete_conversation'), + description: `${localize('com_ui_delete_confirm')} "${conv.title}"`, + confirmText: localize('com_ui_delete'), + }); + if (!ok) { + return; + } onDelete(conv.id); - setShowDeleteDialog(false); - }, [conv.id, onDelete]); + }, [confirm, localize, conv.title, conv.id, onDelete]); return (
{ - if (renaming || isPopoverActive || showDeleteDialog) return; + if (renaming || isPopoverActive) return; onClick(); }} > @@ -174,7 +182,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }: label: localize('com_ui_delete'), onClick: () => { setIsPopoverActive(false); - setShowDeleteDialog(true); + handleDeleteClick(); }, icon: , hideOnClick: false, @@ -184,31 +192,6 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }: ]} menuId={menuId} /> - - {/* Delete confirmation dialog */} - {showDeleteDialog && ( - - -
- -
-
- } - selection={{ - selectHandler: confirmDelete, - selectClasses: 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> - - )} )}
diff --git a/src/frontend/client/src/store/linsight.ts b/src/frontend/client/src/store/linsight.ts index 1fb35b9f9..189f2fb08 100644 --- a/src/frontend/client/src/store/linsight.ts +++ b/src/frontend/client/src/store/linsight.ts @@ -52,6 +52,12 @@ export type LinsightInfo = { }[]; output_result: null | any; score: null | number; + // like/dislike on the task result. The result is a category="task" ChatMessage; + // `message_id` is that row's id (the feedback target) and `liked` its verdict + // (0 none / 1 up / 2 down). Both come enriched on the session-version list + // (snake_case so they pass through the raw `...version` spread into the store). + message_id?: string | null; + liked?: null | number; has_reexecute: boolean; id: string; update_time: string; diff --git a/src/frontend/client/src/types/chat/config.ts b/src/frontend/client/src/types/chat/config.ts index e2aa97960..71977443d 100644 --- a/src/frontend/client/src/types/chat/config.ts +++ b/src/frontend/client/src/types/chat/config.ts @@ -522,6 +522,8 @@ export type BsConfig = { id: string; name: string; displayName: string; + /** Optional admin-configured intro shown under the name in model pickers. */ + description?: string; }>; voiceInput: { enabled: boolean; diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index 9b118da41..46037737d 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -1215,6 +1215,8 @@ "knowledgeSpace": "KnowledgeSpace", "model": "Model", "displayName": "Display Name", + "modelDescription": "Description", + "modelDescriptionPlaceholder": "Shown in the model picker", "vision": "Vision", "visionText": "When enabled, the model will answer based on image content (supports PNG, JPEG, WEBP, non-animated GIF formats). Note that only multimodal models support this capability.", "webSearchPrompt": "Web Search Prompt", diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index 167a14ef1..6bf6ef5e3 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -1198,6 +1198,8 @@ "knowledgeSpace": "知識空間", "model": "モデル", "displayName": "表示名", + "modelDescription": "説明", + "modelDescriptionPlaceholder": "モデル選択に表示されます", "vision": "画像", "visionText": "有効にすると、モデルは画像コンテンツ(PNG、JPEG、WEBP、非アニメーションGIF形式をサポート)を組み合わせて回答します。この機能はマルチモーダルモデルのみでサポートされています。", "webSearchPrompt": "Web 検索プロンプト", diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index a1254eb9b..524e3b11d 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -1204,6 +1204,8 @@ "knowledgeSpace": "知识空间", "model": "模型", "displayName": "显示名称", + "modelDescription": "描述信息", + "modelDescriptionPlaceholder": "将展示在模型选择器中", "vision": "视觉", "visionText": "开启后,模型将结合图像内容(支持PNG、JPEG、WEBP、非动画GIF格式)进行回答,注意仅多模态模型支持此能力。", "webSearchPrompt": "联网搜索提示词", diff --git a/src/frontend/platform/src/pages/BuildPage/bench/ModelManagement.tsx b/src/frontend/platform/src/pages/BuildPage/bench/ModelManagement.tsx index d8a275185..6a83f9c54 100644 --- a/src/frontend/platform/src/pages/BuildPage/bench/ModelManagement.tsx +++ b/src/frontend/platform/src/pages/BuildPage/bench/ModelManagement.tsx @@ -13,11 +13,16 @@ import { forwardRef } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +// Keep in sync with backend WSModel (bisheng/api/v1/schemas.py). +export const MODEL_DESCRIPTION_MAX_LENGTH = 50; + export interface Model { key: string; id: string; name: string; displayName: string; + /** Optional one-line intro shown in the workspace model picker. */ + description?: string; visual?: boolean; } interface ModelManagementProps { @@ -28,13 +33,14 @@ interface ModelManagementProps { onRemove: (index: number) => void; onModelChange: (index: number, id: string) => void; onNameChange: (index: number, name: string) => void; + onDescriptionChange?: (index: number, description: string) => void; onVisualToggle?: (index: number, enabled: boolean) => void; /** Linsight default model: the model id used as the default executor for Linsight tasks. */ linsightDefaultModelId?: string | null; onLinsightDefaultChange?: (id: string) => void; } export const ModelManagement = forwardRef( - ({ models, errors, error, onAdd, onRemove, onModelChange, onNameChange, onVisualToggle, linsightDefaultModelId, onLinsightDefaultChange }, ref) => { + ({ models, errors, error, onAdd, onRemove, onModelChange, onNameChange, onDescriptionChange, onVisualToggle, linsightDefaultModelId, onLinsightDefaultChange }, ref) => { // `assistant` mode hits /api/v1/llm/assistant/llm_list which is already // filtered to the admin-configured assistant allowlist (default model // and its server are placed first). Avoids the fetch-all + client-side @@ -43,6 +49,17 @@ export const ModelManagement = forwardRef { + if (!id) return ''; + for (const group of assistantLlmOptions) { + const hit = group.children?.find((el) => el.value == id); + if (hit) return hit.label; + } + return ''; + }; + const selectFooter = (
-
+
+
+ +
@@ -97,7 +117,7 @@ export const ModelManagement = forwardRef setItemRef(el, index)} className="grid items-center mb-4" - style={{ gridTemplateColumns: "1.35fr 1fr 72px 116px 36px" }} + style={{ gridTemplateColumns: "1.2fr 0.85fr 1.3fr 72px 116px 36px" }} >
{assistantLlmOptions.length > 0 ? ( @@ -125,10 +145,18 @@ export const ModelManagement = forwardRef onNameChange(index, e.target.value)} - placeholder={t('bench.displayName')} + placeholder={getModelLabel(model.id) || t('bench.displayName')} /> {errors[model.key] &&

{errors[model.key]?.[1]}

}
+
+ onDescriptionChange?.(index, e.target.value)} + placeholder={t('bench.modelDescriptionPlaceholder')} + /> +
{ + if (!id) return ''; + for (const group of llmOptions) { + const hit = group.children?.find((el) => el.value == id); + if (hit) return hit.label; + } + return ''; + }; + const handleSave = async () => { const { linsightDefaultModelId, sourceModelId, asrModelId, ttsModelId, chatTitleLlmId, models } = form; const errors = []; @@ -93,7 +104,9 @@ export default function WorkbenchModel({ onBack }) { setSaveLoad(true); try { const data = { - models, + // Empty displayName falls back to the model's own name so the + // workspace model picker never renders a blank option. + models: models.map((m) => m.displayName ? m : { ...m, displayName: findModelLabel(m.id) }), embedding_model: { id: String(sourceModelId) }, // Linsight default executor model: one of the workbench chat models' id. linsight_default_model_id: linsightDefaultModelId ? String(linsightDefaultModelId) : null, @@ -205,7 +218,7 @@ export default function WorkbenchModel({ onBack }) { const inheritedFromRoot = !!linsightConfig?.inherited_from_root; const fallbackBlocked = !!linsightConfig?.fallback_blocked; return ( -
+
{inheritedFromRoot && (
@@ -223,7 +236,7 @@ export default function WorkbenchModel({ onBack }) { onLinsightDefaultChange={(id) => setForm((prev) => ({ ...prev, linsightDefaultModelId: id }))} onAdd={() => setForm((prev) => ({ ...prev, - models: [...prev.models, { key: generateUUID(4), id: '', name: '', displayName: '', visual: false }], + models: [...prev.models, { key: generateUUID(4), id: '', name: '', displayName: '', description: '', visual: false }], }))} onRemove={(index) => setForm((prev) => { const removed = prev.models[index]; @@ -245,6 +258,10 @@ export default function WorkbenchModel({ onBack }) { ...prev, models: prev.models.map((item, i) => i === index ? { ...item, displayName } : item), }))} + onDescriptionChange={(index, description) => setForm((prev) => ({ + ...prev, + models: prev.models.map((item, i) => i === index ? { ...item, description } : item), + }))} onVisualToggle={(index, visual) => setForm((prev) => ({ ...prev, models: prev.models.map((item, i) => i === index ? { ...item, visual } : item),