mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
Merge branch 'feat/2.6.0-beta4' into feat/2.6.0
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (owner→editor) would strip management access and lock them
|
||||
out. Backstop for every resource type; managing others is unaffected."""
|
||||
app = _make_app(_AdminUser) # user_id = 1
|
||||
with patch(
|
||||
"bisheng.permission.domain.services.permission_service.PermissionService.authorize",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_authorize:
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/permissions/resources/workflow/wf-1/authorize",
|
||||
json={
|
||||
"grants": [{"subject_type": "user", "subject_id": 1, "relation": "editor"}],
|
||||
"revokes": [{"subject_type": "user", "subject_id": 1, "relation": "owner"}],
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["status_code"] == 19000
|
||||
mock_authorize.assert_not_awaited()
|
||||
|
||||
def test_permissions_list_requires_can_edit_on_resource(self):
|
||||
app = _make_app(_ViewerUser)
|
||||
|
||||
|
||||
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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
{/* Toolbar:flex-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 true↔false 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 key→copy
|
||||
* 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 binoculars→x 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
-19
@@ -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':
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 —— 死 UI(SidePanel 被注释)',
|
||||
'2(原 3)· 全死 UI',
|
||||
],
|
||||
[
|
||||
'3',
|
||||
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-600</code>,
|
||||
'清空预设',
|
||||
'1',
|
||||
'清空预设 —— 死 UI(Chat/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
|
||||
处)与手拼 AlertDialog(7 处)仍是另外两种壳。逐个打开对比,定统一标准。
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* 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(参照)',
|
||||
'ConfirmContext(AlertDialog 底层)',
|
||||
'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-lg(8px,移动端直角)', 'rounded-2xl(16px)', 'sm:rounded-lg(8px,移动端直角)'],
|
||||
['内边距', 'p-5(20px)', 'p-5(20px)', 'p-6(24px)'],
|
||||
[
|
||||
'边框 / 阴影',
|
||||
'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 套 · DialogTemplate(3 处)" 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 套 · OGDialogTemplate(16 处)" 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="手拼 AlertDialog(7 处)"
|
||||
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>:8px(A 套 / AlertDialog) vs 16px(B/C 套)?移动端是否保留直角/贴底?
|
||||
</li>
|
||||
<li>
|
||||
<b>内边距</b>:p-5(20px,A/B/C) vs p-6(24px,AlertDialog)?header/body/footer 间距(当前统一 gap-4)?
|
||||
</li>
|
||||
<li>
|
||||
<b>标题</b>:font-semibold(A 套) vs font-medium(B/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">
|
||||
|
||||
-165
@@ -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
Reference in New Issue
Block a user