Merge branch 'feat/2.6.0-beta4' into feat/2.6.0

This commit is contained in:
dolphin
2026-07-13 11:56:32 +08:00
113 changed files with 3060 additions and 4934 deletions
+3
View File
@@ -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
@@ -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:
+34 -23
View File
@@ -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"
@@ -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.
@@ -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:
@@ -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
@@ -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.
+161 -8
View File
@@ -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"
@@ -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
@@ -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)
+109 -92
View File
@@ -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.
@@ -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,
)
@@ -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,
}
@@ -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:
@@ -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]
@@ -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))
@@ -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 -----------------------------
@@ -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 (ownereditor) 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)
File diff suppressed because it is too large Load Diff
@@ -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)
@@ -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
+1 -1
View File
@@ -123,7 +123,7 @@ blue: { 50:'rgb(var(--brand-50) / <alpha-value>)', ... 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``<img>` 已换成 `<EmptyStateIllustration className="size-[120px] mb-X opacity-90" />`(去掉对内联 SVG 无意义的 `object-contain`):ChannelMemberManagementPanel、ChannelMemberDialog、KnowledgeSpaceMemberManagementPanel、KnowledgeSpaceMemberDialog、ChannelSquare、Subscription/index、knowledge/index、KnowledgeSquare、SpaceDetail/index、apps/AppEmptyState。
@@ -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: "知识库",
});
});
});
@@ -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);
});
});
+5
View File
@@ -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,
};
}
+6 -1
View File
@@ -18,10 +18,15 @@ export async function getVoice2TextApi(data: any): Promise<any> {
/**
*
*
* 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);
};
@@ -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");
});
});
@@ -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");
});
});
@@ -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(
<div className="flex h-7 min-h-7 w-full min-w-0 items-center justify-between gap-1 touch-mobile:gap-0.5">
{/* Toolbarflex-1 + overflow-hidden,避免与右侧语音/发送横向重叠 */}
<div className="input-bottom-left flex min-w-0 flex-1 items-center gap-1 touch-mobile:-ml-1 touch-mobile:gap-1 touch-mobile:pl-0 overflow-hidden">
<div ref={toolbarRef} className="input-bottom-left flex min-w-0 flex-1 items-center gap-1 touch-mobile:-ml-1 touch-mobile:gap-1 touch-mobile:pl-0 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(
<AgentToolSelector
availableTools={bsConfig.tools}
disabled={toolsDisabled}
compact={toolbarCompact}
/>
)}
{tools && !agentMode && onSearchTypeChange && (
<ChatToolDown
config={bsConfig}
compact={toolbarCompact}
searchType={searchType}
setSearchType={(type) => {
onSearchTypeChange(type);
@@ -528,6 +536,7 @@ const AiChatInput = memo(
{taskMode && (
<TaskModeToggle
active
compact={toolbarCompact}
onClick={onToggleTaskMode ? onToggleTaskMode : () => navigate('/c/new')}
/>
)}
@@ -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}
@@ -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({
<div className={cn("min-w-0", knowledgeChatLayout ? "w-full max-w-none" : "max-w-[80%]")}>
<TaskTurnPanel
versionId={message.linsightSessionVersionId || ""}
liked={message.liked}
allowFeedback={allowFeedback}
conversationId={message.conversationId}
answer={message.text}
onPreviewFile={onPreviewFile}
@@ -762,6 +773,18 @@ function AssistantBubble({
messageId={message.messageId || ""}
text={regularContent}
/>
{/* / 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 && (
<MessageFeedbackButtons
liked={message.liked}
onLike={(liked) => likeChatApi(message.messageId, liked)}
onDislikeComment={(comment) =>
disLikeCommentApi(message.messageId, comment)
}
/>
)}
</>
}
/>
@@ -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`. */}
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[280px]">
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[240px]">
{uniqueOptions.map((opt) => (
<SelectItem key={opt.id + ""} value={opt.id + ""}>
{opt.displayName}
<SelectItem key={opt.id + ""} value={opt.id + ""} textValue={opt.displayName}>
<div className="flex min-w-0 items-center py-0.5">
<span className="shrink-0">{opt.displayName}</span>
{opt.description && (
<>
<span className="mx-1.5 h-3 w-px shrink-0 bg-[#E5E6EB]" />
<span className="min-w-0 truncate text-xs font-normal text-[#999999]">
{opt.description}
</span>
</>
)}
</div>
</SelectItem>
))}
</SelectContent>
@@ -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<ResizeObserver | null>(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 (
<div className={cn(
'flex flex-col relative',
// Landing (non-messages) layout keeps auto height on desktop so the
// vh-positioned welcome block + apps flow naturally. On mobile it
// needs a definite height so the landing block's `min-h-full`
// centering resolves against the visible scroll-area height.
useMessagesLayout ? 'h-full' : 'touch-mobile:h-full'
// Landing (non-messages) layout keeps auto height on the desktop
// shell (≥768) so the vh-positioned welcome block + apps flow
// naturally. The H5 shell (≤767) needs a definite height so the
// landing block's `min-h-full` centering resolves against the
// visible scroll-area height.
useMessagesLayout ? 'h-full' : 'max-md:h-full'
)}>
{/* 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}
</div>
) : (
/* 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). */
<div ref={landingParentRef} className="relative min-h-full touch-mobile:h-full">
{/* 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. */
<div ref={landingParentRef} className="relative min-h-full max-md:h-full">
{/* Welcome message + input, absolutely centered. 768 at 45vh,
767 at 40% of the definite-height region. */}
<div
ref={landingBlockRef}
className="absolute inset-x-0 top-[45vh] -translate-y-1/2 touch-mobile:top-[45%]"
className="absolute inset-x-0 top-[45vh] -translate-y-1/2 max-md:top-[35%]"
>
{/* 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 && (
<div className="w-full max-w-[800px] mx-auto px-4 mt-10 touch-mobile:mt-2 touch-mobile:max-w-full pb-3">
<div className="w-full max-w-[800px] mx-auto px-4 mt-10 max-md:max-w-full pb-3">
<AiChatInput
elevated
disabled={!bsConfig?.models?.length || !!shareToken}
@@ -867,16 +862,15 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
)}
</div>
{/* 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). */}
<div
style={{
paddingTop: isTouchLayout
? `${landingParentHeight * 0.45 + landingBlockHeight / 2 + 40}px`
paddingTop: isH5
? `${landingParentHeight * 0.35 + landingBlockHeight / 2 + 40}px`
: `calc(45vh + ${landingBlockHeight / 2 + 40}px)`,
}}
>
@@ -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 <Outlined.Hammer className="size-4 text-[#999]" />;
}
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) {
<div className="relative shrink-0">
<ApiAppIcon size="15" className={cn("shrink-0", isActive ? "text-blue-500" : "text-[#999999]")} strokeWidth={1.5} />
</div>
{/* Mobile: collapse to icon + chevron only to save horizontal space. */}
<span className="text-[14px] font-normal truncate min-w-0 max-w-[min(20vw,60px)] touch-mobile:hidden">
{localize("com_tools_title")}
{/* {isActive ? ` (${activeCount})` : ""} */}
</span>
{/* Compact: collapse to icon + chevron only to save horizontal space. */}
{!compact && (
<span className="text-[14px] font-normal truncate min-w-0 max-w-[min(20vw,60px)]">
{localize("com_tools_title")}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="bg-white rounded-[8px] w-[200px] max-h-[320px] overflow-y-auto">
@@ -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 = ({
<Select disabled={disabled}>
<SelectTrigger
className={cn(
"h-7 rounded-full px-2 data-[state=open]:border-blue-500 touch-mobile:px-1.5",
"h-7 rounded-full px-2 data-[state=open]:border-blue-500",
compact && "px-1.5",
searchType === "netSearch" && "bg-blue-100"
)}
>
<div
className={cn(
"flex gap-2 touch-mobile:gap-1",
"flex items-center",
compact ? "gap-1" : "gap-2",
searchType === "netSearch" && "text-blue-600"
)}
>
<Settings2Icon size="16" />
{/* Mobile: collapse to icon + chevron only to save horizontal space. */}
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] touch-mobile:max-w-[min(18vw,56px)] touch-mobile:hidden">
{localize("com_tools_title")}
</span>
{/* Compact: collapse to icon + chevron only to save horizontal space. */}
{!compact && (
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)]">
{localize("com_tools_title")}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="bg-white rounded-[8px] w-52">
@@ -124,10 +131,10 @@ export const LinsiTools = ({ tools, setTools }) => {
return (
<Select>
<SelectTrigger className="h-7 rounded-full px-2 bg-white dark:bg-transparent data-[state=open]:border-blue-500 touch-mobile:px-1.5">
<div className={cn("flex gap-2 touch-mobile:gap-1", active && "text-blue-600")}>
<SelectTrigger className="h-7 rounded-full px-2 bg-white dark:bg-transparent data-[state=open]:border-blue-500 max-md:px-1.5">
<div className={cn("flex gap-2 max-md:gap-1", active && "text-blue-600")}>
<Settings2Icon size="16" />
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] touch-mobile:max-w-[min(18vw,56px)]">
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] max-md:max-w-[min(18vw,56px)]">
{localize("com_tools_title")}
</span>
</div>
@@ -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 = ({
}}
/>
</div>
{/* Mobile: collapse to icon + chevron only to save horizontal
space in the input toolbar. */}
<span className="touch-mobile:hidden">{localize('com_ui_knowledge_space')}</span>
{/* Compact: collapse to icon + chevron only to save
horizontal space in the input toolbar. */}
{!compact && <span>{localize('com_ui_knowledge_space')}</span>}
<Outlined.Down size={16} className={cn("text-[#999] transition-transform duration-200", rootOpen && "rotate-180")} />
</button>
) : (
@@ -29,29 +29,29 @@ export default function Landing({ Header, isNew, hideSubtitle = false }: {
return (
<div className={`relative ${!isNew ? 'h-full' : ''}`}>
<div className="absolute left-0 right-0">{Header != null ? Header : null}</div>
<div className="flex h-full flex-col items-center justify-center touch-mobile:justify-start touch-mobile:pt-2 touch-mobile:pb-4 px-4">
{/* Hero: stack vertically on 576 稿 */}
<div className="flex flex-col touch-mobile:flex-col items-center gap-3 touch-mobile:gap-3 touch-desktop:flex-row touch-desktop:gap-4">
<div className="flex h-full flex-col items-center justify-center max-md:justify-start max-md:pt-2 max-md:pb-4 px-4">
{/* Hero: row on ≥768 (matches the desktop shell), stacked only on the H5 shell (≤767) */}
<div className="flex flex-col items-center gap-3 md:flex-row md:gap-4">
{bsConfig?.assistantIcon?.image && (
<img
className="overflow-hidden touch-mobile:w-14 touch-mobile:h-14 w-[52px] h-[52px] object-contain shrink-0"
className="overflow-hidden w-[52px] h-[52px] object-contain shrink-0"
src={__APP_ENV__.BASE_URL + bsConfig.assistantIcon.image}
alt=""
/>
)}
<h2 className="max-w-[75vh] touch-mobile:max-w-full text-center text-xl touch-mobile:font-semibold touch-mobile:text-[#1d2129] touch-mobile:leading-snug font-medium dark:text-white touch-desktop:text-2xl px-0">
<h2 className="max-w-full md:max-w-[75vw] text-center text-xl md:text-2xl font-semibold md:font-medium leading-snug md:leading-8 text-[#1d2129] dark:text-white px-0">
{bsConfig?.welcomeMessage}
</h2>
</div>
{!hideSubtitle && (
<div className="max-w-lg touch-mobile:max-w-full text-center mt-[26px] touch-mobile:mt-3 text-sm touch-mobile:text-[13px] font-normal text-gray-500 touch-mobile:text-[#4e5969] leading-5 touch-mobile:leading-relaxed">
<div className="max-w-lg text-center mt-[26px] text-sm font-normal text-gray-500 leading-5">
{bsConfig?.functionDescription}
</div>
)}
{/* Conversation starters */}
{conversation_starters.length > 0 && (
<div className="mt-6 touch-mobile:mt-5 w-full max-w-2xl flex flex-wrap justify-center gap-2 touch-mobile:gap-2 touch-mobile:px-0">
<div className="mt-5 md:mt-6 w-full max-w-2xl flex flex-wrap justify-center gap-2">
{conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
@@ -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<ThumbsState>(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 (
<>
<div className={cn("flex gap-1", className)}>
<button
type="button"
className={ACTION_BTN}
onClick={() => handleClick(1)}
title="点赞"
aria-label="点赞"
aria-pressed={state === 1}
>
<Outlined.ThumbsUp
size={14}
className={cn(state === 1 ? "text-blue-500" : "text-[#818181]")}
/>
</button>
<button
type="button"
className={ACTION_BTN}
onClick={() => handleClick(2)}
title="点踩"
aria-label="点踩"
aria-pressed={state === 2}
>
<Outlined.ThumbsDown
size={14}
className={cn(state === 2 ? "text-blue-500" : "text-[#818181]")}
/>
</button>
</div>
{onDislikeComment && (
<CommentDialog
open={commentOpen}
onOpenChange={setCommentOpen}
title={localize("com_feedback_title")}
placeholder={localize("com_feedback_placeholder")}
onSubmit={handleSubmitComment}
/>
)}
</>
);
}
@@ -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 <textarea> is one reused DOM node across pages, and its
// grown height lives in an inline style set on input. On page change that
// stale height would leak into the next question (or a saved multi-line answer
// would show clipped). Re-fit it to the CURRENT question's content whenever the
// page changes — empty content collapses back to one row.
const customTaRef = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = customTaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [page]);
return (
<div
className="my-3 w-full rounded-2xl border border-[#EEF2F6] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.03)]"
@@ -269,34 +282,52 @@ function ClarifyCardInteractive({ data, disabled = false, onSubmit }: ClarifyCar
<li>
<div
className={cn(
'flex h-9 items-center gap-2 rounded-lg px-4 transition-all duration-200',
// min-h (not fixed h) so the textarea can grow past one
// line when the user inserts Shift+Enter newlines.
// items-start: once grown, the number stays pinned to the
// FIRST line (top) instead of centering on the tall box.
// py-2 (8px) + a 20px line = 36px (h-9) when single-line.
'flex min-h-9 items-start gap-2 rounded-lg px-4 py-2 transition-all duration-200',
// No box by default (matches the other options); the
// input-box background only appears once it's active.
customSelected ? 'bg-[#EEE]' : 'hover:bg-gray-50/80',
)}
>
<span className="shrink-0 text-sm font-medium text-[#8C8C8C]">
<span className="shrink-0 text-sm font-medium leading-5 text-[#8C8C8C]">
{q.options.length + 1}.
</span>
<input
type="text"
<textarea
ref={customTaRef}
rows={1}
disabled={disabled || submitted}
value={customText[q.id] || ''}
placeholder={localize('com_linsight_clarify_custom')}
// Default placeholder is the short "自行输入"; once the row is
// highlighted (customSelected, i.e. focused/active) it grows the
// Shift+Enter hint — matching when the bg-[#EEE] highlight shows.
placeholder={localize(
customSelected
? 'com_linsight_clarify_custom_active'
: 'com_linsight_clarify_custom',
)}
onFocus={() => !customSelected && handleSelect(q, CUSTOM_KEY)}
onChange={(e) => {
setCustomText((prev) => ({ ...prev, [q.id]: e.target.value }));
if (!customSelected) handleSelect(q, CUSTOM_KEY);
// Auto-grow to fit its content (Shift+Enter newlines).
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`;
}}
onKeyDown={(e) => {
// Enter confirms the typed custom answer and advances to
// the next question (both single- AND multi-select),
// matching the "下一题 ↵" hint — the input is focused, so
// the window-level Enter handler is bypassed and this is the
// only place that can advance. Guard the IME composition
// Enter so committing pinyin doesn't skip the question.
// Enter (without Shift) confirms the typed custom answer and
// advances to the next question (both single- AND
// multi-select), matching the "下一题 ↵" hint — the field is
// focused, so the window-level Enter handler is bypassed and
// this is the only place that can advance. Shift+Enter falls
// through to the textarea's native newline. Guard the IME
// composition Enter so committing pinyin doesn't skip.
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.nativeEvent.isComposing &&
customText[q.id]?.trim()
) {
@@ -304,17 +335,16 @@ function ClarifyCardInteractive({ data, disabled = false, onSubmit }: ClarifyCar
handleConfirm();
}
}}
className={cn(
'flex-1 bg-transparent text-sm outline-none placeholder:text-[#8C8C8C]',
customSelected ? 'text-[#1A1A1A] font-medium' : 'text-[#1A1A1A]',
)}
className="flex-1 resize-none border-0 bg-transparent p-0 text-sm font-normal leading-5 text-[#1A1A1A] outline-none placeholder:text-[#8C8C8C]"
/>
{!q.multiple && customSelected && customText[q.id]?.trim() && (
<button
type="button"
disabled={disabled || submitted}
onClick={handleConfirm}
className="flex shrink-0 items-center gap-1 text-sm font-medium text-[#8C8C8C] hover:text-[#212121] disabled:opacity-50 transition-colors"
// self-end pins 确定 to the bottom-right of the (possibly
// grown) box, while the number stays top-left.
className="flex shrink-0 self-end items-center gap-1 text-sm font-medium text-[#8C8C8C] hover:text-[#212121] disabled:opacity-50 transition-colors"
>
{localize('com_linsight_clarify_submit')}
<Outlined.CornerDownLeft size={14} className="shrink-0" />
@@ -1,140 +0,0 @@
/**
* DeepStepGroup regression guard for the anti-flicker fix + the quiet-open fold.
*
* Root cause the anti-flicker fix locks down: the group's live-vs-done UI used to be
* driven by `group.running` ("any step mid-flight"), which toggles truefalse many
* times within ONE live episode thinking frames ship as `status:'end'` (never
* running) and a tool step is running only between its start/end frames. Binding the
* / label (and, before b1ff8967a, the fold) to it made the group flicker on
* every tool call ("内容上下反复跳跃").
*
* Current contract these tests assert:
* - the / LABEL follows the stable `active` prop (the live tail episode, owned
* by ExecutionTimeline), NOT `group.running`;
* - the FOLD defaults COLLAPSED for every group even the live tail so task mode
* opens quiet (b1ff8967a); it is bound to neither `active` nor `group.running`.
*/
import { render } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { DeepStepGroup } from './DeepStepGroup';
import type { DeepStepGroup as DeepStepGroupData, MergedStep } from './stepUtils';
// useLocalize → identity so the rendered label IS the i18n key (assertable).
jest.mock('~/hooks', () => ({
__esModule: true,
useLocalize: () => (key: string) => key,
}));
// jsdom has no IntersectionObserver; the sticky-header pin detection news one up
// on mount. Stub it as an inert no-op so the group renders.
beforeAll(() => {
class MockIO {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
(window as any).IntersectionObserver = MockIO;
(global as any).IntersectionObserver = MockIO;
});
const RUNNING_LABEL = 'com_linsight_deep_thinking_running';
const DONE_LABEL = 'com_linsight_deep_thinking_done';
/** One thinking step. Thinking is the minimal render tree (no tool/knowledge child
* rows, so no extra hook deps) and always lands as a `status:'end'` frame, i.e.
* running=false exactly the case the old code mis-collapsed. */
function thinkingStep(callId: string, output: string): MergedStep {
return {
callId,
taskId: 't',
name: 'thinking',
stepType: 'thinking',
running: false,
callReason: '',
params: null,
output,
namespace: null,
extraInfo: {},
// far-past second-level stamps so the live ticker measures elapsedMs > 0
// (the label keeps its 用时/已用 clause instead of the 0s compact form).
startedAt: 1000,
endedAt: 1200,
raw: {} as any,
};
}
function makeGroup(running: boolean, steps: MergedStep[]): DeepStepGroupData {
return { kind: 'deep_step_group', steps, startedAt: 1000, endedAt: 1200, running };
}
function renderGroup(group: DeepStepGroupData, active: boolean) {
return render(
<RecoilRoot>
<DeepStepGroup group={group} active={active} />
</RecoilRoot>,
);
}
/** The group's own fold container is the only element carrying an inline
* grid-template-rows (thinking-only group renders no nested collapsibles). */
function foldRows(container: HTMLElement): string {
const grid = container.querySelector('[style*="grid-template-rows"]') as HTMLElement;
return grid.style.gridTemplateRows;
}
describe('DeepStepGroup — label follows `active`; fold opens quiet, bound to neither', () => {
it('active=true shows the running label, yet opens collapsed (quiet) even as the live tail', () => {
const { container, getByText } = renderGroup(
makeGroup(false, [thinkingStep('c1', 'reasoning…')]),
true,
);
getByText(RUNNING_LABEL); // label follows active, not the (false) group.running
// Every group — even the live tail — defaults collapsed so task mode opens
// quiet (b1ff8967a); the collapsed header still streams via the NarrationTicker.
expect(foldRows(container)).toBe('0fr');
});
it('active=false collapses and shows the done label even while group.running=true (the regression guard)', () => {
// group.running=true would, under the old code, force-expand + "正在" —
// the exact per-tool-call flicker we removed.
const { container, getByText, queryByText } = renderGroup(
makeGroup(true, [thinkingStep('c1', 'reasoning…')]),
false,
);
getByText(DONE_LABEL);
expect(queryByText(RUNNING_LABEL)).toBeNull();
expect(foldRows(container)).toBe('0fr'); // collapsed
});
it('toggling group.running while active stays true changes neither the label nor the (collapsed) fold (anti-flicker)', () => {
const steps = [thinkingStep('c1', 'reasoning…')];
const { container, rerender, getByText } = render(
<RecoilRoot>
<DeepStepGroup group={makeGroup(false, steps)} active={true} />
</RecoilRoot>,
);
getByText(RUNNING_LABEL);
expect(foldRows(container)).toBe('0fr');
// a tool call starts → group.running flips true … (active unchanged)
rerender(
<RecoilRoot>
<DeepStepGroup group={makeGroup(true, steps)} active={true} />
</RecoilRoot>,
);
getByText(RUNNING_LABEL);
expect(foldRows(container)).toBe('0fr');
// … and ends → group.running flips back to false (active still unchanged)
rerender(
<RecoilRoot>
<DeepStepGroup group={makeGroup(false, steps)} active={true} />
</RecoilRoot>,
);
getByText(RUNNING_LABEL);
expect(foldRows(container)).toBe('0fr'); // fold never flips with group.running
});
});
@@ -52,7 +52,7 @@ interface ExecutionFlowProps {
export function ExecutionFlow({ versionId, conversationId, isSharePage = false, readOnly = false, artifactsPanel }: ExecutionFlowProps) {
const localize = useLocalize();
const { getLinsight, continueConversation } = useLinsightManager();
const { getLinsight, continueConversation, updateLinsight } = useLinsightManager();
// Mount the WS pump here (the legacy TaskFlow used to own it).
const { stop, sendInput } = useLinsightWebSocket(versionId);
@@ -238,7 +238,12 @@ export function ExecutionFlow({ versionId, conversationId, isSharePage = false,
{/* ── artifacts area (P4): report link / answer markdown / file
card lifted into the terminal ResultPanel (peak-end). */}
{completed && (
<ResultPanel>
<ResultPanel
messageId={linsight?.message_id ?? undefined}
liked={linsight?.liked ?? undefined}
allowFeedback={!readOnly && !isSharePage}
onLikedChange={(l) => updateLinsight(versionId, { liked: l })}
>
<ResultSection
answer={linsight?.output_result?.answer}
files={fileList}
@@ -11,14 +11,29 @@
import { Outlined } from 'bisheng-icons';
import type { ReactNode } from 'react';
import { useLocalize } from '~/hooks';
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
import { likeChatApi, disLikeCommentApi } from '~/api/apps';
import { INK } from './execTokens';
interface ResultPanelProps {
/** the terminal deliverable (typically <ResultSection />) */
children: ReactNode;
/** task-result ChatMessage id the like/dislike target. The task result is a
category="task" ChatMessage, so feedback reuses the shared /liked +
/chat/comment endpoints (same as daily / knowledge / channel). */
messageId?: string;
/** persisted like/dislike verdict: 0 none / 1 up / 2 down */
liked?: number;
/** show like/dislike (off for read-only / share view) */
allowFeedback?: boolean;
/** Sync the new verdict back to the linsight store so the highlight survives a
switch-away/switch-back: the panel reads `liked` from the store, which is
seeded once on hydration and would otherwise go stale after an optimistic
click (re-mount then shows the old value until a full page reload). */
onLikedChange?: (liked: number) => void;
}
export function ResultPanel({ children }: ResultPanelProps) {
export function ResultPanel({ children, messageId, liked, allowFeedback, onLikedChange }: ResultPanelProps) {
const localize = useLocalize();
// peak-end (§2.6): a DoubleCheck Ink "task completed" header marks the
// terminal state and lifts the deliverable out of the homogeneous flow; body
@@ -37,6 +52,21 @@ export function ResultPanel({ children }: ResultPanelProps) {
</span>
</div>
{children}
{/* like/dislike the task result is a category="task" ChatMessage, so
rate it through the shared chatmessage feedback endpoints keyed by
message id (rollup to message_session is maintained backend-side). */}
{allowFeedback && messageId && (
<div className="mt-3">
<MessageFeedbackButtons
liked={liked}
onLike={(l) => {
onLikedChange?.(l);
likeChatApi(messageId, l);
}}
onDislikeComment={(c) => disLikeCommentApi(messageId, c)}
/>
</div>
)}
</div>
);
}
@@ -1,66 +0,0 @@
/**
* TaskErrorCard friendly rate-limit copy ().
*
* Verifies the user-visible contract of the rate-limit copy change:
* - desc renders the unified "当前使用人数较多,请稍后再试。" in all three locales;
* - the suggestion (hint) line is removed the empty i18n value is hidden by the
* component's `{hint && ...}` guard;
* - the title is retained;
* - quota_exhausted is left untouched (the throttling-vs-billing split) and still
* shows the "contact admin to top up" hint.
*
* Uses the REAL i18n resources (not the usual identity mock) so the keycopy
* wiring and the empty-string-hides-hint behaviour are actually exercised.
*/
import { render, screen } from '@testing-library/react';
import i18n from '~/locales/i18n';
import { TaskErrorCard } from './TaskErrorCard';
// useLocalize → real i18n.t so rendered text is the actual localized copy.
jest.mock('~/hooks', () => {
const realI18n = require('~/locales/i18n').default;
return {
__esModule: true,
useLocalize: () => (key: string, opts?: any) => realI18n.t(key, opts),
};
});
const RATE_LIMIT_DESC: Record<string, string> = {
'zh-Hans': '当前使用人数较多,请稍后再试。',
en: 'Too many users at the moment. Please try again later.',
ja: '現在ご利用が集中しています。しばらくしてからもう一度お試しください。',
};
describe('TaskErrorCard — rate-limit friendly copy', () => {
afterAll(async () => {
await i18n.changeLanguage('en');
});
it.each(Object.entries(RATE_LIMIT_DESC))('renders the unified rate-limit desc in %s', async (lang, expected) => {
await i18n.changeLanguage(lang);
render(<TaskErrorCard errorType="rate_limit" detail="raw provider 429 text" />);
expect(screen.getByText(expected)).toBeInTheDocument();
});
it('retains the rate-limit title', async () => {
await i18n.changeLanguage('zh-Hans');
render(<TaskErrorCard errorType="rate_limit" />);
expect(screen.getByText('模型服务繁忙')).toBeInTheDocument();
});
it('drops the suggestion line for rate_limit (empty hint hidden)', async () => {
await i18n.changeLanguage('zh-Hans');
render(<TaskErrorCard errorType="rate_limit" />);
// the old suggestion copy must be gone ...
expect(screen.queryByText(/稍等片刻后重新发起任务/)).not.toBeInTheDocument();
// ... and the empty hint key must not leak as raw text either
expect(screen.queryByText('com_linsight_error_hint_rate_limit')).not.toBeInTheDocument();
});
it('keeps quota_exhausted distinct (top-up hint intact, split not broken)', async () => {
await i18n.changeLanguage('zh-Hans');
render(<TaskErrorCard errorType="quota_exhausted" />);
expect(screen.getByText('模型服务额度已用尽')).toBeInTheDocument();
expect(screen.getByText(/联系管理员充值/)).toBeInTheDocument();
});
});
@@ -47,6 +47,10 @@ import { findPendingUserInput, hasRenderableTimeline, isTaskRunning, isTaskStart
interface TaskTurnPanelProps {
/** linsight session_version id holding this turn's execution detail */
versionId: string;
/** persisted like/dislike verdict fallback (store value wins once hydrated) */
liked?: number;
/** show like/dislike (off for the read-only share view) */
allowFeedback?: boolean;
/** chat id of the hosting conversation (for the history lazy-load) */
conversationId?: string;
/** final answer text (fallback shown before the panel hydrates) */
@@ -58,9 +62,9 @@ interface TaskTurnPanelProps {
onPreviewFile?: (file: ArtifactFile) => void;
}
export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = false, onPreviewFile }: TaskTurnPanelProps) {
export function TaskTurnPanel({ versionId, liked, allowFeedback = true, conversationId, answer, readOnly = false, onPreviewFile }: TaskTurnPanelProps) {
const localize = useLocalize();
const { getLinsight, switchAndUpdateLinsight } = useLinsightManager();
const { getLinsight, switchAndUpdateLinsight, updateLinsight } = useLinsightManager();
// WS pump — self-guards on status===Running, so mounting it for a completed
// historical turn is a no-op (no connection opened).
const { sendInput, stop } = useLinsightWebSocket(versionId);
@@ -250,7 +254,12 @@ export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = fa
document link opens it directly in ChatView's inline workspace
panel (preview), replacing the legacy right-side drawer. */}
{completed && (
<ResultPanel>
<ResultPanel
messageId={linsight?.message_id ?? undefined}
liked={linsight?.liked ?? liked}
allowFeedback={allowFeedback && !readOnly}
onLikedChange={(l) => updateLinsight(versionId, { liked: l })}
>
<ResultSection
answer={linsight.output_result?.answer}
files={fileList}
@@ -1,160 +0,0 @@
/**
* timelineMemo guards the React.memo comparators that keep the task-mode
* execution timeline from re-rendering frozen episodes on every WS frame (the fix
* for the "用时 N 秒" counter advancing unevenly / skipping seconds under a thinking
* token-delta storm). The critical contract: the WS pump rebuilds the whole node
* tree with FRESH objects each frame, so an unchanged (frozen) episode must compare
* EQUAL across rebuilds (skip re-render), while any real change in the active tail
* must compare UNEQUAL (re-render).
*/
import { deepStepGroupPropsEqual } from './DeepStepGroup';
import { toolRowLitePropsEqual } from './ToolRowLite';
import type { DeepStepGroup as DeepStepGroupData, MergedStep } from './stepUtils';
// useLocalize is only called inside the component, but importing DeepStepGroup
// pulls the module graph in — mirror the sibling test's hook stub so it resolves.
jest.mock('~/hooks', () => ({
__esModule: true,
useLocalize: () => (key: string) => key,
}));
const SHARED_PARAMS = { q: 'a' }; // a stable raw-frame reference (what mergeStepFrames reuses)
function step(overrides: Partial<MergedStep> = {}): MergedStep {
return {
callId: 'c1',
taskId: 't',
name: 'thinking',
stepType: 'thinking',
running: false,
callReason: '',
params: null,
output: 'abc',
namespace: null,
extraInfo: {},
startedAt: 1000,
endedAt: 1200,
raw: {} as any,
...overrides,
};
}
/** Clone a step into a NEW object with identical field values — the rebuild case. */
function rebuilt(s: MergedStep): MergedStep {
return { ...s, extraInfo: {} }; // fresh extraInfo, as mergeStepFrames spreads one each pass
}
function group(steps: MergedStep[], overrides: Partial<DeepStepGroupData> = {}): DeepStepGroupData {
return { kind: 'deep_step_group', steps, startedAt: 1000, endedAt: 1200, running: false, ...overrides };
}
describe('deepStepGroupPropsEqual', () => {
it('treats a rebuilt-but-unchanged frozen episode as EQUAL (skip re-render)', () => {
const s = step({ params: SHARED_PARAMS });
const a = { group: group([s]), active: false, compact: false };
const b = { group: group([rebuilt(s)]), active: false, compact: false };
expect(deepStepGroupPropsEqual(a, b)).toBe(true);
});
it('re-renders when `active` flips (live tail ⇄ done)', () => {
const s = step();
expect(
deepStepGroupPropsEqual(
{ group: group([s]), active: true, compact: false },
{ group: group([rebuilt(s)]), active: false, compact: false },
),
).toBe(false);
});
it('re-renders when a streaming step appends output (length grows)', () => {
const s = step({ output: 'abc' });
const grown = step({ output: 'abcdef' });
expect(
deepStepGroupPropsEqual(
{ group: group([s]), active: true, compact: false },
{ group: group([grown]), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when a new step is appended to the episode', () => {
const s = step({ callId: 'c1' });
expect(
deepStepGroupPropsEqual(
{ group: group([s]), active: true, compact: false },
{ group: group([rebuilt(s), step({ callId: 'c2', name: 'web_search', stepType: 'tool' })]), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when a MIDDLE step closes (running true→false) — the subtle case', () => {
// c2 is not the last step; a last-step-only signature would miss its close.
const mk = (c2Running: boolean) => [
step({ callId: 'c1', name: 'thinking' }),
step({ callId: 'c2', name: 'web_search', stepType: 'tool', running: c2Running }),
step({ callId: 'c3', name: 'thinking' }),
];
expect(
deepStepGroupPropsEqual(
{ group: group(mk(true)), active: true, compact: false },
{ group: group(mk(false)), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when the group clock end stamp changes', () => {
const s = step();
expect(
deepStepGroupPropsEqual(
{ group: group([s], { endedAt: 1200 }), active: true, compact: false },
{ group: group([rebuilt(s)], { endedAt: 1300 }), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when a subagent segment goal changes; equal when same', () => {
const s = step();
const base = { group: group([s]), active: false, compact: false, subagent: { goal: 'research X', idx: 1 } };
expect(
deepStepGroupPropsEqual(base, {
group: group([rebuilt(s)]),
active: false,
compact: false,
subagent: { goal: 'research Y', idx: 1 },
}),
).toBe(false);
expect(
deepStepGroupPropsEqual(base, {
group: group([rebuilt(s)]),
active: false,
compact: false,
subagent: { goal: 'research X', idx: 1 },
}),
).toBe(true);
});
});
describe('toolRowLitePropsEqual', () => {
it('treats a rebuilt-but-unchanged tool row as EQUAL', () => {
const s = step({ name: 'web_search', stepType: 'tool', params: SHARED_PARAMS, output: 'res' });
expect(toolRowLitePropsEqual({ step: s }, { step: rebuilt(s) })).toBe(true);
});
it('re-renders when the tool step closes (running flip)', () => {
const running = step({ name: 'web_search', stepType: 'tool', running: true });
const done = step({ name: 'web_search', stepType: 'tool', running: false });
expect(toolRowLitePropsEqual({ step: running }, { step: done })).toBe(false);
});
it('re-renders when output streams in (length grows)', () => {
const a = step({ name: 'web_search', stepType: 'tool', output: '' });
const b = step({ name: 'web_search', stepType: 'tool', output: 'hit list' });
expect(toolRowLitePropsEqual({ step: a }, { step: b })).toBe(false);
});
it('re-renders when params first arrive (reference changes)', () => {
const a = step({ name: 'web_search', stepType: 'tool', params: null });
const b = step({ name: 'web_search', stepType: 'tool', params: SHARED_PARAMS });
expect(toolRowLitePropsEqual({ step: a }, { step: b })).toBe(false);
});
});
@@ -28,9 +28,11 @@ interface KnowledgeSpaceSelectProps {
value: TaskModeKnowledgeItem[];
disabled?: boolean;
onChange: (items: TaskModeKnowledgeItem[]) => void;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}
export function KnowledgeSpaceSelect({ value, disabled = false, onChange }: KnowledgeSpaceSelectProps) {
export function KnowledgeSpaceSelect({ value, disabled = false, onChange, compact = false }: KnowledgeSpaceSelectProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: bsConfig } = useGetBsConfig();
@@ -154,7 +156,7 @@ export function KnowledgeSpaceSelect({ value, disabled = false, onChange }: Know
WebkitMaskSize: 'contain', maskSize: 'contain',
}}
/>
<span className="truncate max-w-[min(30vw,120px)]">{localize('com_ui_knowledge_space')}</span>
{!compact && <span className="truncate">{localize('com_ui_knowledge_space')}</span>}
<ChevronDown size={14} className="text-slate-400" />
</button>
</DropdownMenuTrigger>
@@ -70,15 +70,25 @@ export function ModelSelector({ value, disabled = false, onChange }: ModelSelect
return (
<Select value={String(value)} disabled={disabled} onValueChange={onChange}>
<SelectTrigger className="h-8 w-auto min-w-0 max-w-[min(40vw,220px)] touch-mobile:max-w-[min(40vw,140px)] gap-1 overflow-hidden border-none bg-transparent px-2 text-[#4E5969] shadow-none outline-none hover:bg-black/5 focus:ring-0">
<SelectTrigger className="h-8 w-auto min-w-0 max-w-[min(40vw,220px)] max-md:max-w-[min(40vw,140px)] gap-1 overflow-hidden border-none bg-transparent px-2 text-[#4E5969] shadow-none outline-none hover:bg-black/5 focus:ring-0">
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] font-normal">
{label}
</span>
</SelectTrigger>
<SelectContent className="bg-white">
<SelectContent className="bg-white max-w-[240px]">
{options.map((opt: any) => (
<SelectItem key={String(opt.id)} value={String(opt.id)}>
{opt.displayName ?? opt.name}
<SelectItem key={String(opt.id)} value={String(opt.id)} textValue={opt.displayName ?? opt.name}>
<div className="flex min-w-0 items-center py-0.5">
<span className="shrink-0">{opt.displayName ?? opt.name}</span>
{opt.description && (
<>
<span className="mx-1.5 h-3 w-px shrink-0 bg-[#E5E6EB]" />
<span className="min-w-0 truncate text-xs font-normal text-[#999999]">
{opt.description}
</span>
</>
)}
</div>
</SelectItem>
))}
</SelectContent>
@@ -29,7 +29,7 @@ import {
AlertDialogTitle,
} from '~/components/ui/AlertDialog';
import { useGetBsConfig } from '~/hooks/queries/data-provider';
import { useLocalize } from '~/hooks';
import { useContainerCompact, useLocalize, TOOLBAR_COMPACT_THRESHOLD } from '~/hooks';
import { useLinsightSessionManager } from '~/hooks/useLinsightManager';
import InputFiles from '~/pages/appChat/components/InputFiles';
import { useFileDropAndPaste } from '~/pages/appChat/useFileDropAndPaste';
@@ -73,6 +73,9 @@ interface TaskModeInputProps {
export function TaskModeInput({ conversationId = 'new', disabled = false, onFollowUp, running = false, onStop }: TaskModeInputProps) {
const localize = useLocalize();
// 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);
const navigate = useNavigate();
const location = useLocation();
const { showToast } = useToastContext();
@@ -339,7 +342,7 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll
{/* Toolbar */}
<div className="flex h-8 min-h-8 w-full min-w-0 items-center justify-between gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div ref={toolbarRef} className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<PlusMenu
disabled={disabled}
onUploadFile={() => inputFilesRef.current?.openPicker?.()}
@@ -353,14 +356,16 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll
<KnowledgeSpaceSelect
value={context.knowledge}
disabled={disabled}
compact={toolbarCompact}
onChange={(knowledge) => setContext((prev) => ({ ...prev, knowledge }))}
/>
<ToolsSelect
tools={context.tools}
disabled={disabled}
compact={toolbarCompact}
onChange={(tools) => setContext((prev) => ({ ...prev, tools }))}
/>
<TaskModeToggle active disabled={disabled} onClick={handleExitTaskMode} />
<TaskModeToggle active disabled={disabled} compact={toolbarCompact} onClick={handleExitTaskMode} />
</div>
<div className="flex shrink-0 items-center gap-1.5">
@@ -8,25 +8,33 @@
import { X } from 'lucide-react';
import { Outlined } from 'bisheng-icons';
import { useState } from 'react';
import { useLocalize } from '~/hooks';
import useMediaQuery from '~/hooks/useMediaQuery';
import { useLocalize, useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
interface TaskModeToggleProps {
active: boolean;
disabled?: boolean;
onClick: () => void;
/**
* Toolbar ran out of room (measured by the parent, see useContainerCompact):
* collapse to icon-only with a persistent exit "x". When compact, the hover
* icon-swap is disabled otherwise it renders a SECOND x next to the
* persistent one. Roomy toolbars keep the hover binocularsx affordance.
*/
compact?: boolean;
}
export function TaskModeToggle({ active, disabled = false, onClick }: TaskModeToggleProps) {
export function TaskModeToggle({ active, disabled = false, onClick, compact = false }: TaskModeToggleProps) {
const localize = useLocalize();
const [hovered, setHovered] = useState(false);
// Matches the CSS `touch-mobile` variant (≤1023px), where the label is
// hidden and a persistent exit "x" is shown instead. In that layout the
// hover icon-swap must be disabled — otherwise it renders a SECOND x next to
// the persistent one. Wide screens keep the hover binoculars→x affordance.
const isTouchLayout = useMediaQuery('(max-width: 1023px)');
const showExit = active && hovered && !isTouchLayout;
// Touch devices (iPad, foldables) can't hover, so the binoculars→x swap
// never fires there — fall back to a persistent exit "x" even when the label
// is shown. The swap stays only on hover-capable, roomy layouts.
const noHover = useMediaQuery('(hover: none)');
const showExit = active && hovered && !compact && !noHover;
// Standing exit "x": when there's no hover-swap to reveal it (compact layout
// or a non-hover device).
const showPersistentExit = active && (compact || noHover);
return (
<button
@@ -48,14 +56,15 @@ export function TaskModeToggle({ active, disabled = false, onClick }: TaskModeTo
) : (
<Outlined.Binoculars size={16} className={active ? 'text-blue-600' : 'text-[#4E5969]'} />
)}
{/* Mobile: collapse to icon only to save horizontal space in the
{/* Compact: collapse to icon only to save horizontal space in the
input toolbar, matching the knowledge/tools selectors. */}
<span className="touch-mobile:hidden">{localize('com_linsight_task_mode')}</span>
{/* Mobile + active: persistent exit "x" standing in for the other
selectors' chevron same size/color/gap as their down icon
(size 16, #999). Desktop keeps the hover-swap affordance above. */}
{active && (
<X size={16} className="hidden shrink-0 text-[#999] touch-mobile:block" />
{!compact && <span>{localize('com_linsight_task_mode')}</span>}
{/* Persistent exit "x" standing in for the other selectors' chevron
same size/color/gap as their down icon (size 16, #999). Shown
when no hover-swap will reveal one: compact layout, or a device
that can't hover. Hover-capable roomy layouts use the swap above. */}
{showPersistentExit && (
<X size={16} className="shrink-0 text-[#999]" />
)}
</button>
);
@@ -19,9 +19,11 @@ interface ToolsSelectProps {
tools: TaskModeToolItem[];
disabled?: boolean;
onChange: (tools: TaskModeToolItem[]) => void;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}
export function ToolsSelect({ tools, disabled = false, onChange }: ToolsSelectProps) {
export function ToolsSelect({ tools, disabled = false, onChange, compact = false }: ToolsSelectProps) {
const localize = useLocalize();
const active = tools.some((tool) => tool.checked);
@@ -41,7 +43,7 @@ export function ToolsSelect({ tools, disabled = false, onChange }: ToolsSelectPr
)}
>
<Hammer size={16} />
<span className="truncate max-w-[min(30vw,120px)]">{localize('com_tools_title')}</span>
{!compact && <span className="truncate">{localize('com_tools_title')}</span>}
<ChevronDown size={14} className="text-slate-400" />
</button>
</DropdownMenuTrigger>
@@ -174,7 +174,7 @@ const Nav = ({
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full min-h-0 w-full flex-col gap-0 pt-5 px-3 max-[767px]:gap-0 max-[767px]:p-0"
className="flex h-full min-h-0 w-full flex-col gap-0 pt-4 px-3 max-[767px]:gap-0 max-[767px]:p-0"
>
{/* New chat header and buttons */}
<NewChat
@@ -14,13 +14,11 @@ import {
Label,
} from '~/components/ui';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/hooks/queries/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, usePrefersMobileLayout } from '~/hooks';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useToastContext, useConfirm } from '~/Providers';
import { formatDate } from '~/utils';
import { Spinner } from '~/components/svg';
const PAGE_SIZE = 25;
@@ -37,7 +35,6 @@ export default function SharedLinks() {
const { showToast } = useToastContext();
const isSmallScreen = usePrefersMobileLayout();
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
@@ -86,8 +83,6 @@ export default function SharedLinks() {
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
setIsDeleteOpen(false);
setDeleteRow(null);
await refetch();
},
onError: (error) => {
@@ -144,14 +139,22 @@ export default function SharedLinks() {
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const confirmDelete = useCallback(() => {
if (deleteRow) {
handleDelete([deleteRow]);
}
setIsDeleteOpen(false);
}, [deleteRow, handleDelete]);
const confirm = useConfirm();
const handleDeleteClick = useCallback(
async (row: SharedLinkItem) => {
const ok = await confirm({
variant: 'destructive',
title: localize('com_ui_delete_shared_link'),
description: `${localize('com_ui_delete_confirm')} "${row.title}"`,
confirmText: localize('com_ui_delete'),
});
if (!ok) {
return;
}
handleDelete([row]);
},
[confirm, localize, handleDelete],
);
const columns = useMemo(
() => [
@@ -247,10 +250,7 @@ export default function SharedLinks() {
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
onClick={() => handleDeleteClick(row.original)}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
@@ -261,7 +261,7 @@ export default function SharedLinks() {
),
},
],
[isSmallScreen, localize],
[isSmallScreen, localize, handleDeleteClick],
);
return (
@@ -294,31 +294,6 @@ export default function SharedLinks() {
/>
</OGDialogContent>
</OGDialog>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
}`,
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
);
}
@@ -1,171 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
getMessageListApi,
markMessageReadApi,
} from "~/api/message";
import { NotificationsDialog } from "./NotificationsDialog";
jest.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "zh-CN" } }),
}));
jest.mock("~/hooks/useLocalize", () => ({
__esModule: true,
default: () => (key: string, vars?: Record<string, string>) => {
const translations: Record<string, string> = {
com_notifications_action_request_menu_access: "申请访问菜单「{{target}}」",
com_notifications_action_approval_task_pending: "提交了「{{target}}」审批申请",
};
const template = translations[key];
if (!template) return key;
return template.replace("{{target}}", vars?.target ?? "");
},
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("~/api/message", () => ({
getMessageListApi: jest.fn(),
markMessageReadApi: jest.fn(),
markAllMessageReadApi: jest.fn(),
deleteMessageApi: jest.fn(),
}));
jest.mock("~/components/ui/Dialog", () => ({
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
jest.mock("~/components/ui/ExpandableSearchField", () => ({
ExpandableSearchField: () => null,
}));
jest.mock("~/components/ui/Tabs", () => ({
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
TabsContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock("~/components/ui/Button", () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>{children}</button>
),
}));
jest.mock("~/components/ui/Avatar", () => ({
Avatar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AvatarImage: () => null,
AvatarName: ({ name }: { name?: string }) => <span>{name}</span>,
}));
jest.mock("~/components/ui/Tooltip", () => ({
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe("NotificationsDialog approval jump", () => {
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation(() => ({
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
});
class MockIntersectionObserver {
observe = jest.fn();
disconnect = jest.fn();
}
(window as any).IntersectionObserver = MockIntersectionObserver;
(global as any).IntersectionObserver = MockIntersectionObserver;
});
it("opens approval center instead of inline approving request messages", async () => {
jest.mocked(getMessageListApi).mockResolvedValue({
total: 1,
data: [{
id: 501,
sender: 7,
sender_name: "Alice",
message_type: "request",
action_code: "request_knowledge_space",
status: "pending",
is_read: false,
create_time: "2026-04-27T10:00:00Z",
update_time: "2026-04-27T10:00:00Z",
content: [{
type: "business_url",
content: "知识空间订阅申请",
metadata: {
business_type: "approval_instance_id",
data: { approval_instance_id: 99 },
},
}],
}],
});
jest.mocked(markMessageReadApi).mockResolvedValue({});
const openApprovalCenter = jest.fn();
render(<NotificationsDialog open onOpenApprovalCenter={openApprovalCenter} />);
expect(await screen.findByText("com_notifications_view_approval")).toBeInTheDocument();
expect(screen.queryByText("com_notifications_accept")).not.toBeInTheDocument();
expect(screen.queryByText("com_notifications_reject")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("com_notifications_view_approval"));
await waitFor(() => {
expect(openApprovalCenter).toHaveBeenCalledWith({
tab: "my_tasks",
taskId: null,
instanceId: 99,
});
});
});
it("uses scenario-specific PRD copy for later approval nodes", async () => {
jest.mocked(getMessageListApi).mockResolvedValue({
total: 1,
data: [{
id: 502,
sender: 7,
sender_name: "站内信",
message_type: "notify",
action_code: "approval_task_pending",
status: "pending",
is_read: false,
create_time: "2026-06-01T10:00:00Z",
update_time: "2026-06-01T10:00:00Z",
content: [
{ type: "system_text", content: "approval_task_pending" },
{
type: "business_url",
content: "--知识空间",
metadata: {
business_type: "approval_instance_id",
scenario_code: "menu_access_request",
data: {
approval_instance_id: "99",
business_name: "知识空间",
scenario_code: "menu_access_request",
},
},
},
],
}],
});
jest.mocked(markMessageReadApi).mockResolvedValue({});
render(<NotificationsDialog open onOpenApprovalCenter={jest.fn()} />);
expect(await screen.findByText(/申请访问菜单/)).toBeInTheDocument();
expect(screen.queryByText(/提交了/)).not.toBeInTheDocument();
});
});
@@ -46,7 +46,11 @@ export const TextToSpeechButton = ({ messageId, text, className }: TextToSpeechB
return `${__APP_ENV__.BASE_URL}${audioPath}`
} catch (error) {
console.error("Failed to fetch audio URL:", error)
throw new Error("Audio generation failed")
// Re-throw as-is (not wrapped) so a backend business error (e.g. TTS
// synthesis failure, code 10026) keeps its status_code — the request
// interceptor already toasted the localized message for those; the
// caller only needs to fall back to a generic toast for other errors.
throw error
}
}
@@ -72,7 +76,14 @@ export const TextToSpeechButton = ({ messageId, text, className }: TextToSpeechB
playAudio(messageId, audioUrl)
} catch (error) {
console.error("Failed to play audio:", error)
showToast({ message: "播放功能不可用,请联系管理员", status: "error" })
// A backend business error (e.g. TTS synthesis failure, code 10026)
// already got its localized toast from the request interceptor
// (skip403Redirect path) — only show the generic fallback here for
// errors that never reached that path (network failure, malformed
// response, etc.), so the user doesn't see two toasts.
if (!(error as any)?.status_code) {
showToast({ message: "播放功能不可用,请联系管理员", status: "error" })
}
// Clean up state on error
if (isCurrentMessage) {
@@ -1,104 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { ApprovalCenterDialog } from "./ApprovalCenterDialog";
import {
getApprovalInstanceDetailApi,
getMyApprovalTaskDetailApi,
listMyApprovalRequestsApi,
listMyApprovalTasksApi,
} from "~/api/approval";
jest.mock("~/hooks/useLocalize", () => ({
__esModule: true,
default: () => (key: string) => key,
}));
const mockShowToast = jest.fn();
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock("~/api/approval", () => ({
getApprovalInstanceDetailApi: jest.fn(),
getMyApprovalTaskDetailApi: jest.fn(),
listMyApprovalRequestsApi: jest.fn(),
listMyApprovalTasksApi: jest.fn(),
decideApprovalTaskApi: jest.fn(),
withdrawApprovalInstanceApi: jest.fn(),
revokeMenuAccessGrantApi: jest.fn(),
}));
jest.mock("~/components/ui/Dialog", () => ({
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
describe("ApprovalCenterDialog", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("does not render a resubmit action for rejected requests", async () => {
jest.mocked(listMyApprovalRequestsApi).mockResolvedValue({
data: [
{
instance_id: 21,
business_name: "知识库申请",
status: "rejected",
},
],
total: 1,
});
jest.mocked(getApprovalInstanceDetailApi).mockResolvedValue({
instance_id: 21,
business_name: "知识库申请",
status: "rejected",
scenario_code: "knowledge_space_subscribe_request",
} as any);
render(
<ApprovalCenterDialog
open
onOpenChange={jest.fn()}
target={{ tab: "my_requests", instanceId: 21 }}
/>,
);
await waitFor(() => {
expect(getApprovalInstanceDetailApi).toHaveBeenCalled();
});
expect(screen.queryByText("com_approval_action_resubmit")).toBeNull();
});
it("selects the my-task matching the target instance id when no task id is provided", async () => {
// Channel/space subscribe approval notifications only carry instance_id (no task_id);
// the dialog must resolve the correct task from instance_id instead of picking the first.
jest.mocked(listMyApprovalTasksApi).mockResolvedValue({
data: [
{ task_id: 901, instance_id: 500, status: "pending", business_name: "频道A" },
{ task_id: 902, instance_id: 777, status: "pending", business_name: "频道B" },
],
total: 2,
});
jest.mocked(getMyApprovalTaskDetailApi).mockResolvedValue({
task_id: 902,
instance_id: 777,
status: "pending",
} as any);
render(
<ApprovalCenterDialog
open
onOpenChange={jest.fn()}
target={{ tab: "my_tasks", instanceId: 777 }}
/>,
);
await waitFor(() => {
expect(getMyApprovalTaskDetailApi).toHaveBeenCalledWith(902);
});
});
});
@@ -1,34 +1,52 @@
import React from 'react';
/**
* "System maintenance" empty-state illustration (database with a wrench).
* "System maintenance" empty-state illustration (magnifier over a bug).
*
* Brand greens re-point to the `--brand-*` palette so the illustration follows
* the blue green theme switch. SVG presentation attributes ignore `var()`,
* so brand fills / strokes are applied via inline `style`
* (see BRAND-THEME-HANDOFF.md §3).
* Brand greens re-point to the `--illus-*` palette so the illustration follows
* the blue green theme switch (and greyscale via the `grey` prop). SVG
* presentation attributes ignore `var()`, so brand fills / strokes are applied
* via inline `style` (see BRAND-THEME-HANDOFF.md §3 / §3.1 / §3.2).
*
* Colour mapping (§5):
* #19B476 (main green) rgb(var(--illus-500))
* #BDE6D3 (light green) rgb(var(--illus-100))
* white kept as-is
* Colour mapping (by lightness §5):
* #19B476 (main green) rgb(var(--illus-500))
* #86DEB8 / #9BDDC1 (mid green) rgb(var(--illus-300))
* #D3EFE3 / #DDF0E8 / #AAE9CE rgb(var(--illus-100))
* white kept as-is
*/
export const SystemMaintenanceIllustration = ({ className, grey, ...props }: React.SVGProps<SVGSVGElement> & { grey?: boolean }) => {
const fill100 = { fill: 'rgb(var(--illus-100))' } as React.CSSProperties;
const fill300 = { fill: 'rgb(var(--illus-300))' } as React.CSSProperties;
const fill500 = { fill: 'rgb(var(--illus-500))' } as React.CSSProperties;
const stroke100 = { stroke: 'rgb(var(--illus-100))' } as React.CSSProperties;
const stroke500 = { stroke: 'rgb(var(--illus-500))' } as React.CSSProperties;
const fill500stroke100 = { fill: 'rgb(var(--illus-500))', stroke: 'rgb(var(--illus-100))' } as React.CSSProperties;
const fill500stroke500 = { fill: 'rgb(var(--illus-500))', stroke: 'rgb(var(--illus-500))' } as React.CSSProperties;
return (
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" className={['brand-illustration', grey && 'illus-grey', className].filter(Boolean).join(' ')} {...props}>
<ellipse cx="181.687" cy="155.079" rx="106.687" ry="23.8642" style={fill500} />
<rect x="74.9999" y="100.332" width="213.374" height="55.2151" style={fill500} />
<circle cx="99.7018" cy="143.766" r="10.266" fill="white" />
<circle opacity="0.6" cx="129.04" cy="149.766" r="10.266" fill="white" />
<ellipse cx="181.687" cy="99.8642" rx="106.687" ry="23.8642" style={fill500} stroke="white" strokeWidth="8" />
<path d="M288.374 223.864H288.352C287.237 236.828 239.909 247.26 181.686 247.26C123.464 247.26 76.1366 236.828 75.0224 223.864H74.9999V168.649H75.0224C76.1372 181.613 123.464 192.046 181.686 192.046C239.909 192.046 287.237 181.613 288.352 168.649H288.374V223.864Z" style={fill100} />
<path d="M288.374 292.181H288.352C287.238 305.145 239.91 315.577 181.686 315.577C123.464 315.577 76.1364 305.145 75.0224 292.181H74.9999V236.966H75.0224C76.137 249.93 123.464 260.363 181.686 260.363C239.909 260.363 287.237 249.93 288.352 236.966H288.374V292.181Z" style={fill100} />
<path d="M174.2 223.925C174.2 251.9 197.034 274.639 224.982 274.521C228.553 274.507 232.035 274.124 235.386 273.404C238.486 272.742 241.719 273.712 243.967 275.963L285.169 317.205C289.694 321.735 295.66 324 301.626 324C307.592 324 313.557 321.735 318.083 317.205C322.609 312.675 324.872 306.703 324.872 300.732C324.872 294.76 322.609 288.788 318.083 284.258L276.778 242.913C274.545 240.678 273.575 237.456 274.222 234.368C274.927 231.014 275.294 227.528 275.294 223.969C275.324 196.067 252.622 173.328 224.747 173.328C221.103 173.328 217.547 173.711 214.109 174.446C213.198 174.637 212.287 174.858 211.39 175.108C207.923 176.064 206.776 180.418 209.318 182.977L211.317 184.977L234.724 208.393C238.104 211.776 239.97 216.306 239.97 221.16C239.97 225.999 238.104 230.529 234.724 233.912C231.345 237.295 226.819 239.163 221.985 239.163C217.15 239.163 212.625 237.295 209.23 233.912L183.839 208.481C181.297 205.937 176.933 207.069 175.978 210.555C175.728 211.452 175.508 212.364 175.317 213.276C174.582 216.718 174.2 220.277 174.2 223.925Z" style={fill500} stroke="white" strokeWidth="8" />
<circle opacity="0.4" cx="99.7018" cy="210.766" r="10.266" fill="white" />
<circle opacity="0.4" cx="99.7018" cy="277.766" r="10.266" fill="white" />
<circle cx="199.564" cy="221.492" r="108.508" style={fill100} />
<path d="M243.805 97.0672C256.64 98.1625 269.476 111.673 269.476 111.673L255.838 135.406C255.838 135.406 232.173 126.643 226.959 118.245C221.745 109.847 230.97 95.972 243.805 97.0672Z" style={fill100} />
<ellipse cx="205.881" cy="320.8" rx="120.4" ry="8.4" style={fill100} />
<path d="M271.87 111.073C292.15 125.045 299.901 132.12 309.359 143.163L310.37 144.35L310.444 144.466C316.432 153.778 318.847 158.829 320.744 166.031L321.117 167.501L321.137 167.579L321.149 167.658C322.551 176.134 322.797 182.534 321.075 187.921C319.323 193.403 315.639 197.528 309.915 201.696L309.913 201.697C294.721 212.726 286.298 217.232 271.588 223.492C266.991 225.946 264.556 227.745 263.36 229.389C262.39 230.723 262.14 232.093 262.611 234.248L262.715 234.69L262.729 234.746C270.058 267.8 270.337 286.463 268.374 319.724L268.258 321.697H141.853L142.38 319.171C148.247 291.094 149.949 275.296 147.146 246.201C146.534 239.856 148.962 234.358 153.559 229.468C158.101 224.636 164.842 220.302 173.137 216.115C189.702 207.752 213.298 199.582 239.922 189.245L240.067 189.189L240.219 189.155C252.964 186.277 260.574 184.368 279.163 177.77C283.174 173.991 284.684 171.529 284.979 169.557C285.262 167.671 284.508 165.69 282.273 162.559C269.863 150.168 262.48 145.109 249.113 136.141L247.647 135.158L248.366 133.546C250.757 128.184 252.837 123.78 255.999 120.097C259.211 116.356 263.422 113.48 269.894 110.857L270.94 110.433L271.87 111.073Z" style={fill500stroke100} strokeWidth="4.19355" />
<ellipse cx="199.564" cy="209.96" rx="29.879" ry="25.6855" fill="white" />
<circle cx="176.681" cy="148.4" r="78.4" fill="white" style={stroke500} strokeWidth="6.29032" />
<path d="M124.08 138.608C124.629 141.427 122.79 144.158 119.971 144.707C117.152 145.257 114.422 143.417 113.872 140.599C113.322 137.78 115.807 129.625 116.985 129.396C118.163 129.166 123.53 135.789 124.08 138.608Z" style={fill300} />
<path d="M208.879 99.4077C209.429 102.226 207.59 104.957 204.771 105.507C201.952 106.056 199.221 104.217 198.672 101.398C198.122 98.5794 200.607 90.4248 201.785 90.1952C202.963 89.9655 208.33 96.5889 208.879 99.4077Z" style={fill300} />
<path d="M229.68 129.808C230.229 132.627 228.39 135.357 225.571 135.907C222.752 136.457 220.022 134.617 219.472 131.798C218.922 128.98 221.407 120.825 222.585 120.595C223.763 120.366 229.13 126.989 229.68 129.808Z" style={fill300} />
<path d="M126.171 134.625C126.635 137.006 125.081 139.313 122.699 139.778C120.318 140.242 118.01 138.688 117.546 136.306C117.082 133.925 119.181 127.035 120.177 126.841C121.172 126.647 125.706 132.243 126.171 134.625Z" fill="white" />
<path d="M211.002 95.5828C211.475 98.0081 209.892 100.358 207.467 100.83C205.041 101.303 202.692 99.7207 202.219 97.2954C201.746 94.8701 203.884 87.854 204.898 87.6564C205.911 87.4588 210.529 93.1575 211.002 95.5828Z" fill="white" />
<path d="M231.78 125.875C232.248 128.27 230.684 130.591 228.289 131.058C225.894 131.525 223.573 129.962 223.106 127.566C222.639 125.171 224.751 118.241 225.752 118.046C226.753 117.851 231.313 123.479 231.78 125.875Z" fill="white" />
<path d="M170.54 261.855C171.74 285.855 169.664 295.588 166.54 319.055" style={stroke100} strokeWidth="6.29032" strokeLinecap="round" />
<ellipse cx="141.207" cy="165.992" rx="3.77185" ry="7.33871" transform="rotate(73.507 141.207 165.992)" style={fill500} />
<ellipse cx="177.424" cy="159.413" rx="7.355" ry="3.63321" transform="rotate(-9.93375 177.424 159.413)" style={fill500} />
<path d="M179.573 179.108C167.9 185.591 161.258 186.446 149.325 185.179C149.325 185.179 155.13 202.809 169.684 198.952C184.239 195.094 179.573 179.108 179.573 179.108Z" style={fill500stroke500} strokeWidth="4.19355" strokeLinejoin="round" />
<path d="M134.701 185.323L133.501 190.923M127.501 186.123L126.701 189.723" style={stroke100} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M205.991 168.548L204.791 174.148M198.791 169.348L197.991 174.148M212.391 169.748L211.591 172.948" style={stroke100} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="101.081" cy="255.2" r="13.2" style={fill500} />
<path d="M81.904 237.37C81.904 240.639 79.2589 243.288 75.9959 243.288C72.733 243.288 70.0879 240.639 70.0879 237.37C70.0879 234.101 72.733 231.452 75.9959 231.452C79.2589 231.452 81.904 234.101 81.904 237.37Z" style={fill300} />
<path d="M299.685 277.953C299.685 281.221 297.04 283.871 293.777 283.871C290.514 283.871 287.869 281.221 287.869 277.953C287.869 274.684 290.514 272.034 293.777 272.034C297.04 272.034 299.685 274.684 299.685 277.953Z" style={fill300} />
<circle cx="285.481" cy="89.1999" r="5.6" style={fill500} />
</svg>
);
};
@@ -1,36 +0,0 @@
import { render, screen } from "@testing-library/react";
import { PermissionDialog } from "./PermissionDialog";
jest.mock("~/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog", () => ({
KnowledgeSpaceShareDialog: ({
resourceType,
resourceId,
resourceName,
showShareTab,
showMembersTab,
showPermissionTab,
}: any) => (
<div>
{`share-dialog:${resourceType}:${resourceId}:${resourceName}:${showShareTab}:${showMembersTab}:${showPermissionTab}`}
</div>
),
}));
describe("PermissionDialog", () => {
it("uses the shared subject-scoped permission dialog", () => {
render(
<PermissionDialog
open
onOpenChange={jest.fn()}
resourceType="channel"
resourceId="channel-1"
resourceName="Channel 1"
/>,
);
expect(
screen.getByText("share-dialog:channel:channel-1:Channel 1:false:false:true"),
).toBeInTheDocument();
});
});
@@ -1,382 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
authorizeResource,
getGrantableRelationModels,
getResourcePermissions,
getResourceGrantDepartmentChildren,
searchResourceGrantDepartments,
getResourceGrantUserGroups,
getResourceGrantUsers,
} from "~/api/permission";
import { PermissionGrantTab } from "./PermissionGrantTab";
const deptNode = (
id: number,
name: string,
parent_id: number | null,
path: string,
has_children: boolean,
) => ({
id,
dept_id: `dept-${id}`,
name,
parent_id,
path,
has_children,
matched: false,
children: [] as any[],
});
const emptyDeptSearch = { roots: [], total_matches: 0, truncated: false };
const mockLocalize = (key: string) => key;
jest.mock("~/hooks", () => ({
useLocalize: () => mockLocalize,
usePrefersMobileLayout: () => false,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("~/api/permission", () => ({
authorizeResource: jest.fn(),
getGrantableRelationModels: jest.fn(),
getResourcePermissions: jest.fn(),
getResourceGrantDepartmentChildren: jest.fn(),
searchResourceGrantDepartments: jest.fn(),
getResourceGrantUserGroups: jest.fn(),
getResourceGrantUsers: jest.fn(),
}));
const mockedAuthorizeResource = jest.mocked(authorizeResource);
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
const mockedGetResourcePermissions = jest.mocked(getResourcePermissions);
const mockedDeptChildren = jest.mocked(getResourceGrantDepartmentChildren);
const mockedDeptSearch = jest.mocked(searchResourceGrantDepartments);
const mockedGetResourceGrantUserGroups = jest.mocked(getResourceGrantUserGroups);
const mockedGetResourceGrantUsers = jest.mocked(getResourceGrantUsers);
describe("PermissionGrantTab", () => {
beforeAll(() => {
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
disconnect = jest.fn();
observe = jest.fn();
takeRecords = jest.fn(() => []);
unobserve = jest.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
configurable: true,
value: IntersectionObserverMock,
});
class ResizeObserverMock implements ResizeObserver {
disconnect = jest.fn();
observe = jest.fn();
unobserve = jest.fn();
}
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
configurable: true,
value: ResizeObserverMock,
});
if (!window.PointerEvent) {
Object.defineProperty(window, "PointerEvent", {
configurable: true,
value: MouseEvent,
});
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = jest.fn();
}
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = jest.fn(() => false);
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = jest.fn();
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = jest.fn();
}
});
beforeEach(() => {
jest.clearAllMocks();
mockedAuthorizeResource.mockResolvedValue(null);
mockedGetResourcePermissions.mockResolvedValue([]);
mockedGetGrantableRelationModels.mockResolvedValue([
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
]);
mockedGetResourceGrantUsers.mockResolvedValue([]);
mockedDeptChildren.mockResolvedValue([deptNode(7, "测试部门", null, "/7/", false)] as any);
mockedDeptSearch.mockResolvedValue(emptyDeptSearch as any);
mockedGetResourceGrantUserGroups.mockResolvedValue([]);
});
const channelRelationModels = [
{
id: "owner",
name: "Owner",
relation: "owner",
permissions: [],
is_system: true,
},
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
{
id: "editor",
name: "Editor",
relation: "editor",
permissions: [],
is_system: true,
},
{
id: "manager",
name: "Manager",
relation: "manager",
permissions: [],
is_system: true,
},
] as const;
async function openRelationSelect() {
const trigger = screen.getByRole("combobox");
trigger.focus();
fireEvent.keyDown(trigger, {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
});
return await screen.findByRole("listbox");
}
it("keeps owner grant level available for channel user grants", async () => {
render(
<PermissionGrantTab
resourceType="channel"
resourceId="channel-1"
onSuccess={jest.fn()}
prefetchedGrantableModels={[...channelRelationModels]}
prefetchedGrantableModelsLoaded
skipGrantableModelsRequest
fixedSubjectType="user"
/>,
);
const listbox = await openRelationSelect();
expect(listbox).toHaveTextContent("com_permission.level_owner");
});
it.each([
["department", "部门"],
["user_group", "用户组"],
] as const)("hides owner grant level for channel %s grants", async (subjectType) => {
render(
<PermissionGrantTab
resourceType="channel"
resourceId="channel-1"
onSuccess={jest.fn()}
prefetchedGrantableModels={[...channelRelationModels]}
prefetchedGrantableModelsLoaded
skipGrantableModelsRequest
fixedSubjectType={subjectType}
/>,
);
const listbox = await openRelationSelect();
expect(listbox).not.toHaveTextContent("com_permission.level_owner");
expect(listbox).toHaveTextContent("com_permission.level_viewer");
});
it("submits the current include-children checkbox value for department grants", async () => {
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
fireEvent.click(await screen.findByText("测试部门"));
fireEvent.click(screen.getByRole("checkbox", { name: "com_permission.include_children" }));
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"knowledge_space",
"space-1",
[
{
subject_type: "department",
subject_id: 7,
relation: "viewer",
model_id: "viewer",
include_children: false,
},
],
[],
);
});
expect(mockedDeptChildren).toHaveBeenCalledWith(
"knowledge_space",
"space-1",
null,
{ signal: expect.any(AbortSignal) },
);
});
it("summarizes only the explicitly picked department, not its descendants — decision 10", async () => {
// 测试部门 has children, but include-children coverage is conveyed by the flag;
// the summary must not enumerate descendants client-side.
mockedDeptChildren.mockResolvedValue([deptNode(7, "测试部门", null, "/7/", true)] as any);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
fireEvent.click(await screen.findByText("测试部门"));
await waitFor(() => {
// The picked department appears both as a tree row and as a summary chip.
expect(screen.getAllByText("测试部门").length).toBeGreaterThan(1);
});
// No materialized descendant label is ever produced.
expect(screen.queryByText("测试部门/子部门")).not.toBeInTheDocument();
});
it("marks already granted departments as disabled without selecting them again", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "department",
subject_id: 7,
subject_name: "测试部门",
relation: "viewer",
include_children: false,
},
] as any);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
const departmentLabel = await screen.findByText("测试部门");
const checkbox = departmentLabel.parentElement?.querySelector('[role="checkbox"]');
await waitFor(() => {
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
});
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
fireEvent.click(departmentLabel);
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
});
it("marks already granted users as disabled without selecting them again", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 8,
subject_name: "Alice",
relation: "viewer",
},
] as any);
mockedGetResourceGrantUsers.mockResolvedValue([
{ user_id: 8, user_name: "Alice" },
]);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
const userLabel = await screen.findByText("Alice");
// The already-granted user row has a single checkbox; the name span is nested
// deeper than its sibling checkbox, so query the row's checkbox by role.
const checkbox = screen.getByRole("checkbox");
await waitFor(() => {
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
});
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
fireEvent.click(userLabel);
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
});
it("marks already granted user groups as disabled without selecting them again", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user_group",
subject_id: 9,
subject_name: "测试用户组",
relation: "viewer",
},
] as any);
mockedGetResourceGrantUserGroups.mockResolvedValue([
{ id: 9, group_name: "测试用户组" },
]);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_user_group" }));
const userGroupLabel = await screen.findByText("测试用户组");
const checkbox = userGroupLabel.parentElement?.querySelector('[role="checkbox"]');
await waitFor(() => {
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
});
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
fireEvent.click(userGroupLabel);
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
});
});
@@ -1,443 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
authorizeResource,
getGrantableRelationModels,
getResourcePermissions,
} from "~/api/permission";
import { PermissionListTab } from "./PermissionListTab";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
useConfirm: () => jest.fn().mockResolvedValue(true),
}));
jest.mock("~/api/permission", () => ({
authorizeResource: jest.fn(),
getGrantableRelationModels: jest.fn(),
getResourcePermissions: jest.fn(),
}));
jest.mock("~/components/ui/Avatar", () => ({
Avatar: ({ children }: any) => <div>{children}</div>,
AvatarName: ({ name }: any) => <div>{name}</div>,
}));
jest.mock("~/components/ui/DropdownMenu", () => ({
DropdownMenu: ({ children }: any) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: any) => <div>{children}</div>,
DropdownMenuContent: ({ children }: any) => <div>{children}</div>,
DropdownMenuItem: ({ children, onSelect, ...props }: any) => (
<button type="button" onClick={onSelect} {...props}>{children}</button>
),
DropdownMenuSeparator: () => <div />,
}));
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
const mockedGetResourcePermissions = jest.mocked(getResourcePermissions);
const mockedAuthorizeResource = jest.mocked(authorizeResource);
describe("Client PermissionListTab", () => {
beforeEach(() => {
jest.clearAllMocks();
mockedAuthorizeResource.mockResolvedValue(null);
mockedGetGrantableRelationModels.mockResolvedValue([
{
id: "owner",
name: "Owner",
relation: "owner",
permissions: [],
is_system: true,
},
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
{
id: "editor",
name: "Editor",
relation: "editor",
permissions: [],
is_system: true,
},
]);
});
it("keeps the last owner row read-only", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "owner",
model_id: "owner",
model_name: "Owner",
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="space-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
it("shows owner actions when another owner remains", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "owner",
model_id: "owner",
model_name: "Owner",
},
{
subject_type: "user",
subject_id: 3,
subject_name: "Bob",
relation: "owner",
model_id: "owner",
model_name: "Owner",
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="space-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
expect(screen.getAllByLabelText("com_permission.remove")).toHaveLength(2);
});
it("hides the owner option for a user group entry", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user_group",
subject_id: 9,
subject_name: "zz",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user_group"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("zz").length).toBeGreaterThan(0);
});
expect(screen.queryByText("com_permission.level_owner")).not.toBeInTheDocument();
expect(screen.getByText("com_permission.level_editor")).toBeInTheDocument();
});
it("keeps the owner option for a user entry", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 9,
subject_name: "Carol",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Carol").length).toBeGreaterThan(0);
});
expect(screen.getByText("com_permission.level_owner")).toBeInTheDocument();
});
it("deletes all relations for the selected subject", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "editor",
model_id: "editor",
model_name: "Editor",
},
{
subject_type: "user",
subject_id: 3,
subject_name: "Bob",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_file"
resourceId="file-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getAllByLabelText("com_permission.remove")[0]);
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"knowledge_file",
"file-1",
[],
[
{
subject_type: "user",
subject_id: 2,
relation: "viewer",
},
{
subject_type: "user",
subject_id: 2,
relation: "editor",
},
],
);
});
});
it("updates the binding without a revoke when only the model changes (same relation)", async () => {
mockedGetGrantableRelationModels.mockResolvedValue([
{ id: "viewer", name: "Viewer", relation: "viewer", permissions: [], is_system: true },
{ id: "custom-viewer", name: "自定义查看", relation: "viewer", permissions: [], is_system: false },
] as any);
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByText("自定义查看"));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"channel",
"channel-1",
[
{
subject_type: "user",
subject_id: 2,
relation: "viewer",
model_id: "custom-viewer",
},
],
[],
);
});
});
it("revokes the old relation when the model change also changes the relation", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByText("com_permission.level_editor"));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"channel",
"channel-1",
[
{
subject_type: "user",
subject_id: 2,
relation: "editor",
model_id: "editor",
},
],
[
{
subject_type: "user",
subject_id: 2,
relation: "viewer",
},
],
);
});
});
it("deletes department include-children grants across subtree and exact variants", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "department",
subject_id: 7,
subject_name: "研发部",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
include_children: true,
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="space-1"
refreshKey={0}
fixedSubjectType="department"
/>,
);
await waitFor(() => {
expect(screen.getByText("研发部")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("com_permission.remove"));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"knowledge_space",
"space-1",
[],
[
{
subject_type: "department",
subject_id: 7,
relation: "viewer",
include_children: true,
},
{
subject_type: "department",
subject_id: 7,
relation: "viewer",
include_children: false,
},
],
);
});
});
it("uses an injected permission API instead of generic resource endpoints", async () => {
const permissionApi = {
getPermissions: jest.fn().mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
]),
authorize: jest.fn(),
getGrantableRelationModels: jest.fn().mockResolvedValue([
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
]),
};
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
permissionApi={permissionApi as any}
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
expect(permissionApi.getPermissions).toHaveBeenCalledWith("channel", "channel-1");
expect(permissionApi.getGrantableRelationModels).toHaveBeenCalledWith("channel", "channel-1");
expect(mockedGetResourcePermissions).not.toHaveBeenCalled();
expect(mockedGetGrantableRelationModels).not.toHaveBeenCalled();
});
});
@@ -92,6 +92,10 @@ interface PermissionListTabProps {
// and locks the list to the given subject type.
fixedSubjectType?: ListSubjectType;
permissionApi?: PermissionApiAdapter;
// When set, the current user's own row is locked (no modify/remove): you cannot
// change your own permission, which would strip your management access and lock
// you out of the dialog.
currentUserId?: string | number;
onChanged?: () => void;
}
@@ -118,6 +122,7 @@ export function PermissionListTab({
skipGrantableModelsRequest = false,
fixedSubjectType,
permissionApi,
currentUserId,
onChanged,
}: PermissionListTabProps) {
const localize = useLocalize();
@@ -551,8 +556,15 @@ export function PermissionListTab({
// The channel creator's permission level is permanent: never
// show the modify/remove dropdown — render the level as static text.
const isCreatorEntry = entry.is_creator === true;
const canModifyEntry = !isCreatorEntry && canManageEntry(entry) && entryModelOptions.length > 0;
const canDeleteEntrySubject = !isCreatorEntry && canDeleteSubject(entry);
// You cannot modify/remove your OWN permission row — that would
// strip your management access and lock you out of the dialog.
const isSelfEntry =
entry.subject_type === "user" &&
currentUserId != null &&
String(entry.subject_id) === String(currentUserId);
const canModifyEntry =
!isCreatorEntry && !isSelfEntry && canManageEntry(entry) && entryModelOptions.length > 0;
const canDeleteEntrySubject = !isCreatorEntry && !isSelfEntry && canDeleteSubject(entry);
const displayName = getEntryDisplayName(entry);
const entryCaption = getEntryCaption(entry);
@@ -1,166 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import {
getResourceGrantDepartmentChildren,
searchResourceGrantDepartments,
} from "~/api/permission";
import type { SelectedSubject } from "~/api/permission";
import { SubjectSearchDepartment } from "./SubjectSearchDepartment";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
// F038: the client picker is now a lazy tree fed by the resource-scoped grant
// endpoints (children / search). No full-tree load.
jest.mock("~/api/permission", () => ({
getResourceGrantDepartmentChildren: jest.fn(),
searchResourceGrantDepartments: jest.fn(),
}));
const mockedChildren = jest.mocked(getResourceGrantDepartmentChildren);
const mockedSearch = jest.mocked(searchResourceGrantDepartments);
const node = (
id: number,
name: string,
parent_id: number | null,
path: string,
has_children: boolean,
matched = false,
) => ({
id,
dept_id: `dept-${id}`,
name,
parent_id,
path,
has_children,
matched,
children: [] as any[],
});
const emptySearch = { roots: [], total_matches: 0, truncated: false };
describe("SubjectSearchDepartment (lazy, F038 decision 9/10)", () => {
beforeEach(() => {
jest.clearAllMocks();
// Root layer = 全集团 (has children); search returns its pruned subtree.
mockedChildren.mockResolvedValue([node(1, "全集团", null, "/1/", true)] as any);
mockedSearch.mockResolvedValue(emptySearch as any);
});
it("lazy-loads the grant root layer via the resource-scoped children endpoint", async () => {
render(
<SubjectSearchDepartment
value={[]}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
/>,
);
await waitFor(() => {
expect(mockedChildren).toHaveBeenCalledWith("workflow", "wf-1", null, {
signal: expect.any(AbortSignal),
});
});
expect(await screen.findByText("全集团")).toBeInTheDocument();
});
it("shows descendants as checked + disabled (implicit) when an ancestor grant includes children — decision 9 (path-based)", async () => {
mockedSearch.mockResolvedValue({
roots: [
{ ...node(1, "全集团", null, "/1/", true), children: [node(2, "子部门", 1, "/1/2/", false, true)] },
],
total_matches: 1,
truncated: false,
} as any);
const value: SelectedSubject[] = [
{ type: "department", id: 1, name: "全集团", include_children: true },
];
render(
<SubjectSearchDepartment
value={value}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
/>,
);
// Root load primes the ancestor's path so implicit selection resolves.
await screen.findByText("全集团");
fireEvent.change(screen.getByPlaceholderText("com_permission.search_department"), {
target: { value: "子部门" },
});
const childLabel = await screen.findByText("子部门");
const childCheckbox = within(childLabel.parentElement as HTMLElement).getByRole("checkbox");
expect(childCheckbox).toHaveAttribute("data-state", "checked");
expect(childCheckbox).toBeDisabled();
});
it("summarizes only the explicit picks, never the materialized subtree — decision 10", async () => {
const onSelectionSummaryChange = jest.fn();
render(
<SubjectSearchDepartment
value={[{ type: "department", id: 1, name: "全集团", include_children: true }]}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
onSelectionSummaryChange={onSelectionSummaryChange}
/>,
);
await waitFor(() => {
expect(onSelectionSummaryChange).toHaveBeenLastCalledWith([
{ type: "department", id: 1, name: "全集团", include_children: true },
]);
});
});
it("shows already granted departments as disabled and unchecked without selecting them", async () => {
render(
<SubjectSearchDepartment
value={[]}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
disabledIds={[1]}
/>,
);
const departmentLabel = await screen.findByText("全集团");
const checkbox = within(departmentLabel.parentElement as HTMLElement).getByRole("checkbox");
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
});
it("adds a department carrying the current include-children flag when toggled on", async () => {
const onChange = jest.fn();
render(
<SubjectSearchDepartment
value={[]}
onChange={onChange}
resourceType="workflow"
resourceId="wf-1"
includeChildren
/>,
);
fireEvent.click(await screen.findByText("全集团"));
expect(onChange).toHaveBeenCalledWith([
{ type: "department", id: 1, name: "全集团", include_children: true },
]);
});
});
@@ -0,0 +1,93 @@
/**
* Shared "title + free-text textarea + cancel/submit" dialog.
*
* Extracted from MessageFeedbackButtons' dislike-reason dialog so other
* surfaces (e.g. menu-permission apply on MenuUnavailablePage) reuse the
* exact same shell. Anatomy: the container carries no padding; header /
* body / footer each own px-5 (20px). Mobile: width calc(100%-48px),
* centered title, full-width button pair.
*
* The textarea is uncontrolled and reset on every open, so every close
* path (cancel / ESC / overlay click) discards the draft. Submitting does
* NOT auto-close the caller owns `open` and closes when its side effect
* settles (sync callers close immediately; async ones close on success).
*/
import { useEffect, useRef } from 'react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './Dialog';
import { Textarea } from './Textarea';
import { Button } from './Button';
import { useLocalize } from '~/hooks';
interface CommentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
placeholder?: string;
/** Disables the submit button and swaps its label while an async submit runs. */
submitting?: boolean;
/** Submit-button label while `submitting` (defaults to the normal submit label). */
submittingText?: string;
/** Receives the trimmed textarea content ('' when left blank). */
onSubmit: (comment: string) => void;
}
export function CommentDialog({
open,
onOpenChange,
title,
placeholder,
submitting = false,
submittingText,
onSubmit,
}: CommentDialogProps) {
const localize = useLocalize();
const commentRef = useRef<HTMLTextAreaElement | null>(null);
// Reset the draft on every open so all close paths discard it alike.
useEffect(() => {
if (open && commentRef.current) {
commentRef.current.value = '';
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* Don't auto-focus the textarea on open no focus ring until the user clicks in.
Anatomy: container carries no padding; header / body / footer each own px-5 (20px). */}
<DialogContent
className="w-[calc(100%-48px)] sm:w-full sm:max-w-[425px] rounded-xl sm:rounded-xl p-0 gap-0"
onOpenAutoFocus={(e) => e.preventDefault()}
close={false}
>
<DialogHeader className="px-5 py-4 text-center sm:text-left">
<DialogTitle className="text-base leading-6 text-[#212121]">{title}</DialogTitle>
</DialogHeader>
<div className="px-5">
{/* Focus chrome mirrors the app-center search box (ExpandableSearchField). */}
<Textarea
ref={commentRef}
maxLength={9999}
placeholder={placeholder}
className="bg-white border-[#E5E6EB] shadow-none transition-[border-color,box-shadow] duration-200 focus:border-[#DDDDDD] focus:shadow-[0_0_0_2px_#F1F5F9] placeholder:text-sm placeholder:text-[#999]"
/>
</div>
<DialogFooter className="flex-row justify-end gap-3 space-x-0 sm:space-x-0 px-5 py-4">
<Button
className="h-8 px-4 rounded-md text-sm font-normal flex-1 sm:flex-none"
variant="outline"
onClick={() => onOpenChange(false)}
>
{localize('com_ui_cancel')}
</Button>
<Button
className="h-8 px-4 rounded-md text-sm font-normal flex-1 sm:flex-none"
disabled={submitting}
onClick={() => onSubmit(commentRef.current?.value?.trim() ?? '')}
>
{submitting ? (submittingText ?? localize('com_ui_submit')) : localize('com_ui_submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -136,7 +136,13 @@ const SelectItem = React.forwardRef<
</SelectPrimitive.ItemIndicator>
</span>
)}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{/* Radix ItemText silently drops className/style, so the shrink constraint
lives on this wrapper: as a flex child it must be allowed to go below
its content width, otherwise long content overflows past the pr-8
indicator area instead of truncating before the check mark. */}
<div className="min-w-0 flex-1">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</div>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
@@ -2,6 +2,7 @@ export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Checkbox';
export * from './CommentDialog';
export * from './DataTableColumnHeader';
export * from './Dialog';
export * from './ExpandableSearchField';
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { userInputLinsightEvent, userStopLinsightEvent } from "~/api/linsight";
import { getLinsightSessionVersionList, userInputLinsightEvent, userStopLinsightEvent } from "~/api/linsight";
import { SopStatus } from "~/store/linsight";
import { useToastContext } from "~/Providers";
import { toggleNav } from "~/utils";
@@ -317,6 +317,27 @@ export const useLinsightWebSocket = (versionId) => {
status: SopStatus.completed
})
toggleNav(true)
// Live only: the task answer is now a persisted category="task"
// ChatMessage. Pull its real id (+ liked verdict) into the store so
// like/dislike targets the right row the moment the result panel
// appears — without this the panel showed while the message still
// held its streaming placeholder id, so a like clicked before a
// reload was lost.
// The version-list already carries message_id (backend enrichment);
// history hydration seeds the store from the same endpoint.
{
const sessionId = getLinsight(id)?.session_id;
if (sessionId) {
getLinsightSessionVersionList(sessionId, '')
.then((list: any[]) => {
const v = (list || []).find((x) => String(x.id) === String(id));
if (v?.message_id != null) {
updateLinsight(id, { message_id: v.message_id, liked: v.liked });
}
})
.catch(() => { /* best-effort: a reload seeds it from the same endpoint */ });
}
}
break;
case 'task_terminated':
+1
View File
@@ -24,6 +24,7 @@ export { default as useNewConvo } from './useNewConvo';
export { default as useLocalize } from './useLocalize';
export type { TranslationKeys } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';
export { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from './useContainerCompact';
export { default as usePrefersMobileLayout } from './usePrefersMobileLayout';
export { default as useScrollToRef } from './useScrollToRef';
export { useScrollRevealRef } from './useScrollRevealRef';
@@ -73,14 +73,20 @@ export default function useChannelChat(articleDocId: string) {
return msgs;
});
},
onFinal: (fullText) => {
onFinal: (fullText, realMessageId) => {
setMessages((prev) => {
const msgs = [...prev];
const idx = msgs.findIndex(
(m) => m.messageId === responseMessageId
);
if (idx >= 0) {
msgs[idx] = { ...msgs[idx], text: fullText };
// Swap the temporary placeholder id for the real persisted
// ChatMessage id so like/dislike targets the right row before a reload.
msgs[idx] = {
...msgs[idx],
text: fullText,
...(realMessageId != null && { messageId: String(realMessageId) }),
};
}
return msgs;
});
@@ -0,0 +1,48 @@
import { useCallback, useRef, useState } from 'react';
/**
* Width (px) below which the input toolbars collapse their button labels to
* icons. Sized for the WIDEST locale (English labels "Knowledge Space",
* "Task mode" are far longer than the Chinese ones), so labels collapse as a
* group before any single one truncates, in every language. Tune here once;
* both toolbars (AiChatInput, TaskModeInput) read this same value.
*/
export const TOOLBAR_COMPACT_THRESHOLD = 440;
/**
* Report whether a container's inline width has dropped below `threshold`,
* measured live via a ResizeObserver.
*
* Input toolbars collapse their button labels to icon-only when space runs
* out. Viewport media queries are the wrong signal for that: the same viewport
* width leaves very different room once the sidebar opens. This hook measures
* the ACTUAL space available (the flex-1 toolbar column), so labels collapse
* exactly when the box that holds them can no longer fit them.
*
* The observed element must be layout-sized (e.g. `flex-1`), NOT content-sized:
* its width is then decided by the row, independent of whether labels are shown,
* so hiding labels can't feed back into the measurement and oscillate.
*
* Returns a callback ref (re-attaches the observer across conditional remounts,
* which a deps-based effect would miss) plus the current `compact` flag.
*/
export function useContainerCompact(threshold: number) {
const [compact, setCompact] = useState(false);
const observerRef = useRef<ResizeObserver | null>(null);
const ref = useCallback(
(el: HTMLElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (!el) return;
const update = () => setCompact(el.clientWidth < threshold);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
observerRef.current = ro;
},
[threshold],
);
return { ref, compact };
}
+10 -2
View File
@@ -65,13 +65,21 @@ export default function useFileChat(spaceId: string, fileId: string) {
return msgs;
});
},
onFinal: (fullText) => {
onFinal: (fullText, realMessageId) => {
setMessages((prev) => {
const msgs = [...prev];
const idx = msgs.findIndex(
(m) => m.messageId === responseMessageId
);
if (idx >= 0) msgs[idx] = { ...msgs[idx], text: fullText };
if (idx >= 0) {
// Swap the temporary placeholder id for the real persisted
// ChatMessage id so like/dislike targets the right row before a reload.
msgs[idx] = {
...msgs[idx],
text: fullText,
...(realMessageId != null && { messageId: String(realMessageId) }),
};
}
return msgs;
});
},
+10 -2
View File
@@ -152,13 +152,21 @@ export default function useFolderChat(
return msgs;
});
},
onFinal: (fullText) => {
onFinal: (fullText, realMessageId) => {
setMessages((prev) => {
const msgs = [...prev];
const idx = msgs.findIndex(
(m) => m.messageId === responseMessageId
);
if (idx >= 0) msgs[idx] = { ...msgs[idx], text: fullText };
if (idx >= 0) {
// Swap the temporary placeholder id for the real persisted
// ChatMessage id so like/dislike targets the right row before a reload.
msgs[idx] = {
...msgs[idx],
text: fullText,
...(realMessageId != null && { messageId: String(realMessageId) }),
};
}
return msgs;
});
},
@@ -30,7 +30,7 @@ export interface StreamChatSSESubmission {
*/
onMessage: (text: string) => void;
/** Called when the stream ends (type: "end") with final full text */
onFinal: (text: string) => void;
onFinal: (text: string, messageId?: string | number) => void;
/** Called on connection or parse errors */
onError: (error: string) => void;
/** Called when the SSE lifecycle is fully done */
@@ -80,8 +80,10 @@ export default function useStreamChatSSE(
if (data.type === "end") {
// Stream complete — skip content (it's the full duplicate),
// send final accumulated text
onFinal(buildFullText());
// send final accumulated text plus the real persisted answer id
// (backend end event) so the caller can swap out the temporary
// placeholder id and feedback/like targets the right row.
onFinal(buildFullText(), data?.message?.message_id);
onEnd();
return;
}
@@ -333,6 +333,7 @@
"com_error_no_user_key": "No key found. Please provide a key and try again.",
"com_error_retry": "Retry",
"com_feedback_required": "Feedback cannot be empty",
"com_feedback_placeholder": "Share your suggestions — we'll keep improving (optional)",
"com_feedback_title": "Feedback",
"com_file_content_exceed_tokens": "File content exceeds 30k tokens",
"com_file_current_empty": "Current file is empty",
@@ -420,6 +421,7 @@
"com_linsight_clarify_single": "Single choice",
"com_linsight_clarify_multi": "Multiple choice",
"com_linsight_clarify_custom": "Type your own",
"com_linsight_clarify_custom_active": "Type your own — Shift+Enter for a new line",
"com_linsight_clarify_input_placeholder": "Type here…",
"com_linsight_clarify_skip": "Skip",
"com_linsight_clarify_skipped": "Skipped",
@@ -1286,6 +1288,7 @@
"10023": "Only TTS-type models are supported. {{model_type}} is not supported",
"10024": "TTS model {{model_name}} under {{server_name}} is offline",
"10025": "Failed to initialize TTS: {{exception}}",
"10026": "Speech synthesis failed. Please try again later",
"10030": "System configuration cannot be empty",
"10031": "Invalid system configuration: {{exception}}",
"10040": "Uploaded file cannot be empty",
@@ -1523,8 +1526,8 @@
"empty_go_explore": "No apps used yet. Head to the app marketplace",
"explore_more": "Explore more apps",
"recent_apps_hint": "Your recently used apps are all here",
"service_maintenance_title": "System under maintenance",
"service_maintenance": "We're performing emergency maintenance; service will be back soon.",
"service_maintenance_title": "Oops, we hit a small hiccup",
"service_maintenance": "Hang tight for a moment — we'll be back to normal shortly.",
"refresh": "Refresh to retry"
},
"com_knowledge": {
@@ -319,6 +319,7 @@
"com_error_no_user_key": "キーが見つかりません。キーを提供して再試行してください。",
"com_error_retry": "再試行",
"com_feedback_required": "フィードバックは空にできません",
"com_feedback_placeholder": "ご意見をお聞かせください。今後の改善に活かします(任意)",
"com_feedback_title": "フィードバック",
"com_file_content_exceed_tokens": "ファイル内容が 3 万トークンを超えています",
"com_file_current_empty": "現在のファイルは空です",
@@ -405,6 +406,7 @@
"com_linsight_clarify_single": "単一選択",
"com_linsight_clarify_multi": "複数選択",
"com_linsight_clarify_custom": "自由入力",
"com_linsight_clarify_custom_active": "自由入力(Shift + Enter で改行)",
"com_linsight_clarify_input_placeholder": "入力してください…",
"com_linsight_clarify_skip": "スキップ",
"com_linsight_clarify_skipped": "スキップ",
@@ -1210,6 +1212,7 @@
"10023": "TTSタイプのみサポートしています",
"10024": "TTSモデルがオフラインです",
"10025": "TTS初期化に失敗しました:{{exception}}",
"10026": "音声合成に失敗しました。しばらくしてからもう一度お試しください",
"10030": "システム設定は必須です",
"10031": "システム設定の形式が不正です:{{exception}}",
"10040": "アップロードファイルが空です",
@@ -1447,8 +1450,8 @@
"empty_go_explore": "利用したアプリはまだありません。アプリ広場へどうぞ",
"explore_more": "さらにアプリを探す",
"recent_apps_hint": "最近使用したアプリはすべてここにあります~",
"service_maintenance_title": "システムメンテナンス中",
"service_maintenance": "緊急メンテナンスを実施中です。まもなく復旧します。",
"service_maintenance_title": "おっと、システムに少し問題が発生しました",
"service_maintenance": "少々お待ちください。まもなく通常どおりご利用いただけます。",
"refresh": "再読み込み"
},
"com_knowledge": {
@@ -322,6 +322,7 @@
"com_error_no_user_key": "没有找到密钥。请提供密钥后重试。",
"com_error_retry": "重试",
"com_feedback_required": "反馈信息不能为空",
"com_feedback_placeholder": "欢迎留下你的建议,我们会持续改进(选填)",
"com_feedback_title": "反馈",
"com_file_content_exceed_tokens": "文件内容超出3万token",
"com_file_current_empty": "当前文件为空",
@@ -408,6 +409,7 @@
"com_linsight_clarify_single": "单选",
"com_linsight_clarify_multi": "多选",
"com_linsight_clarify_custom": "自行输入",
"com_linsight_clarify_custom_active": "自行输入,Shift + Enter 可换行",
"com_linsight_clarify_input_placeholder": "请输入…",
"com_linsight_clarify_skip": "跳过",
"com_linsight_clarify_skipped": "跳过",
@@ -1213,6 +1215,7 @@
"10023": "只支持TTS类型的模型,不支持{{model_type}}类型的模型",
"10024": "{{server_name}}下的{{model_name}}模型已下线,请联系管理员上线对应的模型",
"10025": "初始化tts失败,请检查配置或联系管理员。错误信息:{{exception}}",
"10026": "语音合成失败,请稍后重试",
"10030": "系统配置不能为空",
"10031": "系统配置格式不正确,请检查配置内容:{{exception}}",
"10040": "上传文件不能为空",
@@ -1450,8 +1453,8 @@
"empty_go_explore": "暂无使用过的应用,可以前往应用广场",
"explore_more": "探索更多应用",
"recent_apps_hint": "最近使用过的应用都在这里~",
"service_maintenance_title": "系统维护中",
"service_maintenance": "我们正在进行紧急维护,服务将尽快恢复。",
"service_maintenance_title": "哎呀,系统出了点小状况",
"service_maintenance": "短暂停留一下,马上恢复正常使用",
"refresh": "刷新重试"
},
"com_knowledge": {
@@ -6,6 +6,7 @@ import { NotificationSeverity } from '~/common';
import { useAuthContext, useLocalize } from '~/hooks';
import { WorkbenchEmptyIllustration } from '~/components/workbench/WorkbenchEmptyIllustration';
import { Button } from '~/components/ui/Button';
import { CommentDialog } from '~/components/ui/CommentDialog';
const MENU_LABEL_KEYS: Record<string, string> = {
home: 'com_nav_home',
@@ -49,7 +50,6 @@ export default function MenuUnavailablePage() {
const menuName = pluginId ? localize((MENU_LABEL_KEYS[pluginId] || pluginId) as any) : '';
const [showDialog, setShowDialog] = useState(false);
const [reason, setReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [applied, setApplied] = useState(false);
@@ -67,14 +67,14 @@ export default function MenuUnavailablePage() {
return () => { cancelled = true; };
}, [canApply, pluginId]);
const handleSubmit = async () => {
const handleSubmit = async (reason: string) => {
if (!canApply || !pluginId || submitting) return;
setSubmitting(true);
try {
await applyMenuAccessApi({
menu_key: pluginId,
menu_name: menuName || pluginId,
reason: reason.trim() || undefined,
reason: reason || undefined,
});
setApplied(true);
setShowDialog(false);
@@ -121,43 +121,16 @@ export default function MenuUnavailablePage() {
</Button>
)}
{/* Apply reason dialog */}
{showDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h3 className="mb-4 text-base font-semibold text-gray-900">
{localize('com_menu_unavailable_apply_button')}
</h3>
<label className="mb-1 block text-sm text-gray-600">
{localize('com_menu_unavailable_reason_label')}
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder={localize('com_menu_unavailable_reason_placeholder') as string}
className="mb-4 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 outline-none focus:border-blue-500"
/>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => { setShowDialog(false); setReason(''); }}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{localize('com_ui_cancel')}
</button>
<button
type="button"
disabled={submitting}
onClick={() => void handleSubmit()}
className="rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-60 btn-brand-primary"
>
{submitting ? localize('com_menu_unavailable_apply_submitting') : localize('com_ui_submit')}
</button>
</div>
</div>
</div>
)}
{/* Apply reason dialog — shared shell with the message-feedback dialog. */}
<CommentDialog
open={showDialog}
onOpenChange={(open) => { if (!submitting) setShowDialog(open); }}
title={localize('com_menu_unavailable_apply_button')}
placeholder={localize('com_menu_unavailable_reason_placeholder') as string}
submitting={submitting}
submittingText={localize('com_menu_unavailable_apply_submitting')}
onSubmit={(reason) => void handleSubmit(reason)}
/>
</div>
);
}
@@ -806,7 +806,7 @@ export function ArticleList({
<LoadingIcon className="size-20 text-primary" />
</div>
) : articles.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center py-60 text-center">
<div className="flex flex-1 flex-col items-center justify-center py-8 text-center">
{(searchQuery || selectedSources.length > 0 || onlyUnread) ? (
<>
<EmptyStateIllustration className="size-[120px] mb-4 opacity-90" />
@@ -1,160 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChannelRole } from "~/api/channels";
import type { Channel } from "~/api/channels";
import { ChannelActionsMenu } from "./ChannelActionsMenu";
// react-query is mocked so the component reads channel lists straight from the
// stubbed cache; queryKey[1] is "created" | "subscribed" (see component).
const mockLists: Record<string, Channel[]> = { created: [], subscribed: [] };
jest.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => ({
data: mockLists[queryKey[1] as string] ?? [],
}),
}));
const mockHandleDeleteChannel = jest.fn();
const mockHandleUnsubscribeChannel = jest.fn();
jest.mock("../hooks/useChannelActions", () => ({
useChannelActions: () => ({
handleDeleteChannel: mockHandleDeleteChannel,
handleUnsubscribeChannel: mockHandleUnsubscribeChannel,
}),
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_subscription.channel_settings": "频道设置",
"com_subscription.edit_channel": "编辑频道",
"com_subscription.permission_management": "权限管理",
"com_subscription.share": "分享",
"com_subscription.source_filter": "信息源筛选",
"com_subscription.dissolve_channel": "解散频道",
"com_subscription.delete_channel": "删除频道",
"com_subscription.unsubscribe": "取消订阅",
"com_subscription.prompt_tip": "提示",
"com_subscription.confirm_delete_channel_for_all": "删除频道",
"com_subscription.confirm_unsubscribe_channel_and_subs": "取消订阅",
"com_subscription.confirm": "确认",
"com_subscription.cancel": "取消",
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useConfirm: () => jest.fn().mockResolvedValue(true),
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("bisheng-icons", () => ({
Outlined: new Proxy(
{},
{ get: () => () => <span data-testid="icon" /> },
),
}));
const createChannel = (role: Channel["role"], permissionIds?: string[]): Channel => ({
id: "channel-1",
name: "资讯频道",
creator: "owner",
creatorId: "1",
subscriberCount: 3,
articleCount: 5,
unreadCount: 0,
role,
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
subChannels: [],
permissionIds,
});
function renderMenu(
list: "created" | "subscribed",
role: Channel["role"],
permissionIds?: string[],
) {
const channel = createChannel(role, permissionIds);
mockLists.created = [];
mockLists.subscribed = [];
mockLists[list] = [channel];
const props = {
channel,
onChannelSelect: jest.fn(),
onManageMembers: jest.fn(),
onChannelSettings: jest.fn(),
};
const view = render(<ChannelActionsMenu {...props} />);
return { ...view, props };
}
async function openMenu(container: HTMLElement) {
const user = userEvent.setup();
const trigger = container.querySelector("button");
expect(trigger).not.toBeNull();
await user.click(trigger as HTMLButtonElement);
return user;
}
describe("ChannelActionsMenu permission gating", () => {
beforeEach(() => {
mockHandleDeleteChannel.mockClear();
mockHandleUnsubscribeChannel.mockClear();
});
it("shows channel settings to a granted owner whose channel sits in the followed list", async () => {
const { container } = renderMenu("subscribed", "owner", [
"view_channel",
"edit_channel",
"delete_channel",
"manage_channel_owner",
]);
await openMenu(container);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.getByText("权限管理")).toBeInTheDocument();
});
it("shows both dissolve and unsubscribe to a granted owner in the followed list", async () => {
const { container } = renderMenu("subscribed", "owner", [
"view_channel",
"edit_channel",
"delete_channel",
"manage_channel_owner",
]);
await openMenu(container);
expect(await screen.findByText("解散频道")).toBeInTheDocument();
expect(screen.getByText("取消订阅")).toBeInTheDocument();
});
it("shows channel settings to an editor (edit permission, no delete) in the followed list", async () => {
const { container } = renderMenu("subscribed", "editor", ["view_channel", "edit_channel"]);
await openMenu(container);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
// Editor cannot dissolve (no delete_channel) but can leave.
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
expect(screen.getByText("取消订阅")).toBeInTheDocument();
});
it("hides channel settings from a plain subscriber (viewer)", async () => {
const { container } = renderMenu("subscribed", "viewer", ["view_channel"]);
await openMenu(container);
expect(await screen.findByText("取消订阅")).toBeInTheDocument();
expect(screen.queryByText("频道设置")).not.toBeInTheDocument();
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
});
it("shows only dissolve (no unsubscribe) for the creator's own channel", async () => {
const { container } = renderMenu("created", ChannelRole.CREATOR);
await openMenu(container);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.getByText("解散频道")).toBeInTheDocument();
expect(screen.queryByText("取消订阅")).not.toBeInTheDocument();
});
});
@@ -28,6 +28,8 @@ import {
TabsTrigger,
} from "~/components/ui";
import { useLocalize } from "~/hooks";
import { useRecoilValue } from "recoil";
import store from "~/store";
const CHANNEL_RESOURCE_TYPE = "channel" as const;
@@ -54,6 +56,7 @@ export function ChannelPermissionDialog({
channel,
}: ChannelPermissionDialogProps) {
const localize = useLocalize();
const currentUser = useRecoilValue(store.user);
const queryClient = useQueryClient();
const [refreshKey, setRefreshKey] = useState(0);
const [currentSubjectType, setCurrentSubjectType] = useState<SubjectType>("user");
@@ -175,6 +178,7 @@ export function ChannelPermissionDialog({
resourceId={channel.id}
refreshKey={refreshKey}
fixedSubjectType={currentSubjectType}
currentUserId={currentUser?.id}
prefetchedGrantableModels={grantableModels}
prefetchedGrantableModelsLoaded={grantableModelsLoaded}
skipGrantableModelsRequest
@@ -397,7 +397,7 @@ export function AddSourceDropdown({
{mgr.viewMode === "list" && (
<>
{displayList.length === 0 ? (
<div className="p-8 text-center text-[14px] text-[#86909C]">{localize("com_subscription.no_data")}</div>
<div className="flex min-h-full items-center justify-center p-8 text-center text-[14px] text-[#86909C]">{localize("com_subscription.no_data")}</div>
) : (
<div className="">
{displayList.map((source) => {
@@ -1,191 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChannelRole } from "~/api/channels";
import type { Channel } from "~/api/channels";
import ChannelItem from "./ChannelItem";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_subscription.channel_settings": "频道设置",
"com_subscription.member_management": "成员管理",
"com_subscription.unpin": "取消置顶",
"com_subscription.pin_channel": "置顶频道",
"com_subscription.prompt_tip": "提示",
"com_subscription.confirm_unsubscribe_channel_and_subs": "取消订阅",
"com_subscription.confirm_delete_channel_for_all": "删除频道",
"com_subscription.confirm": "确认",
"com_subscription.cancel": "取消",
"com_subscription.max_10_characters": "最多10个字符",
"com_subscription.dissolve_channel": "解散频道",
"com_subscription.unsubscribe": "取消订阅",
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useConfirm: () => jest.fn().mockResolvedValue(true),
useToastContext: () => ({
showToast: jest.fn(),
}),
}));
jest.mock("~/components/ui/icon/ClosedIcon", () => ({
__esModule: true,
default: () => <span data-testid="closed-icon" />,
}));
jest.mock("~/components/icons/channels", () => ({
ChannelPinIcon: () => <span data-testid="pin-icon" />,
}));
jest.mock("~/components/icons/SpaceNotebookIcon", () => ({
SpaceNotebookIcon: () => <span data-testid="notebook-icon" />,
}));
const createChannel = (role: Channel["role"], permissionIds?: string[]): Channel => ({
id: "channel-1",
name: "资讯频道",
creator: "owner",
creatorId: "1",
subscriberCount: 3,
articleCount: 5,
unreadCount: 0,
role,
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
subChannels: [],
permissionIds,
});
function renderChannelItem(
role: Channel["role"],
type: "created" | "subscribed" = "subscribed",
permissionIds?: string[],
) {
const props = {
channel: createChannel(role, permissionIds),
isActive: false,
type,
onSelect: jest.fn(),
onUpdate: jest.fn(),
onDelete: jest.fn(),
onUnsubscribe: jest.fn(),
onPin: jest.fn(),
onManageMembers: jest.fn(),
onChannelSettings: jest.fn(),
};
const view = render(<ChannelItem {...props} />);
return { ...view, props };
}
describe("ChannelItem relation actions", () => {
it("shows channel settings to editor without member management", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("editor");
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
});
it("does not show channel settings to viewer", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("viewer");
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(screen.queryByText("频道设置")).not.toBeInTheDocument();
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
});
it("hides member management when manager model no longer grants it", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("manager", "subscribed", ["view_channel", "edit_channel"]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
});
it("keeps legacy creator able to open channel settings", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem(ChannelRole.CREATOR, "created");
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
});
it("shows both dissolve and unsubscribe to a subscribed user granted delete_channel", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("manager", "subscribed", [
"view_channel",
"edit_channel",
"delete_channel",
]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("解散频道")).toBeInTheDocument();
expect(screen.getByText("取消订阅")).toBeInTheDocument();
});
it("shows only dissolve (no unsubscribe) for a created channel", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem(ChannelRole.CREATOR, "created", [
"view_channel",
"edit_channel",
"delete_channel",
]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("解散频道")).toBeInTheDocument();
expect(screen.queryByText("取消订阅")).not.toBeInTheDocument();
});
it("triggers onDelete when a delete-permitted subscriber dissolves", async () => {
const user = userEvent.setup();
const { container, props } = renderChannelItem("manager", "subscribed", [
"view_channel",
"delete_channel",
]);
const menuTrigger = container.querySelector("button");
await user.click(menuTrigger as HTMLButtonElement);
await user.click(await screen.findByText("解散频道"));
expect(props.onDelete).toHaveBeenCalledWith("channel-1");
expect(props.onUnsubscribe).not.toHaveBeenCalled();
});
it("shows unsubscribe to a subscriber without delete permission", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("viewer", "subscribed", ["view_channel"]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("取消订阅")).toBeInTheDocument();
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
});
});
@@ -1,118 +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 { Channel } from "~/api/channels";
import { SortType } from "~/api/channels";
import { useChannelActions } from "./useChannelActions";
const mockShowToast = jest.fn();
const mockUnsubscribeChannelApi = jest.fn();
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_subscription.unsubscribe_failed_retry": "取消订阅失败,请重试",
"com_subscription.unsubscribed": "已取消订阅",
"com_subscription.organization_grant_unsubscribe_blocked": ORGANIZATION_GRANT_MESSAGE,
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({
showToast: mockShowToast,
}),
}));
jest.mock("~/api/channels", () => ({
SortType: {
RECENT_UPDATE: "latest_update",
RECENT_ADDED: "latest_added",
NAME: "channel_name",
},
pinChannelApi: jest.fn(),
updateChannelApi: jest.fn(),
deleteChannelApi: jest.fn(),
unsubscribeChannelApi: (...args: unknown[]) => mockUnsubscribeChannelApi(...args),
}));
const ORGANIZATION_GRANT_MESSAGE = "本频道通过部门/用户组授权给你,暂无法取消订阅";
function createChannel(id = "channel-1"): Channel {
return {
id,
name: "资讯频道",
creator: "owner",
creatorId: "1",
subscriberCount: 3,
articleCount: 5,
unreadCount: 0,
role: "viewer",
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
subChannels: [],
permissionIds: ["view_channel"],
};
}
describe("useChannelActions unsubscribe", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockShowToast.mockClear();
mockUnsubscribeChannelApi.mockReset();
});
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
it("shows organization grant message and restores optimistic state when unsubscribe is blocked", async () => {
const channel = createChannel();
const onChannelSelect = jest.fn();
queryClient.setQueryData(["channels", "subscribed", SortType.RECENT_UPDATE], [channel]);
const invalidateQueriesSpy = jest.spyOn(queryClient, "invalidateQueries");
mockUnsubscribeChannelApi.mockResolvedValue({
status_code: 19055,
});
const { result } = renderHook(() => useChannelActions({
activeChannelId: channel.id,
createdSortBy: SortType.RECENT_UPDATE,
subscribedSortBy: SortType.RECENT_UPDATE,
createdChannels: [],
subscribedChannels: [channel],
onChannelSelect,
}), { wrapper });
await act(async () => {
await result.current.handleUnsubscribeChannel(channel.id);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: ORGANIZATION_GRANT_MESSAGE,
severity: NotificationSeverity.ERROR,
});
});
expect(mockShowToast).not.toHaveBeenCalledWith(expect.objectContaining({
message: "已取消订阅",
}));
expect(queryClient.getQueryData(["channels", "subscribed", SortType.RECENT_UPDATE])).toEqual([channel]);
expect(onChannelSelect).toHaveBeenLastCalledWith(channel);
expect(onChannelSelect).not.toHaveBeenCalledWith(null);
expect(mockUnsubscribeChannelApi).toHaveBeenCalledWith(channel.id);
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
queryKey: ["channels", "subscribed"],
});
});
});
@@ -1,73 +0,0 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { NotificationSeverity } from "~/common";
import { useCrawlQueue } from "./useCrawlQueue";
const mockShowToast = jest.fn();
const mockCrawlTempSourceApi = jest.fn();
const mockAddWebsiteSourceApi = jest.fn();
// p-limit ships as ESM and is not transformed by jest; replace it with a
// pass-through limiter that runs the task immediately.
jest.mock("p-limit", () => ({
__esModule: true,
default: () => (fn: () => unknown) => fn(),
}));
jest.mock("~/utils", () => ({
generateUUID: () => "test-crawl-id",
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock("~/api/channels", () => ({
crawlTempSourceApi: (...args: unknown[]) => mockCrawlTempSourceApi(...args),
addWebsiteSourceApi: (...args: unknown[]) => mockAddWebsiteSourceApi(...args),
}));
describe("useCrawlQueue API-key limit handling", () => {
beforeEach(() => {
mockShowToast.mockClear();
mockCrawlTempSourceApi.mockReset();
mockAddWebsiteSourceApi.mockReset();
});
it("shows an error popup when crawl is rejected with the 19006 API-key limit code", async () => {
mockCrawlTempSourceApi.mockResolvedValue({ status_code: 19006 });
const { result } = renderHook(() => useCrawlQueue({ onSourceAdded: jest.fn() }));
act(() => {
result.current.enqueue("https://example.com");
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: "api_errors.19006",
severity: NotificationSeverity.ERROR,
});
});
// The site is never added once the account quota is exhausted.
expect(mockAddWebsiteSourceApi).not.toHaveBeenCalled();
});
it("does not popup for per-site crawl failures (19003), which stay as queue tooltips", async () => {
mockCrawlTempSourceApi.mockResolvedValue({ status_code: 19003 });
const { result } = renderHook(() => useCrawlQueue({ onSourceAdded: jest.fn() }));
act(() => {
result.current.enqueue("https://example.com/article/123");
});
await waitFor(() => {
expect(result.current.queue[0]?.status).toBe("failed");
});
expect(mockShowToast).not.toHaveBeenCalled();
});
});
@@ -14,6 +14,7 @@ import { OverviewSection } from './sections/OverviewSection';
import { ModalSection } from './sections/ModalSection';
import { ConfirmDialogSection } from './sections/ConfirmDialogSection';
import { ButtonSection } from './sections/ButtonSection';
import { FeedbackSection } from './sections/FeedbackSection';
interface NavItem {
id: string;
@@ -26,6 +27,7 @@ const NAV: NavItem[] = [
{ id: 'modal', label: 'Modal 弹窗', status: 'wip' },
{ id: 'confirm', label: '二次确认弹窗', status: 'wip' },
{ id: 'button', label: 'Button 按钮', status: 'todo' },
{ id: 'feedback', label: '点赞点踩反馈', status: 'done' },
];
const STATUS_DOT: Record<NonNullable<NavItem['status']>, string> = {
@@ -81,6 +83,7 @@ export default function GalleryApp() {
<ModalSection />
<ConfirmDialogSection />
<ButtonSection />
<FeedbackSection />
</div>
</main>
</div>
@@ -109,8 +109,9 @@ export function ConfirmDialogSection() {
subtitle={
<>
/ / {' '}
<code>OGDialogTemplate selection</code> 14 <code>useConfirm()</code>
24 8 <b></b>B C 9 selectClasses
<code>OGDialogTemplate selection</code> 13 7 UI + 6 {' '}
<code>useConfirm()</code>26 10
<b></b> UI SidePanel + Chat/Header Modal <b></b>B C 9 selectClasses
danger / primary
9 <code>AlertDialog</code> Modal
</>
@@ -126,7 +127,7 @@ export function ConfirmDialogSection() {
<>
<code>OGDialogTemplate</code> + <code>selection</code>
</>,
'14(原 21,迁移中',
'13(原 21;剩余全是死 UI 或表单',
'旧页面(会话/书签/Agent/设置/Prompt…LibreChat 血统)',
'差 · 确认按钮 9 种写法',
],
@@ -135,7 +136,7 @@ export function ConfirmDialogSection() {
<>
<code>useConfirm()</code>ConfirmContext + AlertDialog
</>,
'24(收敛目标,含已迁入 8 处)',
'26(收敛完成,含已迁入 10 处)',
'新页面(知识空间 / 订阅频道 / 权限)',
'好 · 样式集中在一个文件,destructive/default 两档',
],
@@ -151,20 +152,20 @@ export function ConfirmDialogSection() {
[
'1',
<code key="c">bg-red-700 dark:bg-red-600 hover:bg-red-800 </code>,
'删除(书签/工具/分享链接…)· 删会话 2 处已迁 C',
'6(原 8',
'可达的 4 处已迁 C;剩 4 处全是死 UI(书签/分享弹窗/两个工具移除)',
'4(原 8· 全死 UI',
],
[
'2',
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-800</code>,
'删除Agent / Assistant)· Prompt 组已迁 C',
'2(原 3',
'删除 Agent / Assistant —— 死 UISidePanel 被注释)',
'2(原 3· 全死 UI',
],
[
'3',
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-600</code>,
'清空预设',
'1',
'清空预设 —— 死 UIChat/Header 无人引用)',
'1 · 死 UI',
],
[
'4',
@@ -292,7 +293,7 @@ export function ConfirmDialogSection() {
/>
<ConfirmDemo
label="Loading 态(isLoading: true"
note="模板内置 Spinner · 自塞 Spinner 的只剩 SharedLinks 1 处(原 4 处,3 处已迁 C"
note="模板内置 Spinner · 各页自塞 Spinner 的写法已随迁移清零(原 4 处"
title="删除会话"
body="确认按钮处于加载中。"
selectText="删除"
@@ -0,0 +1,56 @@
/**
* Message feedback (/) gallery DEV-ONLY.
*
* The single shared control `MessageFeedbackButtons` now backs every AI answer
* surface (daily chat / / subscription docks / linsight ResultPanel / appChat
* MessageButtons the old appChat MessageFeedbackForm was deleted). Dislike is
* deferred: the reason dialog must be submitted before anything persists or
* highlights; cancel discards the dislike. The demos below persist to console.log
* only, so the dialog interaction can be exercised without a backend.
*/
import { useState } from 'react';
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
import { Section, Demo, DemoGrid } from '../components/kit';
function LoggedDemo({ liked }: { liked?: number }) {
const [last, setLast] = useState<string>('—');
return (
<div className="flex items-center gap-4">
<MessageFeedbackButtons
liked={liked}
onLike={(l) => setLast(`onLike(${l})`)}
onDislikeComment={(c) => setLast(`onDislikeComment("${c}")`)}
/>
<span className="text-xs text-muted-foreground">{last}</span>
</div>
);
}
export function FeedbackSection() {
return (
<Section
id="feedback"
title="点赞 / 点踩反馈"
subtitle={
<>
<code>MessageFeedbackButtons</code> 6 AI / /
3 / / appChat<b></b>
= 12 / 20 / 32 · 14px ·
400 · 6
</>
}
>
<DemoGrid cols={3}>
<Demo label="初始未评价" note="点踩先弹窗,提交后才高亮;取消不留痕">
<LoggedDemo />
</Demo>
<Demo label="已点赞态(liked=1" note="点踩弹窗取消后应保持点赞高亮">
<LoggedDemo liked={1} />
</Demo>
<Demo label="已点踩态(liked=2" note="再点踩=直接取消(onLike(0)),不弹窗">
<LoggedDemo liked={2} />
</Demo>
</DemoGrid>
</Section>
);
}
@@ -1,10 +1,13 @@
/**
* Modal / Dialog gallery DEV-ONLY. See docs-ui-refactor/-Modal弹窗.md.
*
* Renders BiSheng's two parallel dialog families side by side so the difference
* (overlay darkness / blur / z-index) is visible by opening them.
* Fresh survey (2026-07-09): FIVE coexisting modal populations. The same demo
* content (title + description + input + cancel/confirm footer) is mounted into
* each shell so the shell differences (overlay / radius / padding / title /
* buttons) are the only variable when opening them side by side.
*/
import { Button } from '~/components/ui/Button';
import { Input } from '~/components/ui/Input';
import {
Dialog,
DialogTrigger,
@@ -23,16 +26,46 @@ import {
OGDialogTitle,
OGDialogDescription,
} from '~/components/ui/OriginalDialog';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
} from '~/components/ui/AlertDialog';
import DialogTemplate from '~/components/ui/DialogTemplate';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useConfirm } from '~/Providers';
import { Section, Demo, DemoGrid, CompareTable } from '../components/kit';
const sampleBody = (
<p className="text-sm text-text-primary">
</p>
/** Identical body for every shell so only the shell itself differs. */
const demoBody = (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-primary">
/
</p>
<Input placeholder="示例输入框" />
</div>
);
/** Small reference demo of the finalized confirm dialog, for shell comparison. */
function ConfirmReferenceDemo() {
const confirm = useConfirm();
return (
<Button
variant="outline"
onClick={() =>
confirm({
variant: 'destructive',
title: '确认删除',
description: '弹窗壳视觉参照:圆角16 / p-5 / 灰底毛玻璃遮罩。',
confirmText: '确认删除',
})
}
>
</Button>
);
}
export function ModalSection() {
return (
<Section
@@ -40,94 +73,263 @@ export function ModalSection() {
title="Modal 弹窗"
subtitle={
<>
<b></b>A <code>Dialog</code> z-100 B {' '}
<code>OriginalDialog / OG</code> z-50
2026-07-09 <b>5 64 </b>
B C A 22 + 3
AlertDialog7
</>
}
>
{/* Difference table */}
<div className="mb-6">
{/* ① Population overview */}
<h3 className="mb-3 mt-2 text-base font-semibold text-text-primary">
ui/
</h3>
<div className="mb-8">
<CompareTable
head={['维度', 'A 套 Dialog', 'B 套 OriginalDialog / OG']}
head={['体系', '实现', '业务文件数', '用在哪 / 备注']}
rows={[
['遮罩颜色', 'bg-black/40(浅)', 'bg-black/80(深)'],
['毛玻璃模糊', '有 backdrop-blur-md', '无'],
['层级 z-index', 'z-[100]', 'z-50'],
['便捷模板', 'DialogTemplate~4 处)', 'OGDialogTemplate~25 处 · 用得最多)'],
[
'A 套 · 原语直接拼',
'Dialog + DialogContent 手拼',
<b key="a1">22</b>,
'新页面为主:知识库 8、订阅 2、审批/通知/账号/分享/appChat…(含 MainLayout 全局弹窗)',
],
[
'A 套 · 模板',
'DialogTemplate',
'3',
'EditPresetDialog、PresetItems、ContextButton(后者在 SidePanel 死树)',
],
[
'B 套 · 模板',
'OGDialogTemplate',
'16(原 25,确认迁移后)',
'书签/导出/SetKey/归档/Agent 面板…(其中 SidePanel 死树约 6 处);壳已对齐 C 套',
],
[
'B 套 · 原语直接拼',
'OGDialog + OGDialogContent 手拼',
'16',
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上(已对齐 C 套)',
],
[
'手拼 AlertDialog',
'AlertDialogContent + 自拼头尾',
'7',
'频道成员 2、爬取系 4、灵思 TaskModeInput(部分带确认性质,本期一并处理)',
],
[
'C 套 · useConfirm(参照)',
'ConfirmContextAlertDialog 底层)',
'26(已收敛 ✅)',
'二次确认已定稿的视觉基准:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳的天然候选',
],
]}
/>
</div>
<DemoGrid cols={4}>
{/* A-set raw primitives */}
<Demo label="A 套 · Dialog 原语" note="ui/Dialog.tsx · 毛玻璃遮罩">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog </DialogTitle>
<DialogDescription> Header / Footer </DialogDescription>
</DialogHeader>
{sampleBody}
<DialogFooter>
<DialogClose asChild>
{/* ② Shell anatomy */}
<h3 className="mb-3 text-base font-semibold text-text-primary">
</h3>
<div className="mb-8">
<CompareTable
head={['维度', 'A 套 Dialog', 'B 套 OriginalDialog(已对齐 C 套)', 'AlertDialog(手拼底座)']}
rows={[
[
'遮罩',
'bg-black/40 + blur(浅黑毛玻璃)',
'bg-gray-500/90 + blur(灰白毛玻璃)',
'bg-gray-500/90 + blur(同 B',
],
['层级 z-index', 'z-[100]', 'z-50', 'z-[110]'],
['圆角', 'sm:rounded-lg8px,移动端直角)', 'rounded-2xl16px', 'sm:rounded-lg8px,移动端直角)'],
['内边距', 'p-520px', 'p-520px', 'p-624px'],
[
'边框 / 阴影',
'border + shadow-lg',
'border #ebebeb + 淡投影',
'无边框、无阴影',
],
[
'标题',
'text-base font-semibold',
'text-base font-medium',
'组件是 text-lg semibold,但各页多自拼标题行',
],
[
'关闭按钮 ×',
'内置右上(可关)',
'内置右上(可关)',
'无内置,各页自拼',
],
[
'深色模式底色',
'dark:bg-[#303134](写死)',
'bg-background(跟主题)',
'dark:bg-gray-900',
],
[
'移动端行为',
'居中缩放出现',
'居中缩放出现',
'从底部滑入、贴底',
],
[
'footer 按钮',
'各页自拼(多为 Button outline + default',
'模板:取消白底描边 + 确认 danger/primary 档;直接拼的各页自理',
'各页自拼(红 #F53F3F 等)',
],
]}
/>
</div>
{/* ③ Side-by-side demos — identical content, different shells */}
<h3 className="mb-3 text-base font-semibold text-text-primary">
</h3>
<div className="mb-8">
<DemoGrid cols={3}>
{/* A-set raw primitives — the largest population */}
<Demo label="A 套 · 原语直接拼(22 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{demoBody}
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<Button></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Demo>
{/* A-set template — legacy default black confirm button */}
<Demo label="A 套 · DialogTemplate3 处)" note="旧模板 · 默认黑底确认钮 · 正文额外 px-6">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogTemplate
title="弹窗标题"
description="标题下的说明文字。"
main={demoBody}
selection={{ selectHandler: () => null, selectText: '确定' }}
/>
</Dialog>
</Demo>
{/* B-set template — shell already aligned with C-set */}
<Demo label="B 套 · OGDialogTemplate16 处)" note="壳已对齐 C 套 · 圆角16 · 取消/确认已统一">
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline"></Button>
</OGDialogTrigger>
<OGDialogTemplate
title="弹窗标题"
description="标题下的说明文字。"
className="max-w-md"
main={demoBody}
selection={{
selectHandler: () => null,
selectVariant: 'primary',
selectText: '确定',
}}
/>
</OGDialog>
</Demo>
{/* B-set raw primitives — same shell, hand-rolled body/footer */}
<Demo label="B 套 · OG 原语直接拼(16 处)" note="壳同左 · 头尾各页自拼(设置/Prompts 老页面)">
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline"></Button>
</OGDialogTrigger>
<OGDialogContent className="max-w-md">
<OGDialogHeader>
<OGDialogTitle></OGDialogTitle>
<OGDialogDescription></OGDialogDescription>
</OGDialogHeader>
{demoBody}
<div className="flex justify-end gap-2">
<Button variant="outline"></Button>
</DialogClose>
<Button></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Demo>
<Button></Button>
</div>
</OGDialogContent>
</OGDialog>
</Demo>
{/* A-set template */}
<Demo label="A 套 · DialogTemplate" note="ui/DialogTemplate.tsx">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogTemplate
title="DialogTemplate"
description="传 title / main / buttons 的便捷版。"
main={sampleBody}
buttons={<Button></Button>}
/>
</Dialog>
</Demo>
{/* Hand-rolled AlertDialog population */}
<Demo
label="手拼 AlertDialog7 处)"
note="p-6 · 圆角8 · 无边框阴影 · z-110 · 移动端贴底滑入"
>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline"></Button>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
{/* Business pages hand-roll header/footer like this (ChannelMemberDialog etc.) */}
<h3 className="text-base font-medium text-text-primary"></h3>
{demoBody}
<div className="flex justify-end gap-2">
<Button variant="outline"></Button>
<Button></Button>
</div>
</AlertDialogContent>
</AlertDialog>
</Demo>
{/* B-set raw primitives */}
<Demo label="B 套 · OG 原语" note="ui/OriginalDialog.tsx · 纯深色遮罩">
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline"></Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-lg bg-background text-foreground">
<OGDialogHeader>
<OGDialogTitle>OG </OGDialogTitle>
<OGDialogDescription>OriginalDialog </OGDialogDescription>
</OGDialogHeader>
<div className="py-2">{sampleBody}</div>
</OGDialogContent>
</OGDialog>
</Demo>
{/* C-set reference — the finalized confirm shell */}
<Demo
label="C 套 · useConfirm 参照(已定稿)"
note="二次确认基准壳:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳候选"
>
<ConfirmReferenceDemo />
</Demo>
</DemoGrid>
</div>
{/* B-set template — the most used one */}
<Demo label="B 套 · OGDialogTemplate" note="用得最多 · 基准候选">
<OGDialog>
<OGDialogTrigger asChild>
<Button></Button>
</OGDialogTrigger>
<OGDialogTemplate
title="OGDialogTemplate"
description="全站用得最多的便捷模板,收敛基准候选。"
className="max-w-lg"
main={sampleBody}
buttons={<Button></Button>}
/>
</OGDialog>
</Demo>
</DemoGrid>
{/* ④ Decision checklist */}
<h3 className="mb-3 text-base font-semibold text-text-primary"> </h3>
<div className="rounded-xl border border-border-light bg-muted/20 p-5 text-sm leading-7 text-text-primary">
<ol className="list-decimal space-y-1 pl-5">
<li>
<b></b>A black/40+blur vs B/C gray-500/90+blur
black/80
</li>
<li>
<b></b>8pxA / AlertDialog vs 16pxB/C /
</li>
<li>
<b></b>p-520pxA/B/C vs p-624pxAlertDialogheader/body/footer gap-4
</li>
<li>
<b></b>font-semiboldA vs font-mediumB/C
</li>
<li>
<b> ×</b> A/B ×AlertDialog
</li>
<li>
<b>footer </b>/ Button outline+default C
+ danger/primary gap-2 vs gap-3
</li>
<li>
<b></b>z-50 / z-[100] / z-[110] Drawer/Sheet/Popover
</li>
<li>
<b></b>A 22 A B
</li>
</ol>
</div>
</Section>
);
}
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react";
import { useMemo } from "react";
import { useRecoilValue } from "recoil";
import type { CitationReferencesDesktopPayload } from "~/components/Chat/Messages/Content/CitationReferencesDrawer";
import { SelectionMessagesProvider, SelectAllBelowBanner } from "~/components/Chat/MessageSelection";
@@ -9,7 +9,6 @@ import InputForm from "./components/InputForm";
import InputFormSkill from "./components/InputFormSkill";
import MessageBs, { ReasoningLog } from "./components/MessageBs";
import MessageBsChoose from "./components/MessageBsChoose";
import MessageFeedbackForm from "./components/MessageFeedbackForm";
import MessageFile from "./components/MessageFile";
import MessageNodeRun from "./components/MessageNodeRun";
import MessageRemark from "./components/MessageRemark";
@@ -73,7 +72,6 @@ export default function ChatMessages({
}, [messages]);
console.log("messages :>> ", chatState, messages, guideWord);
const thumbRef = useRef(null);
const remark = chatState?.flow?.guide_word;
@@ -132,7 +130,6 @@ export default function ChatMessages({
isGuestMode={isGuestMode}
onOpenCitationPanel={onOpenCitationPanel}
activeCitationMessageId={activeCitationMessageId}
onUnlike={(messageId) => { thumbRef.current?.openModal(messageId) }}
/>;
case 'divider':
return <div key={msg.id} className="flex items-center justify-center py-4 text-gray-400 text-sm">
@@ -185,7 +182,6 @@ export default function ChatMessages({
<InputFormSkill flow={chatState.flow} logo={logo} />
)}
<MessageFeedbackForm ref={thumbRef} />
</SelectionMessagesProvider>
</div>
};
@@ -51,7 +51,6 @@ type MessageBsProps = {
logo: React.ReactNode;
title: string;
data: ChatMessageType;
onUnlike?: any;
isGuestMode?: boolean;
readOnly?: any;
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
@@ -61,7 +60,6 @@ export default function MessageBs({
logo,
title,
data,
onUnlike = () => { },
readOnly,
isGuestMode = false,
onOpenCitationPanel,
@@ -175,7 +173,6 @@ export default function MessageBs({
id={data.id}
data={data.liked}
text={message}
onUnlike={onUnlike}
onCopy={handleCopyMessage}
>
<span className="text-slate-400 text-sm pt-0.5">{formatStrTime(data.create_time, 'MM 月 dd 日 HH:mm')}</span>
@@ -1,14 +1,8 @@
import { useState } from "react";
import { Outlined } from "bisheng-icons";
import { copyTrackingApi, likeChatApi } from "~/api/apps";
import { copyTrackingApi, disLikeCommentApi, likeChatApi } from "~/api/apps";
import { MessageFeedbackButtons } from "~/components/Chat/MessageFeedbackButtons";
import { TextToSpeechButton } from "~/components/Voice/TextToSpeechButton";
import { cn } from "~/utils";
const enum ThumbsState {
Default = 0,
ThumbsUp,
ThumbsDown
}
// Shared action-icon button — matches ExportSelectionButton (size-6 hit area,
// 14px bisheng-icons Outlined glyph, #818181 idle / brand-500 active) so the
@@ -16,20 +10,9 @@ const enum ThumbsState {
const ACTION_BTN =
"flex size-6 items-center justify-center rounded-[6px] transition-colors hover:bg-[#F7F7F7]";
export default function MessageButtons({ id, text, onCopy, data, onUnlike, children = null }) {
const [state, setState] = useState<ThumbsState>(data)
export default function MessageButtons({ id, text, onCopy, data, children = null }) {
const [copied, setCopied] = useState(false)
const handleClick = (type: ThumbsState) => {
setState(_type => {
const newType = type === _type ? ThumbsState.Default : type
// api
likeChatApi(id, newType);
return newType
})
if (state !== ThumbsState.ThumbsDown && type === ThumbsState.ThumbsDown) onUnlike?.(id)
}
const handleCopy = (e) => {
setCopied(true)
onCopy()
@@ -54,31 +37,10 @@ export default function MessageButtons({ id, text, onCopy, data, onUnlike, child
? <Outlined.Copied size={14} className="text-blue-500" />
: <Outlined.Copy size={14} className="text-[#818181]" />}
</button>
<button
type="button"
className={ACTION_BTN}
onClick={() => handleClick(ThumbsState.ThumbsUp)}
title="点赞"
aria-label="点赞"
aria-pressed={state === ThumbsState.ThumbsUp}
>
<Outlined.ThumbsUp
size={14}
className={cn(state === ThumbsState.ThumbsUp ? 'text-blue-500' : 'text-[#818181]')}
/>
</button>
<button
type="button"
className={ACTION_BTN}
onClick={() => handleClick(ThumbsState.ThumbsDown)}
title="点踩"
aria-label="点踩"
aria-pressed={state === ThumbsState.ThumbsDown}
>
<Outlined.ThumbsDown
size={14}
className={cn(state === ThumbsState.ThumbsDown ? 'text-blue-500' : 'text-[#818181]')}
/>
</button>
<MessageFeedbackButtons
liked={data}
onLike={(liked) => likeChatApi(id, liked)}
onDislikeComment={(comment) => disLikeCommentApi(id, comment)}
/>
</div>
};
@@ -1,56 +0,0 @@
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import useLocalize from "~/hooks/useLocalize";
import { disLikeCommentApi } from '~/api/apps';
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from '~/components';
import { useToastContext } from '~/Providers';
const MessageFeedbackForm = forwardRef((props, ref) => {
const t = useLocalize()
const [open, setOpen] = useState(false);
const [error, setError] = useState(false);
const msgRef = useRef<HTMLTextAreaElement | null>(null)
const chatIdRef = useRef<string | null>(null)
useImperativeHandle(ref, () => ({
openModal: (chatId) => {
setOpen(true)
chatIdRef.current = chatId
if (msgRef.current) {
msgRef.current.value = ''
}
}
}));
const { showToast } = useToastContext();
const handleSubmit = () => {
if (!msgRef.current?.value) {
showToast({ message: t('com_feedback_required'), status: 'warning' });
return setError(true);
}
disLikeCommentApi(chatIdRef.current as string, msgRef.current.value)
setOpen(false);
setError(false);
};
return <Dialog open={open} onOpenChange={setOpen} >
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('com_feedback_title')}</DialogTitle>
</DialogHeader>
<div className="">
<p className="mb-2"></p>
<Textarea ref={msgRef} maxLength={9999} className={`textarea ${error ? 'border border-red-400' : ''}`} ></Textarea>
<div className="flex justify-end gap-4 mt-4">
<Button className='px-11' variant="outline" onClick={() => setOpen(false)}>{t('com_ui_cancel')}</Button>
<Button className='px-11' onClick={handleSubmit}>{t('com_ui_submit')}</Button>
</div>
</div>
</DialogContent>
</Dialog>
});
export default MessageFeedbackForm;
@@ -157,12 +157,13 @@ export default function ExplorePlaza() {
<div
className={cn(
'flex h-full min-h-0 w-full flex-1 flex-col items-center overflow-hidden bg-white',
// Mobile explore renders in document-scroll mode (not innerScrollShell in
// MainLayout), so h-full/flex-1 collapse to content height and the region has
// no height for the empty/loading content to position against. In those states
// (no scrollable grid) pin the page to one viewport tall so the region below the
// header becomes a real, measurable area. Gated by state → has-content path untouched.
(loading || agents.length === 0) && 'max-[767px]:h-[100dvh]',
// Mobile explore is not innerScrollShell in MainLayout (h-auto shell) and
// html/body scrolling is globally disabled (WebView bottom-strip fix in
// index.html), so h-full/flex-1 collapse to content height and nothing can
// scroll. Pin the page to one viewport tall on mobile so <main>'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 */}
@@ -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<string, string> = {
"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: () => <div data-testid="file-card" />,
}));
jest.mock("~/components/ui/Sheet", () => ({
Sheet: ({ open, children }: any) => (open ? <div data-testid="sheet">{children}</div> : null),
SheetContent: ({ children }: any) => <div>{children}</div>,
SheetHeader: ({ children }: any) => <div>{children}</div>,
SheetTitle: ({ children }: any) => <div>{children}</div>,
}));
jest.mock("~/components/ui/Tooltip2", () => ({
Tooltip: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
}));
jest.mock("~/components/ui/Button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
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<Record<string, "join" | "joined" | "pending" | "rejected">>({});
return (
<KnowledgeSpacePreviewDrawer
spaceId={baseSpace.id}
initialSpace={{
...baseSpace,
squareStatus: statusMap[baseSpace.id],
}}
open
onOpenChange={() => undefined}
onSquareStatusChange={(id, status) => {
setStatusMap((prev) => ({
...prev,
[id]: status,
}));
}}
/>
);
}
render(<Wrapper />);
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(
<KnowledgeSpacePreviewDrawer
spaceId={publicSpace.id}
initialSpace={publicSpace as any}
open
onOpenChange={() => 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(
<KnowledgeSpacePreviewDrawer
spaceId={rejectedSpace.id}
initialSpace={rejectedSpace as any}
open
onOpenChange={() => undefined}
/>
);
fireEvent.click(await screen.findByRole("button", { name: "重新申请" }));
await waitFor(() => {
expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-2");
});
});
});
@@ -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 ? (
<div className="space-y-2">
<div className="flex min-h-full flex-col space-y-2">
<div className="mb-1 text-sm text-[#4E5969] flex items-center gap-2 flex-wrap">
<button
type="button"
@@ -536,9 +553,17 @@ export function KnowledgeSpacePreviewDrawer({
);
})}
</div>
{filesPreview.length === 0 ? (
<div className="flex items-center justify-center h-64 text-[#86909c] text-sm">
{localize("com_knowledge.no_files")}</div>
{loadingFiles ? (
<div className="flex flex-1 items-center justify-center">
<LoadingIcon className="size-20 text-primary" />
</div>
) : filesPreview.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<EmptyStateIllustration className="size-[120px] mb-4 opacity-90" />
<p className="text-[14px] font-normal text-[#999999]">
{localize("com_knowledge.no_files")}
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3 min-[768px]:grid-cols-3">
{filesPreview.map((f) => (
@@ -595,7 +620,11 @@ export function KnowledgeSpacePreviewDrawer({
</>
) : (
<div className="flex flex-1 items-center justify-center px-6 py-4 text-sm text-[#86909c] touch-mobile:px-0">
{loadingSpace ? localize("com_knowledge.loading") : localize("com_knowledge.space_invalid_or_deleted")}
{loadingSpace ? (
<LoadingIcon className="size-20 text-primary" />
) : (
localize("com_knowledge.space_invalid_or_deleted")
)}
</div>
)}
</SheetContent>
@@ -1,98 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import KnowledgeSquare from "./KnowledgeSquare";
import { getJoinedSpacesApi, getSquareSpacesApi, SpaceRole, subscribeSpaceApi, VisibilityType } from "~/api/knowledge";
jest.mock("~/Providers", () => ({
useToastContext: () => ({
showToast: jest.fn(),
}),
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const dict: Record<string, string> = {
"com_knowledge.explore_square": "知识广场",
"com_knowledge.explore_more_spaces": "探索更多空间",
"com_knowledge.search_space_placeholder": "搜索空间",
"com_knowledge.no_matched_space": "暂无空间",
"com_knowledge.reapply": "重新申请",
"com_knowledge.join": "加入",
"com_knowledge.pending": "待审批",
"com_knowledge.joined": "已加入",
"com_knowledge.no_description": "暂无描述",
"com_knowledge.users_count": "用户",
"com_knowledge.applied_to_join_space": "申请已发送",
"com_subscription.articles": "篇内容",
};
return dict[key] || key;
},
}));
jest.mock("~/api/knowledge", () => ({
SpaceRole: {
CREATOR: "creator",
ADMIN: "admin",
MEMBER: "member",
},
VisibilityType: {
PUBLIC: "public",
PRIVATE: "private",
APPROVAL: "approval",
},
getJoinedSpacesApi: jest.fn(),
getSquareSpacesApi: jest.fn(),
subscribeSpaceApi: jest.fn(),
}));
describe("KnowledgeSquare", () => {
test("reapplies from rejected card state", async () => {
const mockedGetSquareSpacesApi = jest.mocked(getSquareSpacesApi);
const mockedGetJoinedSpacesApi = jest.mocked(getJoinedSpacesApi);
const mockedSubscribeSpaceApi = jest.mocked(subscribeSpaceApi);
const onSquareStatusChange = jest.fn();
mockedGetSquareSpacesApi.mockResolvedValue({
data: [
{
id: "space-1",
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,
squareStatus: "rejected",
subscriptionStatus: "rejected",
},
],
total: 1,
} as any);
mockedGetJoinedSpacesApi.mockResolvedValue([]);
mockedSubscribeSpaceApi.mockResolvedValue({ status: "pending", spaceId: "space-1" });
render(
<KnowledgeSquare
statusOverride={{ "space-1": "rejected" }}
onSquareStatusChange={onSquareStatusChange}
/>
);
fireEvent.click(await screen.findByRole("button", { name: "重新申请" }));
await waitFor(() => {
expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-1");
expect(onSquareStatusChange).toHaveBeenCalledWith("space-1", "pending");
});
});
});
@@ -219,9 +219,9 @@ export function EditTagsModal({
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
className="flex w-[600px] flex-col items-stretch gap-0 rounded-xl border-none bg-white p-0 shadow-[0px_5px_22px_0px_rgba(61,68,110,0.2)] outline-none touch-mobile:inset-0 touch-mobile:left-0 touch-mobile:top-0 touch-mobile:h-dvh touch-mobile:w-screen touch-mobile:max-w-none touch-mobile:translate-x-0 touch-mobile:translate-y-0 touch-mobile:rounded-none [&>button]:hidden"
className="flex w-[600px] max-w-[600px] flex-col items-stretch gap-0 border-none bg-white p-0 shadow-[0px_5px_22px_0px_rgba(61,68,110,0.2)] [outline:none] rounded-none sm:rounded-none md:rounded-xl max-md:inset-0 max-md:left-0 max-md:top-0 max-md:h-dvh max-md:w-screen max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 [&>button]:hidden"
>
<DialogHeader className="relative h-12 shrink-0 justify-center space-y-0 px-6 py-3 text-left touch-mobile:h-auto touch-mobile:px-4 touch-mobile:pt-6 touch-mobile:pb-4">
<DialogHeader className="relative h-12 shrink-0 justify-center space-y-0 px-5 py-3 text-left max-md:h-auto max-md:px-4 max-md:pt-6 max-md:pb-4">
<DialogTitle className="text-[16px] leading-6 font-medium text-[#212121]">
{isBatchMode ? localize("com_knowledge.batch_add_tags") : localize("com_knowledge.edit_tags")}
</DialogTitle>
@@ -235,10 +235,10 @@ export function EditTagsModal({
</button>
</DialogHeader>
<div className="flex flex-1 flex-col gap-3 px-6 py-6 touch-mobile:px-4 touch-mobile:py-4">
<div className="flex flex-1 flex-col gap-4 px-5 py-3 max-md:px-4 max-md:py-4">
{/* Tags Input Box */}
<div
className="relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-[8px] border border-[#EBECF0] bg-white px-3 py-[5px] pr-[40px] transition-colors focus-within:border-primary"
className="relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-[8px] border border-[#EBECF0] bg-white px-3 py-[5px] pr-[40px] transition-[border-color,box-shadow] focus-within:border-[#ddd] focus-within:shadow-[0_0_0_2px_#f1f5f9]"
onClick={() => document.getElementById("tag-input")?.focus()}
>
{selectedTags.map((tag) => (
@@ -261,6 +261,7 @@ export function EditTagsModal({
<input
id="tag-input"
type="text"
autoComplete="off"
value={inputValue}
onChange={(e) => 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}
/>
<span className="absolute right-3 top-0 flex h-full items-center text-[14px] leading-[22px] text-[#999]">
@@ -284,7 +285,7 @@ export function EditTagsModal({
<div className="text-[14px] leading-[22px] font-medium text-[#212121]">{localize("com_knowledge.existing_tags")}</div>
<div className="flex flex-wrap gap-1">
{spaceTags.length === 0 && (
<span className="text-[12px] text-[#86909c]">{localize("com_knowledge.no_tags")}</span>
<span className="text-[14px] text-[#999]">{localize("com_knowledge.no_tags")}</span>
)}
{spaceTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id);
@@ -318,16 +319,16 @@ export function EditTagsModal({
</div>
</div>
<DialogFooter className="flex h-14 shrink-0 items-center justify-end gap-3 border-none px-6 py-3 touch-mobile:!mt-auto touch-mobile:!h-auto touch-mobile:!flex-row touch-mobile:!justify-stretch touch-mobile:border-t touch-mobile:border-[#ECECEC] touch-mobile:px-4 touch-mobile:py-3 sm:space-x-0">
<DialogFooter className="flex h-14 shrink-0 items-center justify-end gap-3 border-none px-5 py-3 max-md:!mt-auto max-md:!h-auto max-md:!flex-row max-md:!justify-stretch max-md:border-t max-md:border-[#ECECEC] max-md:px-4 max-md:py-3 sm:space-x-0">
<Button
variant="outline"
className="h-8 min-w-[60px] rounded-[6px] border-[#ebecf0] bg-white/50 px-4 font-normal text-[#070038] backdrop-blur-[8px] hover:bg-white/70 touch-mobile:flex-1"
className="h-8 min-w-[60px] rounded-[6px] border-[#ebecf0] bg-white/50 px-4 font-normal text-[#070038] backdrop-blur-[8px] hover:bg-white/70 max-md:flex-1"
onClick={handleClose}
>
{localize("com_knowledge.cancel")}</Button>
<Button
variant="default"
className="h-8 min-w-[60px] rounded-[6px] px-4 font-normal touch-mobile:flex-1"
className="h-8 min-w-[60px] rounded-[6px] px-4 font-normal max-md:flex-1"
onClick={handleSave}
disabled={loading}
>
@@ -376,7 +376,7 @@ export function KnowledgeSpaceHeader({
);
return (
<div className="flex min-h-8 items-center justify-between gap-3 pt-5 pb-4 max-[767px]:gap-2 max-[767px]:pt-4 max-[767px]:pb-3">
<div className="flex min-h-8 items-center justify-between gap-3 pt-4 pb-4 max-[767px]:gap-2 max-[767px]:pb-3">
{/* 左侧:根目录显示空间标题 + 信息 + 分享;进入文件夹后显示返回按钮 + 分隔线 + 当前文件夹名(设计稿 11772:70584 */}
<div className="flex min-w-0 flex-1 items-center gap-1 text-sm">
@@ -1,165 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { getGrantableRelationModels } from "~/api/permission";
import { KnowledgeSpaceShareDialog } from "./KnowledgeSpaceShareDialog";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("~/utils", () => ({
copyText: jest.fn(),
}));
jest.mock("~/api/permission", () => ({
getGrantableRelationModels: jest.fn(),
}));
jest.mock("~/components/KnowledgeSpaceMemberManagementPanel", () => ({
KnowledgeSpaceMemberManagementPanel: () => <div>member-panel</div>,
}));
jest.mock("~/components/permission/PermissionListTab", () => ({
PermissionListTab: ({ resourceType, resourceId, fixedSubjectType }: any) => (
<div>{`list:${resourceType}:${resourceId}:${fixedSubjectType}`}</div>
),
}));
jest.mock("~/components/permission/PermissionGrantTab", () => ({
PermissionGrantTab: ({ resourceType, resourceId, fixedSubjectType, includeChildren }: any) => (
<div>{`grant:${resourceType}:${resourceId}:${fixedSubjectType}:${includeChildren ? "include" : "exclude"}`}</div>
),
}));
jest.mock("~/components/ui", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
Checkbox: ({ checked, onCheckedChange }: any) => (
<button
type="button"
role="checkbox"
aria-checked={checked ? "true" : "false"}
onClick={() => onCheckedChange?.(!checked)}
/>
),
Dialog: ({ children }: any) => <div>{children}</div>,
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <div>{children}</div>,
Input: (props: any) => <input {...props} />,
Tabs: ({ children }: any) => <div>{children}</div>,
TabsContent: ({ children }: any) => <div>{children}</div>,
TabsList: ({ children }: any) => <div>{children}</div>,
TabsTrigger: ({ children }: any) => <button type="button">{children}</button>,
}));
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(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceId="space-59"
resourceName="Space 59"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
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(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceId="space-59"
resourceName="Space 59"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
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(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceType="knowledge_file"
resourceId="file-9"
resourceName="File 9"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
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(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceType="folder"
resourceId="folder-9"
resourceName="Folder 9"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
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();
});
});
@@ -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}
@@ -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) => <span data-testid="city-icon" {...props} />,
Down: (props: any) => <span data-testid="down-icon" {...props} />,
File: (props: any) => <span data-testid="file-icon" {...props} />,
FileImage: (props: any) => <span data-testid="file-image-icon" {...props} />,
FolderClose: (props: any) => <span data-testid="folder-icon" {...props} />,
Notebook: (props: any) => <span data-testid="notebook-icon" {...props} />,
Right: (props: any) => <span data-testid="right-icon" {...props} />,
},
}));
jest.mock("lucide-react", () => ({
Loader2: (props: any) => <span data-testid="loader" {...props} />,
}));
jest.mock("~/components/ui/Button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
jest.mock("~/components/ui/Dialog", () => ({
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
}));
jest.mock("~/components/ui/ExpandableSearchField", () => ({
ExpandableSearchField: ({ value, onChange, placeholder }: any) => (
<input aria-label={placeholder} value={value} onChange={(event) => 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) => (
<span>
{name}
{trailing}
</span>
),
}));
jest.mock("./MoveToFolderTree", () => ({
MoveToFolderTree: () => <div data-testid="folder-tree" />,
}));
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(
<QueryClientProvider client={queryClient}>
<MoveToDialog
open
onOpenChange={() => undefined}
currentSpaceId="current-space"
currentSpaceName="Current Space"
onConfirm={() => undefined}
/>
</QueryClientProvider>,
);
}
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();
});
});
@@ -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<string, string> = {
"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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
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"],
});
});
});

Some files were not shown because too many files have changed in this diff Show More