diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index abb1af14..ca7172e8 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -14,6 +14,7 @@ from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence from langchain.agents import create_agent from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from langchain_core.tools import BaseTool +from langgraph.errors import GraphRecursionError from app.core.logging_config import get_business_logger from app.core.models import RedBearLLM, RedBearModelConfig @@ -377,7 +378,7 @@ class LangChainAgent: {"messages": messages}, config={"recursion_limit": self.max_iterations} ) - except RecursionError as e: + except (RecursionError, GraphRecursionError) as e: logger.warning( f"Agent 达到最大迭代次数限制 ({self.max_iterations}),可能存在工具调用循环", extra={"error": str(e)} @@ -612,6 +613,12 @@ class LangChainAgent: yield stream_total_tokens break + except GraphRecursionError: + logger.warning( + f"Agent 达到最大迭代次数限制 ({self.max_iterations}),模型可能不支持正确的工具调用停止判断" + ) + if not full_content: + yield "抱歉,我在处理您的请求时遇到了问题(已达最大处理步骤限制)。请尝试简化问题或更换模型后重试。" except Exception as e: logger.error(f"Agent astream_events 失败: {str(e)}", exc_info=True) raise diff --git a/api/app/core/error_codes.py b/api/app/core/error_codes.py index 3feae4f6..41f58734 100644 --- a/api/app/core/error_codes.py +++ b/api/app/core/error_codes.py @@ -19,6 +19,7 @@ class BizCode(IntEnum): TENANT_NOT_FOUND = 3002 WORKSPACE_NO_ACCESS = 3003 WORKSPACE_INVITE_NOT_FOUND = 3004 + WORKSPACE_ACCESS_DENIED = 3005 # API Key 管理(3xxx) API_KEY_NOT_FOUND = 3007 API_KEY_DUPLICATE_NAME = 3008 @@ -113,6 +114,8 @@ HTTP_MAPPING = { BizCode.FORBIDDEN: 403, BizCode.TENANT_NOT_FOUND: 400, BizCode.WORKSPACE_NO_ACCESS: 403, + BizCode.WORKSPACE_INVITE_NOT_FOUND: 400, + BizCode.WORKSPACE_ACCESS_DENIED: 403, BizCode.NOT_FOUND: 400, BizCode.USER_NOT_FOUND: 200, BizCode.WORKSPACE_NOT_FOUND: 400, diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index c7d8cfed..eff6292f 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -79,8 +79,10 @@ class RedBearModelFactory: model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) if is_streaming: model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking and config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens + if config.deep_thinking: + model_kwargs["incremental_output"] = True + if config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens else: model_kwargs["enable_thinking"] = False params["model_kwargs"] = model_kwargs @@ -110,7 +112,7 @@ class RedBearModelFactory: params["stream_usage"] = True # 深度思考模式 is_streaming = bool(config.extra_params.get("streaming")) - if is_streaming: + if is_streaming and not config.is_omni: if provider == ModelProvider.VOLCANO: # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 thinking_config: Dict[str, Any] = { @@ -140,8 +142,10 @@ class RedBearModelFactory: model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) if is_streaming: model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking and config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens + if config.deep_thinking: + model_kwargs["incremental_output"] = True + if config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens else: model_kwargs["enable_thinking"] = False params["model_kwargs"] = model_kwargs diff --git a/api/app/core/models/embedding.py b/api/app/core/models/embedding.py index 87c79d09..fb75696a 100644 --- a/api/app/core/models/embedding.py +++ b/api/app/core/models/embedding.py @@ -1,5 +1,5 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Union from langchain_core.embeddings import Embeddings from app.core.models.base import RedBearModelConfig, get_provider_embedding_class, RedBearModelFactory @@ -22,7 +22,8 @@ class RedBearEmbeddings(Embeddings): self._model = self._create_model(config) self._client = None - def _create_model(self, config: RedBearModelConfig) -> Embeddings: + @staticmethod + def _create_model(config: RedBearModelConfig) -> Embeddings: """根据配置创建 LangChain 模型""" embedding_class = get_provider_embedding_class(config.provider) provider = config.provider.lower() @@ -36,6 +37,8 @@ class RedBearEmbeddings(Embeddings): "api_key": config.api_key, "timeout": httpx.Timeout(timeout=config.timeout, connect=60.0), "max_retries": config.max_retries, + "check_embedding_ctx_length": False, + "encoding_format": "float" } elif provider == ModelProvider.DASHSCOPE: params = { diff --git a/api/app/core/models/scripts/dashscope_models.yaml b/api/app/core/models/scripts/dashscope_models.yaml index d1b604e0..d9e6a00f 100644 --- a/api/app/core/models/scripts/dashscope_models.yaml +++ b/api/app/core/models/scripts/dashscope_models.yaml @@ -803,7 +803,6 @@ models: - vision - video - audio - - thinking is_omni: true tags: - 大语言模型 diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index f0cb32dd..61065c71 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -131,7 +131,7 @@ class DifyConverter(BaseConverter): selector = var_selector.split('.') if len(selector) not in [2, 3] and var_selector != "context": raise Exception(f"invalid variable selector: {var_selector}") - if len(selector) == 3: + if len(selector) == 3 and selector[0] in ("conversation", "sys"): selector = selector[1:] if selector[0] == "conversation": selector[0] = "conv" @@ -483,11 +483,11 @@ class DifyConverter(BaseConverter): node_data = node["data"] result = IterationNodeConfig.model_construct( input=self._process_list_variable_literal(node_data["iterator_selector"]), - parallel=node_data["is_parallel"], - parallel_count=node_data["parallel_nums"], + parallel=node_data.get("is_parallel", False), + parallel_count=node_data.get("parallel_nums", 4), output=self._process_list_variable_literal(node_data["output_selector"]), output_type=self.variable_type_map(node_data.get("output_type")), - flatten=node_data["flatten_output"], + flatten=node_data.get("flatten_output", False), ).model_dump() self.config_validate(node["id"], node["data"]["title"], IterationNodeConfig, result) @@ -496,7 +496,23 @@ class DifyConverter(BaseConverter): def convert_assigner_node_config(self, node: dict) -> dict: node_data = node["data"] assignments = [] - for assignment in node_data["items"]: + + # Support both formats: + # 1. New format: node_data["items"] list + # 2. Flat format: assigned_variable_selector + input_variable_selector + write_mode + if "items" in node_data: + raw_items = node_data["items"] + elif "assigned_variable_selector" in node_data and "input_variable_selector" in node_data: + raw_items = [{ + "variable_selector": node_data["assigned_variable_selector"], + "value": node_data["input_variable_selector"], + "input_type": ValueInputType.VARIABLE, + "operation": node_data.get("write_mode", "over-write"), + }] + else: + raw_items = [] + + for assignment in raw_items: if assignment.get("operation") is None or assignment.get("value") is None: continue assignments.append( diff --git a/api/app/core/workflow/nodes/list_operator/config.py b/api/app/core/workflow/nodes/list_operator/config.py index c178ec59..6fde6a57 100644 --- a/api/app/core/workflow/nodes/list_operator/config.py +++ b/api/app/core/workflow/nodes/list_operator/config.py @@ -1,5 +1,5 @@ from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.core.workflow.nodes.base_config import BaseNodeConfig from app.core.workflow.nodes.enums import ComparisonOperator @@ -31,6 +31,11 @@ class ExtractConfig(BaseModel): enabled: bool = False serial: str = "1" # 1-based index string, e.g. "1" = first + @field_validator("serial", mode="before") + @classmethod + def coerce_serial(cls, v): + return str(v) + class ListOperatorNodeConfig(BaseNodeConfig): """ diff --git a/api/app/core/workflow/nodes/list_operator/node.py b/api/app/core/workflow/nodes/list_operator/node.py index d0b30e92..edc15ed1 100644 --- a/api/app/core/workflow/nodes/list_operator/node.py +++ b/api/app/core/workflow/nodes/list_operator/node.py @@ -11,7 +11,7 @@ from app.core.workflow.variable.base_variable import VariableType logger = logging.getLogger(__name__) # File object fields that hold string values -_FILE_STRING_KEYS = {"name", "extension", "mime_type", "url", "transfer_method", "origin_file_type", "file_id"} +_FILE_STRING_KEYS = {"type", "name", "url", "extension", "mime_type", "transfer_method", "origin_file_type", "file_id"} _FILE_NUMBER_KEYS = {"size"} @@ -52,7 +52,7 @@ class ListOperatorNode(BaseNode): result = [result[idx]] # 3. Order - if cfg.order_by.enabled and cfg.order_by.key: + if cfg.order_by.enabled: reverse = cfg.order_by.value == "desc" key_fn = self._make_sort_key(cfg.order_by.key) result = sorted(result, key=key_fn, reverse=reverse) @@ -100,10 +100,17 @@ class ListOperatorNode(BaseNode): else: left = item # primitive array: compare element directly + # Determine if this field should be compared as a string + is_string_field = isinstance(item, dict) and cond.key in _FILE_STRING_KEYS + # Numeric operators if op == ComparisonOperator.EQ: + if is_string_field: + return str(left) == str(value) return self._safe_num(left) == self._safe_num(value) if op == ComparisonOperator.NE: + if is_string_field: + return str(left) != str(value) return self._safe_num(left) != self._safe_num(value) if op == ComparisonOperator.LT: return self._safe_num(left) < self._safe_num(value) diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index 3d9d2fa5..bb87c845 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -246,7 +246,10 @@ class LLMNode(BaseNode): logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}") # 返回 AIMessage(包含响应元数据) - return AIMessage(content=content, response_metadata=response.response_metadata) + return AIMessage(content=content, response_metadata={ + **response.response_metadata, + "token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage') + }) def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]: """提取输入数据(用于记录)""" @@ -305,15 +308,16 @@ class LLMNode(BaseNode): # 调用 LLM(流式,支持字符串或消息列表) last_meta_data = {} + last_usage_metadata = {} async for chunk in llm.astream(self.messages): - # 提取内容 if hasattr(chunk, 'content'): content = self.process_model_output(chunk.content) else: content = str(chunk) - if hasattr(chunk, 'response_metadata'): - if chunk.response_metadata: - last_meta_data = chunk.response_metadata + if hasattr(chunk, 'response_metadata') and chunk.response_metadata: + last_meta_data = chunk.response_metadata + if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata: + last_usage_metadata = chunk.usage_metadata # 只有当内容不为空时才处理 if content: @@ -336,7 +340,10 @@ class LLMNode(BaseNode): # 构建完整的 AIMessage(包含元数据) final_message = AIMessage( content=full_response, - response_metadata=last_meta_data + response_metadata={ + **last_meta_data, + "token_usage": last_usage_metadata or last_meta_data.get('token_usage') + } ) # yield 完成标记 diff --git a/api/app/core/workflow/nodes/parameter_extractor/node.py b/api/app/core/workflow/nodes/parameter_extractor/node.py index 28ac4252..901eddcf 100644 --- a/api/app/core/workflow/nodes/parameter_extractor/node.py +++ b/api/app/core/workflow/nodes/parameter_extractor/node.py @@ -12,7 +12,7 @@ from app.core.workflow.engine.state_manager import WorkflowState from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig -from app.core.workflow.variable.base_variable import VariableType +from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE from app.db import get_db_read from app.models import ModelType from app.services.model_service import ModelConfigService @@ -45,6 +45,12 @@ class ParameterExtractorNode(BaseNode): "model_id": str(self.typed_config.model_id), } + def _extract_output(self, business_result: Any) -> Any: + final_output = {} + for param in self.typed_config.params: + final_output[param.name] = business_result.get(param.name) or DEFAULT_VALUE(self.output_types[param.name]) + return final_output + def _output_types(self) -> dict[str, VariableType]: outputs = {} for param in self.typed_config.params: @@ -202,7 +208,10 @@ class ParameterExtractorNode(BaseNode): ]) model_resp = await llm.ainvoke(messages) - self.response_metadata = model_resp.response_metadata + self.response_metadata = { + **model_resp.response_metadata, + "token_usage": getattr(model_resp, 'usage_metadata', None) or model_resp.response_metadata.get('token_usage') + } model_message = self.process_model_output(model_resp.content) result = json_repair.repair_json(model_message, return_objects=True) logger.info(f"node: {self.node_id} get params:{result}") diff --git a/api/app/core/workflow/nodes/question_classifier/node.py b/api/app/core/workflow/nodes/question_classifier/node.py index 520eb5b0..74ff1cf9 100644 --- a/api/app/core/workflow/nodes/question_classifier/node.py +++ b/api/app/core/workflow/nodes/question_classifier/node.py @@ -136,7 +136,10 @@ class QuestionClassifierNode(BaseNode): response = await llm.ainvoke(messages) result = self.process_model_output(response.content) - self.response_metadata = response.response_metadata + self.response_metadata = { + **response.response_metadata, + "token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage') + } if result in category_names: category = result diff --git a/api/app/core/workflow/utils/file_processor.py b/api/app/core/workflow/utils/file_processor.py index 9b07d2e9..0bedf9a7 100644 --- a/api/app/core/workflow/utils/file_processor.py +++ b/api/app/core/workflow/utils/file_processor.py @@ -91,7 +91,7 @@ async def fetch_remote_file_meta( """ import httpx - name = size = mime_type = extension = None + name = extension = None try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.head(url, follow_redirects=True) diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 8d6df73e..85cff671 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -4,6 +4,10 @@ from typing import Optional, Any, List, Dict, Union from enum import Enum, StrEnum from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator + +from app.schemas.workflow_schema import WorkflowConfigCreate + + # ---------- Multimodal File Support ---------- class FileType(StrEnum): @@ -313,7 +317,7 @@ class AppCreate(BaseModel): # only for type=multi_agent multi_agent_config: Optional[Dict[str, Any]] = None - workflow_config: Optional[Dict[str, Any]] = None + workflow_config: Optional[WorkflowConfigCreate] = None class AppUpdate(BaseModel): diff --git a/api/app/schemas/conversation_schema.py b/api/app/schemas/conversation_schema.py index b2f565ef..fd1be5d9 100644 --- a/api/app/schemas/conversation_schema.py +++ b/api/app/schemas/conversation_schema.py @@ -32,7 +32,7 @@ class ChatRequest(BaseModel): web_search: bool = Field(default=False, description="是否启用网络搜索") memory: bool = Field(default=True, description="是否启用记忆功能") thinking: bool = Field(default=False, description="是否启用深度思考(需Agent配置支持)") - files: Optional[List[FileInput]] = Field(default=None, description="附件列表(支持多文件)") + files: List[FileInput] = Field(default_factory=list, description="附件列表(支持多文件)") # ---------- Output Schemas ---------- diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 53ac577a..fb4955b3 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -231,8 +231,13 @@ class AppChatService: if memory_flag: connected_config = get_end_user_connected_config(user_id, self.db) memory_config_id: str = connected_config.get("memory_config_id") + file_list = [] + for file in files: + file_dict = file.model_dump() + file_dict["upload_file_id"] = str(file_dict["upload_file_id"]) if file_dict["upload_file_id"] else None + file_list.append(file_dict) messages = [ - {"role": "user", "content": message, "files": [file.model_dump() for file in files]}, + {"role": "user", "content": message, "files": file_list}, {"role": "assistant", "content": result["content"]} ] if memory_config_id: @@ -506,8 +511,13 @@ class AppChatService: if memory_flag: connected_config = get_end_user_connected_config(user_id, self.db) memory_config_id: str = connected_config.get("memory_config_id") + file_list = [] + for file in files: + file_dict = file.model_dump() + file_dict["upload_file_id"] = str(file_dict["upload_file_id"]) if file_dict["upload_file_id"] else None + file_list.append(file_dict) messages = [ - {"role": "user", "content": message, "files": [file.model_dump() for file in files]}, + {"role": "user", "content": message, "files": file_list}, {"role": "assistant", "content": full_content} ] if memory_config_id: diff --git a/api/app/services/app_service.py b/api/app/services/app_service.py index 36d7e614..5e26a629 100644 --- a/api/app/services/app_service.py +++ b/api/app/services/app_service.py @@ -1360,6 +1360,7 @@ class AppService: variables=cfg.get("variables", []), execution_config=cfg.get("execution_config", {}), triggers=cfg.get("triggers", []), + features=cfg.get("features", {}), is_active=True, created_at=now, updated_at=now, diff --git a/api/app/services/memory_dashboard_service.py b/api/app/services/memory_dashboard_service.py index b96f4bde..b390aa10 100644 --- a/api/app/services/memory_dashboard_service.py +++ b/api/app/services/memory_dashboard_service.py @@ -513,34 +513,40 @@ def get_dashboard_yesterday_changes( today_data: dict ) -> dict: """ - 计算各指标相比昨天的变化量。 - + 计算各指标相比昨天的变化百分比。 + + - total_app_change / total_knowledge_change:只看活跃记录, + 百分比 = (截止今日活跃总量 - 截止昨日活跃总量) / 截止昨日活跃总量 + - total_memory_change / total_api_call_change: + 百分比 = (今日总量 - 昨日总量) / 昨日总量 + + 昨日总量为 0 时返回 None。返回值为浮点数,例如 0.5 表示增长 50%。 + Args: db: 数据库会话 workspace_id: 工作空间ID storage_type: 存储类型 'neo4j' | 'rag' today_data: 当前数据,包含 total_memory, total_app, total_knowledge, total_api_call - + Returns: { - "total_memory_change": int | None, - "total_app_change": int | None, - "total_knowledge_change": int | None, - "total_api_call_change": int | None + "total_memory_change": float | None, + "total_app_change": float | None, + "total_knowledge_change": float | None, + "total_api_call_change": float | None } """ - from datetime import datetime, timedelta + from datetime import datetime from sqlalchemy import func from app.models.api_key_model import ApiKey, ApiKeyLog from app.models.knowledge_model import Knowledge from app.models.app_model import App from app.models.appshare_model import AppShare - business_logger.info(f"计算昨日对比: workspace_id={workspace_id}, storage_type={storage_type}") + business_logger.info(f"计算昨日对比百分比: workspace_id={workspace_id}, storage_type={storage_type}") now_local = datetime.now() today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0) - yesterday_start = today_start - timedelta(days=1) changes = { "total_memory_change": None, @@ -549,134 +555,102 @@ def get_dashboard_yesterday_changes( "total_api_call_change": None, } - # --- total_api_call_change --- + def _calc_percentage(today_val, yesterday_val): + """计算百分比,昨日为0时返回None""" + if yesterday_val is None or yesterday_val == 0: + return None + return round((today_val - yesterday_val) / yesterday_val, 4) + + # --- total_api_call_change: (截止今日累计总数 - 截止昨日累计总数) / 截止昨日累计总数 --- try: - # 获取该workspace下所有api_key的id api_key_ids = [ row[0] for row in db.query(ApiKey.id).filter( ApiKey.workspace_id == workspace_id ).all() ] if api_key_ids: - # 今日累计 - today_api_count = db.query(func.count(ApiKeyLog.id)).filter( + # 截止今日的累计调用总数 + total_api_until_now = db.query(func.count(ApiKeyLog.id)).filter( ApiKeyLog.api_key_id.in_(api_key_ids), - ApiKeyLog.created_at >= today_start, ApiKeyLog.created_at < now_local ).scalar() or 0 - # 昨日全天 - yesterday_api_count = db.query(func.count(ApiKeyLog.id)).filter( + # 截止昨日的累计调用总数(today_start 即昨日结束) + total_api_until_yesterday = db.query(func.count(ApiKeyLog.id)).filter( ApiKeyLog.api_key_id.in_(api_key_ids), - ApiKeyLog.created_at >= yesterday_start, ApiKeyLog.created_at < today_start ).scalar() or 0 - changes["total_api_call_change"] = today_api_count - yesterday_api_count + changes["total_api_call_change"] = _calc_percentage(total_api_until_now, total_api_until_yesterday) else: - # 没有api_key,如果今日也是0则无对比意义 changes["total_api_call_change"] = None except Exception as e: business_logger.warning(f"计算API调用昨日对比失败: {str(e)}") - # --- total_knowledge_change --- + # --- total_knowledge_change: 只看活跃(status=1)且为顶层知识库(parent_id=workspace_id),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 --- try: - # 今天有效总量:当前status=1的知识库总数,排除用户知识库(permission_id='Memory') + # 截止今日的活跃知识库总量(当前 status=1,parent_id=workspace_id) today_knowledge = db.query(func.count(Knowledge.id)).filter( Knowledge.workspace_id == workspace_id, Knowledge.status == 1, - Knowledge.permission_id != "Memory" + Knowledge.parent_id == Knowledge.workspace_id ).scalar() or 0 - # 昨日有效总量:昨天之前创建的、当前仍有效的知识库,排除用户知识库 + # 截止昨日的活跃知识库总量(昨日之前创建的、当前仍 status=1,parent_id=workspace_id) yesterday_knowledge = db.query(func.count(Knowledge.id)).filter( Knowledge.workspace_id == workspace_id, Knowledge.status == 1, - Knowledge.permission_id != "Memory", + Knowledge.parent_id == Knowledge.workspace_id, Knowledge.created_at < today_start ).scalar() or 0 - # 今日软删:今天被软删的知识库(status=2 且 updated_at >= today_start),排除用户知识库 - today_deleted_knowledge = db.query(func.count(Knowledge.id)).filter( - Knowledge.workspace_id == workspace_id, - Knowledge.status == 2, - Knowledge.permission_id != "Memory", - Knowledge.updated_at >= today_start - ).scalar() or 0 - if yesterday_knowledge == 0 and today_knowledge == 0 and today_deleted_knowledge == 0: - changes["total_knowledge_change"] = None - else: - # change = 今天有效总量 - 今日软删 - 昨日有效总量 - changes["total_knowledge_change"] = today_knowledge - today_deleted_knowledge - yesterday_knowledge + changes["total_knowledge_change"] = _calc_percentage(today_knowledge, yesterday_knowledge) except Exception as e: business_logger.warning(f"计算知识库昨日对比失败: {str(e)}") - # --- total_app_change --- + # --- total_app_change: 只看活跃(is_active=True),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 --- try: # === 自有app === - # 今天有效总量 today_own_apps = db.query(func.count(App.id)).filter( App.workspace_id == workspace_id, App.is_active == True ).scalar() or 0 - # 昨日有效总量 yesterday_own_apps = db.query(func.count(App.id)).filter( App.workspace_id == workspace_id, App.is_active == True, App.created_at < today_start ).scalar() or 0 - # 今日软删 - today_deleted_own_apps = db.query(func.count(App.id)).filter( - App.workspace_id == workspace_id, - App.is_active == False, - App.updated_at >= today_start - ).scalar() or 0 # === 被分享app === - # 今天有效总量 today_shared_apps = db.query(func.count(AppShare.id)).filter( AppShare.target_workspace_id == workspace_id, AppShare.is_active == True ).scalar() or 0 - # 昨日有效总量 yesterday_shared_apps = db.query(func.count(AppShare.id)).filter( AppShare.target_workspace_id == workspace_id, AppShare.is_active == True, AppShare.created_at < today_start ).scalar() or 0 - # 今日软删 - today_deleted_shared_apps = db.query(func.count(AppShare.id)).filter( - AppShare.target_workspace_id == workspace_id, - AppShare.is_active == False, - AppShare.updated_at >= today_start - ).scalar() or 0 today_total_app = today_own_apps + today_shared_apps yesterday_total_app = yesterday_own_apps + yesterday_shared_apps - total_deleted = today_deleted_own_apps + today_deleted_shared_apps - if yesterday_total_app == 0 and today_total_app == 0 and total_deleted == 0: - changes["total_app_change"] = None - else: - # change = 今天有效总量 - 今日软删 - 昨日有效总量 - changes["total_app_change"] = today_total_app - total_deleted - yesterday_total_app + changes["total_app_change"] = _calc_percentage(today_total_app, yesterday_total_app) except Exception as e: business_logger.warning(f"计算应用数量昨日对比失败: {str(e)}") - # --- total_memory_change --- + # --- total_memory_change: (今日总量 - 昨日总量) / 昨日总量 --- try: today_memory = today_data.get("total_memory") if today_memory is None: changes["total_memory_change"] = None elif storage_type == "neo4j": - # 从 memory_increments 取最近一条 created_at < today_start 的记录 last_record = db.query(MemoryIncrement).filter( MemoryIncrement.workspace_id == workspace_id, MemoryIncrement.created_at < today_start ).order_by(desc(MemoryIncrement.created_at)).first() - if last_record is None: + if last_record is None or last_record.total_num == 0: changes["total_memory_change"] = None else: - changes["total_memory_change"] = today_memory - last_record.total_num + changes["total_memory_change"] = _calc_percentage(today_memory, last_record.total_num) elif storage_type == "rag": - # RAG: 查 documents 表中 created_at < today_start 的 chunk_num 之和 from app.models.document_model import Document from app.models.end_user_model import EndUser as _EndUser from app.models.app_model import App as _App @@ -691,18 +665,18 @@ def get_dashboard_yesterday_changes( changes["total_memory_change"] = None else: file_names = [f"{uid}.txt" for uid in end_user_ids] - yesterday_chunk = db.query(func.sum(Document.chunk_num)).filter( + yesterday_chunk = int(db.query(func.sum(Document.chunk_num)).filter( Document.file_name.in_(file_names), Document.created_at < today_start - ).scalar() - if yesterday_chunk is None: + ).scalar() or 0) + if yesterday_chunk == 0: changes["total_memory_change"] = None else: - changes["total_memory_change"] = today_memory - int(yesterday_chunk) + changes["total_memory_change"] = _calc_percentage(today_memory, yesterday_chunk) except Exception as e: business_logger.warning(f"计算记忆总量昨日对比失败: {str(e)}") - business_logger.info(f"昨日对比计算完成: {changes}") + business_logger.info(f"昨日对比百分比计算完成: {changes}") return changes @@ -1104,15 +1078,11 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict: except Exception as e: business_logger.warning(f"获取知识库数量失败: {e}") - # total_api_call: 仅统计当天 api_key_log 调用次数 + # total_api_call: 截止当前的历史累计调用总数 try: - from datetime import datetime from sqlalchemy import func as _api_func from app.models.api_key_model import ApiKey as _ApiKey, ApiKeyLog as _ApiKeyLog - _now = datetime.now() - _today_start = _now.replace(hour=0, minute=0, second=0, microsecond=0) - _api_key_ids = [ row[0] for row in db.query(_ApiKey.id).filter( _ApiKey.workspace_id == workspace_id @@ -1120,9 +1090,7 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict: ] if _api_key_ids: total_api_calls = db.query(_api_func.count(_ApiKeyLog.id)).filter( - _ApiKeyLog.api_key_id.in_(_api_key_ids), - _ApiKeyLog.created_at >= _today_start, - _ApiKeyLog.created_at < _now + _ApiKeyLog.api_key_id.in_(_api_key_ids) ).scalar() or 0 else: total_api_calls = 0 diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index 5fccee54..b771c639 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -25,7 +25,7 @@ from app.repositories.workflow_repository import ( WorkflowExecutionRepository, WorkflowNodeExecutionRepository ) -from app.schemas import DraftRunRequest, FileInput +from app.schemas import DraftRunRequest, FileInput, FileType from app.services.conversation_service import ConversationService from app.services.multi_agent_service import convert_uuids_to_str from app.services.multimodal_service import MultimodalService @@ -466,7 +466,7 @@ class WorkflowService: if not isinstance(item, dict) or item.get("is_file"): return item transfer_method = item.get("transfer_method", "remote_url") - file_type = item.get("type", "document") + file_type = FileType.trans(item.get("type", "document")) origin_file_type = item.get("file_type") or file_type if transfer_method == "remote_url": url = item.get("url", "") diff --git a/web/package.json b/web/package.json index 0284f397..b41ab9b5 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/state": "^6.5.4", diff --git a/web/src/assets/images/file/audio_disabled.svg b/web/src/assets/images/file/audio_disabled.svg new file mode 100644 index 00000000..93d83a0a --- /dev/null +++ b/web/src/assets/images/file/audio_disabled.svg @@ -0,0 +1,13 @@ + + + 音乐 + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/csv_disabled.svg b/web/src/assets/images/file/csv_disabled.svg new file mode 100644 index 00000000..29add1f6 --- /dev/null +++ b/web/src/assets/images/file/csv_disabled.svg @@ -0,0 +1,18 @@ + + + 编组 57 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/excel_disabled.svg b/web/src/assets/images/file/excel_disabled.svg new file mode 100644 index 00000000..5e2136e9 --- /dev/null +++ b/web/src/assets/images/file/excel_disabled.svg @@ -0,0 +1,17 @@ + + + Excel + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/html_disabled.svg b/web/src/assets/images/file/html_disabled.svg new file mode 100644 index 00000000..fa237301 --- /dev/null +++ b/web/src/assets/images/file/html_disabled.svg @@ -0,0 +1,17 @@ + + + Word + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/json_disabled.svg b/web/src/assets/images/file/json_disabled.svg new file mode 100644 index 00000000..267e2b46 --- /dev/null +++ b/web/src/assets/images/file/json_disabled.svg @@ -0,0 +1,14 @@ + + + JSON + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/md_disabled.svg b/web/src/assets/images/file/md_disabled.svg new file mode 100644 index 00000000..8fe81fe7 --- /dev/null +++ b/web/src/assets/images/file/md_disabled.svg @@ -0,0 +1,19 @@ + + + PDF + + + + + + + + + + MD + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/pause.svg b/web/src/assets/images/file/pause.svg new file mode 100644 index 00000000..0e26ece0 --- /dev/null +++ b/web/src/assets/images/file/pause.svg @@ -0,0 +1,16 @@ + + + 播放 + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/pdf_disabled.svg b/web/src/assets/images/file/pdf_disabled.svg new file mode 100644 index 00000000..950edcb8 --- /dev/null +++ b/web/src/assets/images/file/pdf_disabled.svg @@ -0,0 +1,20 @@ + + + PDF + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/play.svg b/web/src/assets/images/file/play.svg new file mode 100644 index 00000000..f2ff9cb7 --- /dev/null +++ b/web/src/assets/images/file/play.svg @@ -0,0 +1,28 @@ + + + 播放 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/ppt_disabled.svg b/web/src/assets/images/file/ppt_disabled.svg new file mode 100644 index 00000000..f3da453e --- /dev/null +++ b/web/src/assets/images/file/ppt_disabled.svg @@ -0,0 +1,14 @@ + + + file-ppt-2-fill + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/txt_disabled.svg b/web/src/assets/images/file/txt_disabled.svg new file mode 100644 index 00000000..100565ce --- /dev/null +++ b/web/src/assets/images/file/txt_disabled.svg @@ -0,0 +1,14 @@ + + + txt + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/video_disabled.svg b/web/src/assets/images/file/video_disabled.svg new file mode 100644 index 00000000..f8f71c2a --- /dev/null +++ b/web/src/assets/images/file/video_disabled.svg @@ -0,0 +1,16 @@ + + + 编组 59 + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/file/word_disabled.svg b/web/src/assets/images/file/word_disabled.svg new file mode 100644 index 00000000..d4f9e6ec --- /dev/null +++ b/web/src/assets/images/file/word_disabled.svg @@ -0,0 +1,15 @@ + + + Word + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index 2bcf479e..b06e1e88 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:46:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-02 16:05:01 + * @Last Modified time: 2026-04-08 11:23:18 */ import { type FC, useRef, useEffect, useState } from 'react' import clsx from 'clsx' @@ -131,7 +131,9 @@ const ChatContent: FC = ({
{data.length === 0 ? empty // Display empty state - : data.map((item, index) => ( + : data.map((item, index) => { + if (!item) return null + return (
= ({ {labelFormat(item)}
} - {item.meta_data?.files && item.meta_data?.files.length > 0 && + {item?.meta_data?.files && item.meta_data?.files.length > 0 && {item.meta_data?.files?.map((file) => { if (file.type.includes('image')) { return ( @@ -185,23 +187,23 @@ const ChatContent: FC = ({ "rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]", file.type?.includes('pdf') ? "rb:bg-[url('@/assets/images/file/pdf.svg')]" - : (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) - ? "rb:bg-[url('@/assets/images/file/excel.svg')]" - : file.type?.includes('csv') - ? "rb:bg-[url('@/assets/images/file/csv.svg')]" - : file.type?.includes('html') - ? "rb:bg-[url('@/assets/images/file/html.svg')]" - : file.type?.includes('json') - ? "rb:bg-[url('@/assets/images/file/json.svg')]" - : file.type?.includes('ppt') - ? "rb:bg-[url('@/assets/images/file/ppt.svg')]" - : file.type?.includes('text') - ? "rb:bg-[url('@/assets/images/file/txt.svg')]" - : file.type?.includes('markdown') - ? "rb:bg-[url('@/assets/images/file/md.svg')]" - : (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document')) - ? "rb:bg-[url('@/assets/images/file/word.svg')]" - : null + : (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) || file.type?.includes('xls') || file.type?.includes('xlsx') + ? "rb:bg-[url('@/assets/images/file/excel.svg')]" + : file.type?.includes('csv') + ? "rb:bg-[url('@/assets/images/file/csv.svg')]" + : file.type?.includes('html') + ? "rb:bg-[url('@/assets/images/file/html.svg')]" + : file.type?.includes('json') + ? "rb:bg-[url('@/assets/images/file/json.svg')]" + : file.type?.includes('ppt') + ? "rb:bg-[url('@/assets/images/file/ppt.svg')]" + : file.type?.includes('markdown') + ? "rb:bg-[url('@/assets/images/file/md.svg')]" + : file.type?.includes('text') + ? "rb:bg-[url('@/assets/images/file/txt.svg')]" + : (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document')) + ? "rb:bg-[url('@/assets/images/file/word.svg')]" + : "rb:bg-[url('@/assets/images/file/txt.svg')]" )} >
@@ -218,7 +220,7 @@ const ChatContent: FC = ({ 'rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': (item.status && item.status !== 'completed') || (errorDesc && item.role === 'assistant' && item.content === null && !renderRuntime), // Assistant message style 'rb:bg-[#E3EBFD] rb:p-[10px_12px_2px_12px] rb:rounded-lg rb:max-w-130': item.role === 'user', - 'rb:max-w-full': item.role === 'assistant', + 'rb:max-w-full rb:w-full': item.role === 'assistant', // User message style 'rb:text-[#212332]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'), 'rb:mt-1': labelPosition === 'top', @@ -282,7 +284,7 @@ const ChatContent: FC = ({ }
{/* Bottom label (such as timestamp, username, etc.) */} - {labelPosition === 'bottom' && + {(labelPosition === 'bottom' || item.meta_data?.audio_url) && {item.meta_data?.audio_url && <> {playingIndex !== item.meta_data?.audio_url && item.meta_data?.audio_status === 'pending' ? @@ -297,15 +299,15 @@ const ChatContent: FC = ({ /> } } -
+ {labelPosition === 'bottom' &&
{labelFormat(item)} -
+
}
} } - )) + )}) } ) diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 6495ff06..4843b198 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -5,10 +5,11 @@ * @Last Modified time: 2026-03-23 17:46:25 */ import { type FC, useEffect, useMemo, useState } from 'react' -import { Flex, Input, Spin } from 'antd' +import { Flex, Input } from 'antd' import clsx from 'clsx' import type { ChatInputProps } from './types' +import FileList from './FileList' /** * Chat Input Component @@ -26,6 +27,7 @@ const ChatInput: FC = ({ }) => { const [inputValue, setInputValue] = useState('') const [isFocus, setIsFocus] = useState(false) + const [isComposing, setIsComposing] = useState(false) // Clear input when external message is cleared useEffect(() => { @@ -52,6 +54,7 @@ const ChatInput: FC = ({ })) || [] }, [fileList]) + const handleSend = () => { if (loading || !inputValue || inputValue.trim() === '') return onSend(inputValue) @@ -64,100 +67,9 @@ const ChatInput: FC = ({ - {previewFileList.length > 0 &&
- - {previewFileList.map((file) => { - if (file.type?.includes('image')) { - return ( - -
- {file.name} -
handleDelete(file)} - >
-
-
- ) - } - if (file.type?.includes('video')) { - return ( - -
-
-
- ) - } - if (file.type?.includes('audio')) { - return ( - -
-
-
- ) - } - return ( - - -
-
-
{file.name}
-
{file.type?.split('/')[file.type?.split('/').length - 1]} · {file.size}
-
-
handleDelete(file)} - >
-
-
- ) - })} -
-
} +
+ +
{/* Message input area */} = ({ setInputValue(e.target.value) onChange?.(e.target.value) }} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} onKeyDown={(e) => { // Enter to send, Shift+Enter for new line - if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) { + if (e.key === 'Enter' && !e.shiftKey && !isComposing && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) { e.preventDefault(); handleSend(); } diff --git a/web/src/components/Chat/ChatToolbar.tsx b/web/src/components/Chat/ChatToolbar.tsx index c5db0c4c..744e2cd6 100644 --- a/web/src/components/Chat/ChatToolbar.tsx +++ b/web/src/components/Chat/ChatToolbar.tsx @@ -82,6 +82,7 @@ const ChatToolbar = forwardRef(({ setVariables: (variables) => { console.log('variables', variables) form.setFieldValue('variables', variables) + onVariablesChange?.(variables) }, })) diff --git a/web/src/components/Chat/FileList.tsx b/web/src/components/Chat/FileList.tsx new file mode 100644 index 00000000..a23085e6 --- /dev/null +++ b/web/src/components/Chat/FileList.tsx @@ -0,0 +1,168 @@ +import { type FC, useRef, useState } from 'react' +import { Flex, Spin } from 'antd' +import { CloseOutlined } from '@ant-design/icons' +import clsx from 'clsx' +import type { UploadFile, FlexProps } from 'antd' + +interface FileListProps { + fileList: UploadFile[]; + onDelete?: (file: UploadFile) => void; + wrap?: FlexProps['wrap']; + className?: string; +} + +const FileList: FC = ({ fileList, onDelete, wrap, + className = "rb:mx-3! rb:mt-3! rb:w-max!" + }) => { + const [playingUid, setPlayingUid] = useState(null) + const mediaRef = useRef(null) + + const handleClose = () => { + mediaRef.current?.pause() + setPlayingUid(null) + } + + const playingFile = fileList.find(f => f.uid === playingUid) + + if (!fileList.length) return null + + const getFileIconClassName = (file: UploadFile) => { + console.log('getFileIconClassName file', file) + if (file.status === 'uploading') { + return file.type?.includes('audio') + ? "rb:bg-[url('@/assets/images/file/audio_disabled.svg')]" + : file.type?.includes('video') + ? "rb:bg-[url('@/assets/images/file/video_disabled.svg')]" + : file.type?.includes('pdf') + ? "rb:bg-[url('@/assets/images/file/pdf_disabled.svg')]" + : (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) + ? "rb:bg-[url('@/assets/images/file/excel_disabled.svg')]" + : file.type?.includes('csv') + ? "rb:bg-[url('@/assets/images/file/csv_disabled.svg')]" + : file.type?.includes('html') + ? "rb:bg-[url('@/assets/images/file/html_disabled.svg')]" + : file.type?.includes('json') + ? "rb:bg-[url('@/assets/images/file/json_disabled.svg')]" + : file.type?.includes('ppt') + ? "rb:bg-[url('@/assets/images/file/ppt_disabled.svg')]" + : file.type?.includes('markdown') + ? "rb:bg-[url('@/assets/images/file/md_disabled.svg')]" + : file.type?.includes('text') + ? "rb:bg-[url('@/assets/images/file/txt_disabled.svg')]" + : (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document')) + ? "rb:bg-[url('@/assets/images/file/word_disabled.svg')]" + : "rb:bg-[url('@/assets/images/file/txt_disabled.svg')]" + } + return file.type?.includes('audio') + ? "rb:bg-[url('@/assets/images/file/audio.svg')]" + : file.type?.includes('video') + ? "rb:bg-[url('@/assets/images/file/video.svg')]" + : file.type?.includes('pdf') + ? "rb:bg-[url('@/assets/images/file/pdf.svg')]" + : (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) || file.type?.includes('xls') || file.type?.includes('xlsx') + ? "rb:bg-[url('@/assets/images/file/excel.svg')]" + : file.type?.includes('csv') + ? "rb:bg-[url('@/assets/images/file/csv.svg')]" + : file.type?.includes('html') + ? "rb:bg-[url('@/assets/images/file/html.svg')]" + : file.type?.includes('json') + ? "rb:bg-[url('@/assets/images/file/json.svg')]" + : file.type?.includes('ppt') + ? "rb:bg-[url('@/assets/images/file/ppt.svg')]" + : file.type?.includes('markdown') + ? "rb:bg-[url('@/assets/images/file/md.svg')]" + : file.type?.includes('text') + ? "rb:bg-[url('@/assets/images/file/txt.svg')]" + : (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document')) + ? "rb:bg-[url('@/assets/images/file/word.svg')]" + : "rb:bg-[url('@/assets/images/file/txt.svg')]" + } + + return ( + <> + + {fileList.map((file) => { + if (file.type?.includes('image')) { + return ( + +
+ {file.name} + {onDelete &&
onDelete(file)} + >
} +
+
+ ) + } + return ( + + +
+
+
{file.name}
+
{[file.type?.split('/').pop(), file.size].filter(item => item).join(' · ')}
+
+ {file.status === 'done' && (file.type?.includes('video') || file.type?.includes('audio')) && +
playingUid === file.uid ? handleClose() : setPlayingUid(file.uid)} + >
+ } + {onDelete &&
onDelete(file)} + >
} +
+
+ ) + })} +
+ + {playingFile && ( +
+ + {playingFile.type?.includes('video') ? ( +
+ )} + + ) +} + +export default FileList diff --git a/web/src/components/CodeMirrorEditor/index.tsx b/web/src/components/CodeMirrorEditor/index.tsx index e100b75b..ec2a6780 100644 --- a/web/src/components/CodeMirrorEditor/index.tsx +++ b/web/src/components/CodeMirrorEditor/index.tsx @@ -6,12 +6,14 @@ */ import { useEffect, useRef, useMemo } from 'react'; import { EditorView, basicSetup } from 'codemirror'; +import { placeholder as cmPlaceholder } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; import { python } from '@codemirror/lang-python'; import { javascript } from '@codemirror/lang-javascript'; import { java } from '@codemirror/lang-java'; import { cpp } from '@codemirror/lang-cpp'; import { rust } from '@codemirror/lang-rust'; +import { json } from '@codemirror/lang-json'; import { oneDark } from '@codemirror/theme-one-dark'; /** @@ -26,12 +28,14 @@ import { oneDark } from '@codemirror/theme-one-dark'; */ interface CodeMirrorEditorProps { value?: string; - language?: 'python' | 'python3' | 'javascript' | 'typescript' | 'java' | 'cpp' | 'c' | 'rust'; + language?: 'python' | 'python3' | 'javascript' | 'typescript' | 'java' | 'cpp' | 'c' | 'rust' | 'json'; onChange?: (value: string) => void; theme?: 'light' | 'dark'; readOnly?: boolean; height?: string; size?: 'default' | 'small'; + placeholder?: string; + variant?: 'outlined' | 'borderless'; } /** @@ -47,6 +51,7 @@ const languageExtensions: Record = { cpp: cpp(), c: cpp(), rust: rust(), + json: json(), }; /** @@ -61,6 +66,8 @@ const CodeMirrorEditor = ({ theme = 'light', readOnly = false, size, + placeholder, + variant = 'borderless', }: CodeMirrorEditorProps) => { // Reference to the DOM element that will contain the editor const editorRef = useRef(null); @@ -88,6 +95,7 @@ const CodeMirrorEditor = ({ } }), EditorState.readOnly.of(readOnly), // Set read-only mode + ...(placeholder ? [cmPlaceholder(placeholder)] : []), ]; // Apply dark theme if specified @@ -111,7 +119,7 @@ const CodeMirrorEditor = ({ return () => { viewRef.current?.destroy(); }; - }, [language, theme, readOnly]); + }, [language, theme, readOnly, placeholder]); /** * Update editor content when the value prop changes externally @@ -144,7 +152,13 @@ const CodeMirrorEditor = ({ return `${size === 'small' ? 16 : 20}px` }, [size]) - return
; + return ( +
+ ); }; export default CodeMirrorEditor; diff --git a/web/src/components/DebounceSelect/index.tsx b/web/src/components/DebounceSelect/index.tsx index ab8379ad..9121b30d 100644 --- a/web/src/components/DebounceSelect/index.tsx +++ b/web/src/components/DebounceSelect/index.tsx @@ -10,6 +10,7 @@ interface OptionType { interface ApiResponse { items?: T[]; + page: { hasnext: boolean }; } export interface DebounceSelectProps extends Omit { @@ -23,8 +24,9 @@ export interface DebounceSelectProps extends Omit { labelKey?: string; /** Key name sent to the API for the search keyword */ searchKey?: string; + pageSize?: number; /** Custom fetch function — mutually exclusive with url */ - fetchOptions?: (search: string | null) => Promise; + fetchOptions?: (search: string | null, page: number) => Promise<{ options: DefaultOptionType[]; hasMore: boolean }>; /** Transform raw API items before rendering */ format?: (items: OptionType[]) => OptionType[]; debounceTimeout?: number; @@ -32,10 +34,11 @@ export interface DebounceSelectProps extends Omit { const DebounceSelect: FC = ({ url, - params = { page: 1, pagesize: 20 }, + params = {}, valueKey = 'value', labelKey = 'label', searchKey = 'search', + pageSize = 20, fetchOptions, format, debounceTimeout = 300, @@ -43,56 +46,81 @@ const DebounceSelect: FC = ({ }) => { const [fetching, setFetching] = useState(false); const [options, setOptions] = useState([]); + const [hasMore, setHasMore] = useState(true); + const pageRef = useRef(1); + const keywordRef = useRef(null); const fetchRef = useRef(0); - const timerRef = useRef>(); - // Load initial options on mount - useEffect(() => { - debounceFetcher(null); - }, []); + const fetchPage = useCallback((keyword: string | null, page: number, replace: boolean) => { + fetchRef.current += 1; + const fetchId = fetchRef.current; + setFetching(true); + + const promise = fetchOptions + ? fetchOptions(keyword, page) + : request + .get>(url!, { ...params, [searchKey]: keyword, page, pagesize: pageSize }) + .then((res) => { + const data: OptionType[] = Array.isArray(res) ? res : res?.items || []; + const formatted = format + ? format(data) + : data.map((item) => ({ label: item[labelKey], value: item[valueKey], avatar: item.avatar, raw: item })); + + console.log('more', res.page?.hasnext) + return { options: formatted, hasMore: res.page?.hasnext }; + }); + + promise + .then(({ options: newOptions, hasMore: more }) => { + if (fetchId !== fetchRef.current) return; + setOptions((prev) => (replace ? newOptions : [...prev, ...newOptions])); + setHasMore(more); + setFetching(false); + }) + .catch(() => setFetching(false)); + }, [url, params, searchKey, fetchOptions, format, valueKey, labelKey, pageSize]); const debounceFetcher = useCallback((keyword: string | null) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { - fetchRef.current += 1; - const fetchId = fetchRef.current; - setOptions([]); - setFetching(true); - - const promise: Promise = fetchOptions - ? fetchOptions(keyword) - : request - .get>(url!, { ...params, [searchKey]: keyword }) - .then((res) => { - const data: OptionType[] = Array.isArray(res) ? res : res?.items || []; - const formatted = format ? format(data) : data.map((item) => ({ - label: item[labelKey], - value: item[valueKey], - avatar: item.avatar, - raw: item, - })); - return formatted; - }); - - promise - .then((newOptions) => { - if (fetchId !== fetchRef.current) return; - setOptions(newOptions); - setFetching(false); - }) - .catch(() => setFetching(false)); + keywordRef.current = keyword; + pageRef.current = 1; + fetchPage(keyword, 1, true); }, debounceTimeout); - }, [url, params, searchKey, fetchOptions, format, valueKey, labelKey, debounceTimeout]); + }, [fetchPage, debounceTimeout]); + + useEffect(() => { + debounceFetcher(null); + }, []); + + const handlePopupScroll = useCallback((e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (!fetching && hasMore && scrollHeight - scrollTop - clientHeight < 50) { + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + fetchPage(keywordRef.current, nextPage, false); + } + }, [fetching, hasMore, fetchPage]); return ( { if (props.name) ctx?.setValue(props.name, val) }} /> + return handleChange(e.target.value)} /> + return handleChange(e.target.value)} /> } }, select: ({ children, ...props }: any) => { @@ -152,15 +154,7 @@ const buildComponents = (onFormSubmit?: (values: Record) => void) = const ctx = useContext(FormContext) return { if (props.name) ctx?.setValue(props.name, e.target.value) }}>{children} }, - form: ({ children, ...props }: any) => { - const [values, setValues] = useState>({}) - const setValue = useCallback((name: string, value: any) => setValues(prev => ({ ...prev, [name]: value })), []) - return ( - -
{children}
-
- ) - }, + form: RbForm, label: ({ children, ...props }: any) => { return }, @@ -175,7 +169,10 @@ const RbMarkdown: FC = ({ className, onFormSubmit, }) => { - const components = buildComponents(onFormSubmit) + const [formValues, setFormValues] = useState>({}) + const setValue = useCallback((name: string, value: any) => setFormValues(prev => ({ ...prev, [name]: value })), []) + const formCtx = useMemo(() => ({ values: formValues, setValue, onSubmit: onFormSubmit }), [formValues, setValue, onFormSubmit]) + const components = useMemo(() => buildComponents(), []) const [editContent, setEditContent] = useState(content) const textareaRef = useRef(null) @@ -242,6 +239,7 @@ const RbMarkdown: FC = ({ /** Render markdown preview mode */ return ( +