Merge pull request #833 from SuanmoSuanyangTechnology/release/v0.2.10

Release/v0.2.10
This commit is contained in:
Ke Sun
2026-04-08 21:45:35 +08:00
committed by GitHub
89 changed files with 1712 additions and 858 deletions

View File

@@ -14,6 +14,7 @@ from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from langgraph.errors import GraphRecursionError
from app.core.logging_config import get_business_logger from app.core.logging_config import get_business_logger
from app.core.models import RedBearLLM, RedBearModelConfig from app.core.models import RedBearLLM, RedBearModelConfig
@@ -377,7 +378,7 @@ class LangChainAgent:
{"messages": messages}, {"messages": messages},
config={"recursion_limit": self.max_iterations} config={"recursion_limit": self.max_iterations}
) )
except RecursionError as e: except (RecursionError, GraphRecursionError) as e:
logger.warning( logger.warning(
f"Agent 达到最大迭代次数限制 ({self.max_iterations}),可能存在工具调用循环", f"Agent 达到最大迭代次数限制 ({self.max_iterations}),可能存在工具调用循环",
extra={"error": str(e)} extra={"error": str(e)}
@@ -612,6 +613,12 @@ class LangChainAgent:
yield stream_total_tokens yield stream_total_tokens
break break
except GraphRecursionError:
logger.warning(
f"Agent 达到最大迭代次数限制 ({self.max_iterations}),模型可能不支持正确的工具调用停止判断"
)
if not full_content:
yield "抱歉,我在处理您的请求时遇到了问题(已达最大处理步骤限制)。请尝试简化问题或更换模型后重试。"
except Exception as e: except Exception as e:
logger.error(f"Agent astream_events 失败: {str(e)}", exc_info=True) logger.error(f"Agent astream_events 失败: {str(e)}", exc_info=True)
raise raise

View File

@@ -19,6 +19,7 @@ class BizCode(IntEnum):
TENANT_NOT_FOUND = 3002 TENANT_NOT_FOUND = 3002
WORKSPACE_NO_ACCESS = 3003 WORKSPACE_NO_ACCESS = 3003
WORKSPACE_INVITE_NOT_FOUND = 3004 WORKSPACE_INVITE_NOT_FOUND = 3004
WORKSPACE_ACCESS_DENIED = 3005
# API Key 管理3xxx # API Key 管理3xxx
API_KEY_NOT_FOUND = 3007 API_KEY_NOT_FOUND = 3007
API_KEY_DUPLICATE_NAME = 3008 API_KEY_DUPLICATE_NAME = 3008
@@ -113,6 +114,8 @@ HTTP_MAPPING = {
BizCode.FORBIDDEN: 403, BizCode.FORBIDDEN: 403,
BizCode.TENANT_NOT_FOUND: 400, BizCode.TENANT_NOT_FOUND: 400,
BizCode.WORKSPACE_NO_ACCESS: 403, BizCode.WORKSPACE_NO_ACCESS: 403,
BizCode.WORKSPACE_INVITE_NOT_FOUND: 400,
BizCode.WORKSPACE_ACCESS_DENIED: 403,
BizCode.NOT_FOUND: 400, BizCode.NOT_FOUND: 400,
BizCode.USER_NOT_FOUND: 200, BizCode.USER_NOT_FOUND: 200,
BizCode.WORKSPACE_NOT_FOUND: 400, BizCode.WORKSPACE_NOT_FOUND: 400,

View File

@@ -79,8 +79,10 @@ class RedBearModelFactory:
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
if is_streaming: if is_streaming:
model_kwargs["enable_thinking"] = config.deep_thinking model_kwargs["enable_thinking"] = config.deep_thinking
if config.deep_thinking and config.thinking_budget_tokens: if config.deep_thinking:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens model_kwargs["incremental_output"] = True
if config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
else: else:
model_kwargs["enable_thinking"] = False model_kwargs["enable_thinking"] = False
params["model_kwargs"] = model_kwargs params["model_kwargs"] = model_kwargs
@@ -110,7 +112,7 @@ class RedBearModelFactory:
params["stream_usage"] = True params["stream_usage"] = True
# 深度思考模式 # 深度思考模式
is_streaming = bool(config.extra_params.get("streaming")) is_streaming = bool(config.extra_params.get("streaming"))
if is_streaming: if is_streaming and not config.is_omni:
if provider == ModelProvider.VOLCANO: if provider == ModelProvider.VOLCANO:
# 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数
thinking_config: Dict[str, Any] = { thinking_config: Dict[str, Any] = {
@@ -140,8 +142,10 @@ class RedBearModelFactory:
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
if is_streaming: if is_streaming:
model_kwargs["enable_thinking"] = config.deep_thinking model_kwargs["enable_thinking"] = config.deep_thinking
if config.deep_thinking and config.thinking_budget_tokens: if config.deep_thinking:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens model_kwargs["incremental_output"] = True
if config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
else: else:
model_kwargs["enable_thinking"] = False model_kwargs["enable_thinking"] = False
params["model_kwargs"] = model_kwargs params["model_kwargs"] = model_kwargs

View File

@@ -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 langchain_core.embeddings import Embeddings
from app.core.models.base import RedBearModelConfig, get_provider_embedding_class, RedBearModelFactory 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._model = self._create_model(config)
self._client = None self._client = None
def _create_model(self, config: RedBearModelConfig) -> Embeddings: @staticmethod
def _create_model(config: RedBearModelConfig) -> Embeddings:
"""根据配置创建 LangChain 模型""" """根据配置创建 LangChain 模型"""
embedding_class = get_provider_embedding_class(config.provider) embedding_class = get_provider_embedding_class(config.provider)
provider = config.provider.lower() provider = config.provider.lower()
@@ -36,6 +37,8 @@ class RedBearEmbeddings(Embeddings):
"api_key": config.api_key, "api_key": config.api_key,
"timeout": httpx.Timeout(timeout=config.timeout, connect=60.0), "timeout": httpx.Timeout(timeout=config.timeout, connect=60.0),
"max_retries": config.max_retries, "max_retries": config.max_retries,
"check_embedding_ctx_length": False,
"encoding_format": "float"
} }
elif provider == ModelProvider.DASHSCOPE: elif provider == ModelProvider.DASHSCOPE:
params = { params = {

View File

@@ -803,7 +803,6 @@ models:
- vision - vision
- video - video
- audio - audio
- thinking
is_omni: true is_omni: true
tags: tags:
- 大语言模型 - 大语言模型

View File

@@ -131,7 +131,7 @@ class DifyConverter(BaseConverter):
selector = var_selector.split('.') selector = var_selector.split('.')
if len(selector) not in [2, 3] and var_selector != "context": if len(selector) not in [2, 3] and var_selector != "context":
raise Exception(f"invalid variable selector: {var_selector}") 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:] selector = selector[1:]
if selector[0] == "conversation": if selector[0] == "conversation":
selector[0] = "conv" selector[0] = "conv"
@@ -483,11 +483,11 @@ class DifyConverter(BaseConverter):
node_data = node["data"] node_data = node["data"]
result = IterationNodeConfig.model_construct( result = IterationNodeConfig.model_construct(
input=self._process_list_variable_literal(node_data["iterator_selector"]), input=self._process_list_variable_literal(node_data["iterator_selector"]),
parallel=node_data["is_parallel"], parallel=node_data.get("is_parallel", False),
parallel_count=node_data["parallel_nums"], parallel_count=node_data.get("parallel_nums", 4),
output=self._process_list_variable_literal(node_data["output_selector"]), output=self._process_list_variable_literal(node_data["output_selector"]),
output_type=self.variable_type_map(node_data.get("output_type")), 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() ).model_dump()
self.config_validate(node["id"], node["data"]["title"], IterationNodeConfig, result) 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: def convert_assigner_node_config(self, node: dict) -> dict:
node_data = node["data"] node_data = node["data"]
assignments = [] 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: if assignment.get("operation") is None or assignment.get("value") is None:
continue continue
assignments.append( assignments.append(

View File

@@ -1,5 +1,5 @@
from typing import Any 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.base_config import BaseNodeConfig
from app.core.workflow.nodes.enums import ComparisonOperator from app.core.workflow.nodes.enums import ComparisonOperator
@@ -31,6 +31,11 @@ class ExtractConfig(BaseModel):
enabled: bool = False enabled: bool = False
serial: str = "1" # 1-based index string, e.g. "1" = first 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): class ListOperatorNodeConfig(BaseNodeConfig):
""" """

View File

@@ -11,7 +11,7 @@ from app.core.workflow.variable.base_variable import VariableType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# File object fields that hold string values # 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"} _FILE_NUMBER_KEYS = {"size"}
@@ -52,7 +52,7 @@ class ListOperatorNode(BaseNode):
result = [result[idx]] result = [result[idx]]
# 3. Order # 3. Order
if cfg.order_by.enabled and cfg.order_by.key: if cfg.order_by.enabled:
reverse = cfg.order_by.value == "desc" reverse = cfg.order_by.value == "desc"
key_fn = self._make_sort_key(cfg.order_by.key) key_fn = self._make_sort_key(cfg.order_by.key)
result = sorted(result, key=key_fn, reverse=reverse) result = sorted(result, key=key_fn, reverse=reverse)
@@ -100,10 +100,17 @@ class ListOperatorNode(BaseNode):
else: else:
left = item # primitive array: compare element directly 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 # Numeric operators
if op == ComparisonOperator.EQ: if op == ComparisonOperator.EQ:
if is_string_field:
return str(left) == str(value)
return self._safe_num(left) == self._safe_num(value) return self._safe_num(left) == self._safe_num(value)
if op == ComparisonOperator.NE: if op == ComparisonOperator.NE:
if is_string_field:
return str(left) != str(value)
return self._safe_num(left) != self._safe_num(value) return self._safe_num(left) != self._safe_num(value)
if op == ComparisonOperator.LT: if op == ComparisonOperator.LT:
return self._safe_num(left) < self._safe_num(value) return self._safe_num(left) < self._safe_num(value)

View File

@@ -246,7 +246,10 @@ class LLMNode(BaseNode):
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}") logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}")
# 返回 AIMessage包含响应元数据 # 返回 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]: def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]:
"""提取输入数据(用于记录)""" """提取输入数据(用于记录)"""
@@ -305,15 +308,16 @@ class LLMNode(BaseNode):
# 调用 LLM流式支持字符串或消息列表 # 调用 LLM流式支持字符串或消息列表
last_meta_data = {} last_meta_data = {}
last_usage_metadata = {}
async for chunk in llm.astream(self.messages): async for chunk in llm.astream(self.messages):
# 提取内容
if hasattr(chunk, 'content'): if hasattr(chunk, 'content'):
content = self.process_model_output(chunk.content) content = self.process_model_output(chunk.content)
else: else:
content = str(chunk) content = str(chunk)
if hasattr(chunk, 'response_metadata'): if hasattr(chunk, 'response_metadata') and chunk.response_metadata:
if chunk.response_metadata: last_meta_data = 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: if content:
@@ -336,7 +340,10 @@ class LLMNode(BaseNode):
# 构建完整的 AIMessage包含元数据 # 构建完整的 AIMessage包含元数据
final_message = AIMessage( final_message = AIMessage(
content=full_response, 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 完成标记 # yield 完成标记

View File

@@ -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.engine.variable_pool import VariablePool
from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.base_node import BaseNode
from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig 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.db import get_db_read
from app.models import ModelType from app.models import ModelType
from app.services.model_service import ModelConfigService from app.services.model_service import ModelConfigService
@@ -45,6 +45,12 @@ class ParameterExtractorNode(BaseNode):
"model_id": str(self.typed_config.model_id), "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]: def _output_types(self) -> dict[str, VariableType]:
outputs = {} outputs = {}
for param in self.typed_config.params: for param in self.typed_config.params:
@@ -202,7 +208,10 @@ class ParameterExtractorNode(BaseNode):
]) ])
model_resp = await llm.ainvoke(messages) 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) model_message = self.process_model_output(model_resp.content)
result = json_repair.repair_json(model_message, return_objects=True) result = json_repair.repair_json(model_message, return_objects=True)
logger.info(f"node: {self.node_id} get params:{result}") logger.info(f"node: {self.node_id} get params:{result}")

View File

@@ -136,7 +136,10 @@ class QuestionClassifierNode(BaseNode):
response = await llm.ainvoke(messages) response = await llm.ainvoke(messages)
result = self.process_model_output(response.content) 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: if result in category_names:
category = result category = result

View File

@@ -91,7 +91,7 @@ async def fetch_remote_file_meta(
""" """
import httpx import httpx
name = size = mime_type = extension = None name = extension = None
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.head(url, follow_redirects=True) resp = await client.head(url, follow_redirects=True)

View File

@@ -4,6 +4,10 @@ from typing import Optional, Any, List, Dict, Union
from enum import Enum, StrEnum from enum import Enum, StrEnum
from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator
from app.schemas.workflow_schema import WorkflowConfigCreate
# ---------- Multimodal File Support ---------- # ---------- Multimodal File Support ----------
class FileType(StrEnum): class FileType(StrEnum):
@@ -313,7 +317,7 @@ class AppCreate(BaseModel):
# only for type=multi_agent # only for type=multi_agent
multi_agent_config: Optional[Dict[str, Any]] = None multi_agent_config: Optional[Dict[str, Any]] = None
workflow_config: Optional[Dict[str, Any]] = None workflow_config: Optional[WorkflowConfigCreate] = None
class AppUpdate(BaseModel): class AppUpdate(BaseModel):

View File

@@ -32,7 +32,7 @@ class ChatRequest(BaseModel):
web_search: bool = Field(default=False, description="是否启用网络搜索") web_search: bool = Field(default=False, description="是否启用网络搜索")
memory: bool = Field(default=True, description="是否启用记忆功能") memory: bool = Field(default=True, description="是否启用记忆功能")
thinking: bool = Field(default=False, description="是否启用深度思考需Agent配置支持") 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 ---------- # ---------- Output Schemas ----------

View File

@@ -231,8 +231,13 @@ class AppChatService:
if memory_flag: if memory_flag:
connected_config = get_end_user_connected_config(user_id, self.db) connected_config = get_end_user_connected_config(user_id, self.db)
memory_config_id: str = connected_config.get("memory_config_id") 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 = [ 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"]} {"role": "assistant", "content": result["content"]}
] ]
if memory_config_id: if memory_config_id:
@@ -506,8 +511,13 @@ class AppChatService:
if memory_flag: if memory_flag:
connected_config = get_end_user_connected_config(user_id, self.db) connected_config = get_end_user_connected_config(user_id, self.db)
memory_config_id: str = connected_config.get("memory_config_id") 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 = [ 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} {"role": "assistant", "content": full_content}
] ]
if memory_config_id: if memory_config_id:

View File

@@ -1360,6 +1360,7 @@ class AppService:
variables=cfg.get("variables", []), variables=cfg.get("variables", []),
execution_config=cfg.get("execution_config", {}), execution_config=cfg.get("execution_config", {}),
triggers=cfg.get("triggers", []), triggers=cfg.get("triggers", []),
features=cfg.get("features", {}),
is_active=True, is_active=True,
created_at=now, created_at=now,
updated_at=now, updated_at=now,

View File

@@ -513,7 +513,14 @@ def get_dashboard_yesterday_changes(
today_data: dict today_data: dict
) -> dict: ) -> dict:
""" """
计算各指标相比昨天的变化 计算各指标相比昨天的变化百分比
- total_app_change / total_knowledge_change只看活跃记录
百分比 = (截止今日活跃总量 - 截止昨日活跃总量) / 截止昨日活跃总量
- total_memory_change / total_api_call_change
百分比 = (今日总量 - 昨日总量) / 昨日总量
昨日总量为 0 时返回 None。返回值为浮点数例如 0.5 表示增长 50%
Args: Args:
db: 数据库会话 db: 数据库会话
@@ -523,24 +530,23 @@ def get_dashboard_yesterday_changes(
Returns: Returns:
{ {
"total_memory_change": int | None, "total_memory_change": float | None,
"total_app_change": int | None, "total_app_change": float | None,
"total_knowledge_change": int | None, "total_knowledge_change": float | None,
"total_api_call_change": int | None "total_api_call_change": float | None
} }
""" """
from datetime import datetime, timedelta from datetime import datetime
from sqlalchemy import func from sqlalchemy import func
from app.models.api_key_model import ApiKey, ApiKeyLog from app.models.api_key_model import ApiKey, ApiKeyLog
from app.models.knowledge_model import Knowledge from app.models.knowledge_model import Knowledge
from app.models.app_model import App from app.models.app_model import App
from app.models.appshare_model import AppShare 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() now_local = datetime.now()
today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday_start = today_start - timedelta(days=1)
changes = { changes = {
"total_memory_change": None, "total_memory_change": None,
@@ -549,134 +555,102 @@ def get_dashboard_yesterday_changes(
"total_api_call_change": None, "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: try:
# 获取该workspace下所有api_key的id
api_key_ids = [ api_key_ids = [
row[0] for row in db.query(ApiKey.id).filter( row[0] for row in db.query(ApiKey.id).filter(
ApiKey.workspace_id == workspace_id ApiKey.workspace_id == workspace_id
).all() ).all()
] ]
if api_key_ids: 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.api_key_id.in_(api_key_ids),
ApiKeyLog.created_at >= today_start,
ApiKeyLog.created_at < now_local ApiKeyLog.created_at < now_local
).scalar() or 0 ).scalar() or 0
# 昨日全天 # 截止昨日的累计调用总数today_start 即昨日结束)
yesterday_api_count = db.query(func.count(ApiKeyLog.id)).filter( total_api_until_yesterday = db.query(func.count(ApiKeyLog.id)).filter(
ApiKeyLog.api_key_id.in_(api_key_ids), ApiKeyLog.api_key_id.in_(api_key_ids),
ApiKeyLog.created_at >= yesterday_start,
ApiKeyLog.created_at < today_start ApiKeyLog.created_at < today_start
).scalar() or 0 ).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: else:
# 没有api_key如果今日也是0则无对比意义
changes["total_api_call_change"] = None changes["total_api_call_change"] = None
except Exception as e: except Exception as e:
business_logger.warning(f"计算API调用昨日对比失败: {str(e)}") business_logger.warning(f"计算API调用昨日对比失败: {str(e)}")
# --- total_knowledge_change --- # --- total_knowledge_change: 只看活跃(status=1)且为顶层知识库(parent_id=workspace_id),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 ---
try: try:
# 今天有效总量当前status=1的知识库总数,排除用户知识库(permission_id='Memory') # 截止今日的活跃知识库总量当前 status=1parent_id=workspace_id
today_knowledge = db.query(func.count(Knowledge.id)).filter( today_knowledge = db.query(func.count(Knowledge.id)).filter(
Knowledge.workspace_id == workspace_id, Knowledge.workspace_id == workspace_id,
Knowledge.status == 1, Knowledge.status == 1,
Knowledge.permission_id != "Memory" Knowledge.parent_id == Knowledge.workspace_id
).scalar() or 0 ).scalar() or 0
# 昨日有效总量:昨天之前创建的、当前仍有效的知识库,排除用户知识库 # 截止昨日的活跃知识库总量(昨日之前创建的、当前仍 status=1parent_id=workspace_id
yesterday_knowledge = db.query(func.count(Knowledge.id)).filter( yesterday_knowledge = db.query(func.count(Knowledge.id)).filter(
Knowledge.workspace_id == workspace_id, Knowledge.workspace_id == workspace_id,
Knowledge.status == 1, Knowledge.status == 1,
Knowledge.permission_id != "Memory", Knowledge.parent_id == Knowledge.workspace_id,
Knowledge.created_at < today_start Knowledge.created_at < today_start
).scalar() or 0 ).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"] = _calc_percentage(today_knowledge, yesterday_knowledge)
changes["total_knowledge_change"] = None
else:
# change = 今天有效总量 - 今日软删 - 昨日有效总量
changes["total_knowledge_change"] = today_knowledge - today_deleted_knowledge - yesterday_knowledge
except Exception as e: except Exception as e:
business_logger.warning(f"计算知识库昨日对比失败: {str(e)}") business_logger.warning(f"计算知识库昨日对比失败: {str(e)}")
# --- total_app_change --- # --- total_app_change: 只看活跃(is_active=True),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 ---
try: try:
# === 自有app === # === 自有app ===
# 今天有效总量
today_own_apps = db.query(func.count(App.id)).filter( today_own_apps = db.query(func.count(App.id)).filter(
App.workspace_id == workspace_id, App.workspace_id == workspace_id,
App.is_active == True App.is_active == True
).scalar() or 0 ).scalar() or 0
# 昨日有效总量
yesterday_own_apps = db.query(func.count(App.id)).filter( yesterday_own_apps = db.query(func.count(App.id)).filter(
App.workspace_id == workspace_id, App.workspace_id == workspace_id,
App.is_active == True, App.is_active == True,
App.created_at < today_start App.created_at < today_start
).scalar() or 0 ).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 === # === 被分享app ===
# 今天有效总量
today_shared_apps = db.query(func.count(AppShare.id)).filter( today_shared_apps = db.query(func.count(AppShare.id)).filter(
AppShare.target_workspace_id == workspace_id, AppShare.target_workspace_id == workspace_id,
AppShare.is_active == True AppShare.is_active == True
).scalar() or 0 ).scalar() or 0
# 昨日有效总量
yesterday_shared_apps = db.query(func.count(AppShare.id)).filter( yesterday_shared_apps = db.query(func.count(AppShare.id)).filter(
AppShare.target_workspace_id == workspace_id, AppShare.target_workspace_id == workspace_id,
AppShare.is_active == True, AppShare.is_active == True,
AppShare.created_at < today_start AppShare.created_at < today_start
).scalar() or 0 ).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 today_total_app = today_own_apps + today_shared_apps
yesterday_total_app = yesterday_own_apps + yesterday_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"] = _calc_percentage(today_total_app, yesterday_total_app)
changes["total_app_change"] = None
else:
# change = 今天有效总量 - 今日软删 - 昨日有效总量
changes["total_app_change"] = today_total_app - total_deleted - yesterday_total_app
except Exception as e: except Exception as e:
business_logger.warning(f"计算应用数量昨日对比失败: {str(e)}") business_logger.warning(f"计算应用数量昨日对比失败: {str(e)}")
# --- total_memory_change --- # --- total_memory_change: (今日总量 - 昨日总量) / 昨日总量 ---
try: try:
today_memory = today_data.get("total_memory") today_memory = today_data.get("total_memory")
if today_memory is None: if today_memory is None:
changes["total_memory_change"] = None changes["total_memory_change"] = None
elif storage_type == "neo4j": elif storage_type == "neo4j":
# 从 memory_increments 取最近一条 created_at < today_start 的记录
last_record = db.query(MemoryIncrement).filter( last_record = db.query(MemoryIncrement).filter(
MemoryIncrement.workspace_id == workspace_id, MemoryIncrement.workspace_id == workspace_id,
MemoryIncrement.created_at < today_start MemoryIncrement.created_at < today_start
).order_by(desc(MemoryIncrement.created_at)).first() ).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 changes["total_memory_change"] = None
else: 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": elif storage_type == "rag":
# RAG: 查 documents 表中 created_at < today_start 的 chunk_num 之和
from app.models.document_model import Document from app.models.document_model import Document
from app.models.end_user_model import EndUser as _EndUser from app.models.end_user_model import EndUser as _EndUser
from app.models.app_model import App as _App from app.models.app_model import App as _App
@@ -691,18 +665,18 @@ def get_dashboard_yesterday_changes(
changes["total_memory_change"] = None changes["total_memory_change"] = None
else: else:
file_names = [f"{uid}.txt" for uid in end_user_ids] 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.file_name.in_(file_names),
Document.created_at < today_start Document.created_at < today_start
).scalar() ).scalar() or 0)
if yesterday_chunk is None: if yesterday_chunk == 0:
changes["total_memory_change"] = None changes["total_memory_change"] = None
else: else:
changes["total_memory_change"] = today_memory - int(yesterday_chunk) changes["total_memory_change"] = _calc_percentage(today_memory, yesterday_chunk)
except Exception as e: except Exception as e:
business_logger.warning(f"计算记忆总量昨日对比失败: {str(e)}") business_logger.warning(f"计算记忆总量昨日对比失败: {str(e)}")
business_logger.info(f"昨日对比计算完成: {changes}") business_logger.info(f"昨日对比百分比计算完成: {changes}")
return changes return changes
@@ -1104,15 +1078,11 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict:
except Exception as e: except Exception as e:
business_logger.warning(f"获取知识库数量失败: {e}") business_logger.warning(f"获取知识库数量失败: {e}")
# total_api_call: 仅统计当天 api_key_log 调用 # total_api_call: 截止当前的历史累计调用
try: try:
from datetime import datetime
from sqlalchemy import func as _api_func from sqlalchemy import func as _api_func
from app.models.api_key_model import ApiKey as _ApiKey, ApiKeyLog as _ApiKeyLog 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 = [ _api_key_ids = [
row[0] for row in db.query(_ApiKey.id).filter( row[0] for row in db.query(_ApiKey.id).filter(
_ApiKey.workspace_id == workspace_id _ApiKey.workspace_id == workspace_id
@@ -1120,9 +1090,7 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict:
] ]
if _api_key_ids: if _api_key_ids:
total_api_calls = db.query(_api_func.count(_ApiKeyLog.id)).filter( total_api_calls = db.query(_api_func.count(_ApiKeyLog.id)).filter(
_ApiKeyLog.api_key_id.in_(_api_key_ids), _ApiKeyLog.api_key_id.in_(_api_key_ids)
_ApiKeyLog.created_at >= _today_start,
_ApiKeyLog.created_at < _now
).scalar() or 0 ).scalar() or 0
else: else:
total_api_calls = 0 total_api_calls = 0

View File

@@ -25,7 +25,7 @@ from app.repositories.workflow_repository import (
WorkflowExecutionRepository, WorkflowExecutionRepository,
WorkflowNodeExecutionRepository WorkflowNodeExecutionRepository
) )
from app.schemas import DraftRunRequest, FileInput from app.schemas import DraftRunRequest, FileInput, FileType
from app.services.conversation_service import ConversationService from app.services.conversation_service import ConversationService
from app.services.multi_agent_service import convert_uuids_to_str from app.services.multi_agent_service import convert_uuids_to_str
from app.services.multimodal_service import MultimodalService from app.services.multimodal_service import MultimodalService
@@ -466,7 +466,7 @@ class WorkflowService:
if not isinstance(item, dict) or item.get("is_file"): if not isinstance(item, dict) or item.get("is_file"):
return item return item
transfer_method = item.get("transfer_method", "remote_url") 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 origin_file_type = item.get("file_type") or file_type
if transfer_method == "remote_url": if transfer_method == "remote_url":
url = item.get("url", "") url = item.get("url", "")

View File

@@ -16,6 +16,7 @@
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-java": "^6.0.2", "@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-rust": "^6.0.2",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>音乐</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.99">
<g id="记忆验证-交互" transform="translate(-1082, -160)" fill="#8A8A8B" fill-rule="nonzero">
<g id="编组-18" transform="translate(767, 80)">
<g id="音乐" transform="translate(315, 80)">
<path d="M14.6073402,2.57262186 C15.6328161,2.25308272 16.6661263,3.02795625 16.6661263,4.11437435 L16.6661263,13.7003614 C16.6732874,14.7217062 15.8670354,15.7879695 14.6194537,16.2794568 C13.1069857,16.8752967 11.5186278,16.4007449 11.0717571,15.2195156 C10.7826864,14.455375 11.0375417,13.5436687 11.7403216,12.8278091 C12.4431014,12.1119495 13.4870367,11.7006926 14.4788871,11.7489549 C14.8184176,11.7654761 15.1341869,11.834986 15.4142713,11.9499225 L15.413623,5.87179282 C15.413623,5.66410268 15.2414169,5.52830743 15.0770266,5.52830743 C15.0457081,5.52830743 15.0143895,5.53628327 14.9830893,5.54427784 L8.45444767,7.66916538 C8.30570751,7.70911948 8.21177022,7.84491472 8.21177022,7.99669909 L8.21177022,15.5216989 C8.20753706,16.1135542 7.93358521,16.7332583 7.42564337,17.250655 C6.72286669,17.9665132 5.67893489,18.3777701 4.68708696,18.3295091 C3.69523903,18.2812482 2.90616029,17.7808014 2.61708906,17.0166822 C2.3280178,16.2525631 2.58287084,15.3408594 3.28564752,14.6250012 C3.98842419,13.909143 5.032356,13.4978861 6.02420393,13.546147 C6.36413873,13.5626875 6.68025567,13.632343 6.96058851,13.7475244 L6.95926656,6.2392619 C6.95880655,5.52546993 7.41686196,4.89574003 8.08651438,4.68953357 Z" id="形状结合"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 57</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-914, -120)">
<g id="编组-18" transform="translate(767, 80)">
<g id="编组-57" transform="translate(147, 40)">
<path d="M5.5,0.833333333 L12.5049062,0.833333333 C13.0353392,0.833333333 13.544047,1.04404701 13.9191198,1.41911977 L16.9142136,4.41421356 C17.2892863,4.78928632 17.5,5.29799415 17.5,5.82842712 L17.5,16.1666667 C17.5,17.8235209 16.1568542,19.1666667 14.5,19.1666667 L5.5,19.1666667 C3.84314575,19.1666667 2.5,17.8235209 2.5,16.1666667 L2.5,3.83333333 C2.5,2.17647908 3.84314575,0.833333333 5.5,0.833333333 Z" id="矩形" fill="#8A8A8B"></path>
<g id="编组-11" transform="translate(3.75, 8.3333)" fill="#FFFFFF" fill-rule="nonzero">
<path d="M3.12498421,1.09590848 C3.12279802,1.19453383 3.080409,1.28827218 3.00714764,1.35649029 C2.93388628,1.42470841 2.83575759,1.46181464 2.73436119,1.45964135 L2.34373817,1.45964135 C1.92126098,1.4511578 1.57160084,1.7772011 1.56249212,2.18812027 L1.56249212,3.64507813 C1.57160083,4.05599731 1.92126097,4.38204063 2.34373817,4.37355707 L2.73436119,4.37355707 C2.83575759,4.37138378 2.93388628,4.40849001 3.00714764,4.47670813 C3.080409,4.54492624 3.12279802,4.63866459 3.12498421,4.73728994 L3.12498421,5.46576887 C3.12279802,5.56439422 3.08040901,5.65813258 3.00714765,5.72635069 C2.93388628,5.79456881 2.83575759,5.83167505 2.73436119,5.82950176 L2.34373817,5.82950176 C1.73484853,5.84308077 1.14544714,5.62034662 0.705696061,5.21048742 C0.265944986,4.80062823 0.0120209613,4.23736162 0,3.64507813 L0,2.18812027 C0.0120209675,1.59583679 0.265944994,1.03257018 0.70569607,0.62271099 C1.14544714,0.212851798 1.73484854,-0.00988234604 2.34373817,0.00369666454 L2.73436119,0.00369666454 C2.83575759,0.0015233721 2.93388628,0.0386296051 3.00714764,0.106847719 C3.080409,0.175065833 3.12279802,0.268804181 3.12498421,0.367429531 L3.12498421,1.09590848 Z" id="路径"></path>
<path d="M5.28643166,5.82950176 L4.68747633,5.82950176 C4.58607993,5.83167505 4.48795124,5.79456881 4.41468988,5.72635069 C4.34142851,5.65813258 4.2990395,5.56439422 4.29685331,5.46576887 L4.29685331,4.73728994 C4.29903951,4.63866459 4.34142852,4.54492624 4.41468988,4.47670813 C4.48795125,4.40849001 4.58607993,4.37138378 4.68747633,4.37355707 L5.28643166,4.37355707 C5.57705519,4.37355707 5.79476243,4.21448726 5.7947624,4.06960202 C5.78858011,3.99901117 5.75039961,3.93476468 5.69059628,3.89432127 L4.62289333,3.04324713 C4.21876742,2.73342941 3.97963148,2.26302277 3.97185496,1.7625832 C4.04972661,0.723694484 4.97276012,-0.0606933577 6.04163615,0.00369666454 L6.63954981,0.00369666454 C6.7409462,0.0015233721 6.83907489,0.0386296051 6.91233626,0.106847719 C6.98559762,0.175065833 7.02798663,0.268804181 7.03017283,0.367429531 L7.03017283,1.09590848 C7.02798663,1.19453383 6.98559762,1.28827218 6.91233626,1.35649029 C6.83907489,1.42470841 6.7409462,1.46181464 6.63954981,1.45964135 L6.04163615,1.45964135 C5.75101262,1.45964135 5.53330538,1.61871116 5.53330541,1.7635964 C5.53978658,1.83382188 5.5779341,1.89764181 5.63747153,1.93786396 L6.70621615,2.79197763 C7.11034207,3.10179535 7.34947801,3.572202 7.35725452,4.07264158 C7.27885154,5.11140608 6.35514329,5.89506858 5.28643166,5.82950176 L5.28643166,5.82950176 Z" id="路径"></path>
<path d="M9.37495267,0.367429531 L9.37495267,1.31374292 C9.37600218,2.23147922 9.64709349,3.12974895 10.1561987,3.90242674 C10.6653574,3.12977266 10.9364534,2.23148738 10.9374448,1.31374292 L10.9374448,0.367429531 C10.939631,0.268804181 10.98202,0.175065833 11.0552814,0.106847719 C11.1285427,0.0386296051 11.2266714,0.0015233721 11.3280678,0.00369666454 L12.1093139,0.00369666454 C12.2107103,0.0015233721 12.3088389,0.0386296051 12.3821003,0.106847719 C12.4553617,0.175065833 12.4977507,0.268804181 12.4999369,0.367429531 L12.4999369,1.31374292 C12.5072912,2.91146684 11.8717207,4.4485896 10.7291125,5.59646955 C10.5779681,5.74572289 10.3715845,5.82976352 10.1561987,5.82976352 C9.94081288,5.82976352 9.73442934,5.74572289 9.58328494,5.59646955 C8.44067669,4.4485896 7.80510624,2.91146684 7.81246055,1.31374292 L7.81246055,0.367429531 C7.81464675,0.268804177 7.85703576,0.175065825 7.93029713,0.106847711 C8.0035585,0.0386295962 8.10168719,0.00152336598 8.2030836,0.00369666454 L8.98432964,0.00369666454 C9.08572604,0.0015233721 9.18385473,0.0386296051 9.25711609,0.106847719 C9.33037746,0.175065833 9.37276647,0.268804181 9.37495267,0.367429531 L9.37495267,0.367429531 Z" id="路径"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Excel</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-767, -120)">
<g id="编组-18" transform="translate(767, 80)">
<g id="Excel" transform="translate(0, 40)">
<g id="编组-9" transform="translate(2.5, 0.8333)">
<path d="M3,0 L10.4985967,0 L10.4985967,0 L15,4.58333333 L15,15.3333333 C15,16.9901876 13.6568542,18.3333333 12,18.3333333 L3,18.3333333 C1.34314575,18.3333333 0,16.9901876 0,15.3333333 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#8A8A8B"></path>
<path d="M3.77012746,6.97784687 L6.58256881,10.695 L6.58256881,10.695 L3.56554701,14.6923338 C3.50166634,14.776971 3.51849286,14.8973686 3.60313009,14.9612493 C3.63644798,14.9863962 3.67705358,15 3.71879627,15 L4.91119321,15 C5.06466904,15 5.2088912,14.9266088 5.29923008,14.8025374 L7.5,11.78 L7.5,11.78 L9.70076992,14.8025374 C9.7911088,14.9266088 9.93533096,15 10.0888068,15 L11.2793807,15 C11.3854194,15 11.4713807,14.9140387 11.4713807,14.808 C11.4713807,14.7660102 11.4576156,14.7251781 11.4321939,14.6917583 L8.39194699,10.695 L8.39194699,10.695 L11.2274552,6.97846056 C11.2917746,6.89415626 11.2755736,6.77367294 11.1912693,6.70935355 C11.1578111,6.68382685 11.1168928,6.67 11.0748088,6.67 L9.88493319,6.67 C9.73145736,6.67 9.5872352,6.74339117 9.49689632,6.86746259 L7.5,9.61 L7.5,9.61 L5.50310368,6.86746259 C5.4127648,6.74339117 5.26854264,6.67 5.11506681,6.67 L3.92324018,6.67 C3.8172015,6.67 3.73124017,6.75596133 3.73124017,6.862 C3.73124017,6.9038202 3.74489445,6.94449685 3.77012746,6.97784687 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
<path d="M10.5,0 L15,4.58333333 L12.228,4.58333333 C11.273652,4.58333333 10.5,3.80968138 10.5,2.85533333 L10.5,0 L10.5,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Word</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-1082, -120)">
<g id="编组-18" transform="translate(767, 80)">
<g id="Word" transform="translate(315, 40)">
<g id="编组-9" transform="translate(2.5, 0.8333)">
<path d="M3,0 L10.4985967,0 L10.4985967,0 L15,4.58333333 L15,15.3333333 C15,16.9901876 13.6568542,18.3333333 12,18.3333333 L3,18.3333333 C1.34314575,18.3333333 0,16.9901876 0,15.3333333 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#8A8A8B"></path>
<path d="M10.5,0 L15,4.58333333 L12.66,4.58333333 C11.4670649,4.58333333 10.5,3.61626839 10.5,2.42333333 L10.5,0 L10.5,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
<path d="M5.25477293,8.43659104 L3.69136395,10 L5.25477293,11.563409 C5.51316277,11.8309401 5.5094673,12.2561938 5.24646711,12.519194 C4.98346693,12.7821942 4.55821317,12.7858897 4.29068209,12.5274998 L2.2452277,10.4820454 C1.97905823,10.2157955 1.97905823,9.78420448 2.2452277,9.51795461 L4.29068209,7.47250022 C4.55821317,7.21411037 4.98346693,7.21780584 5.24646711,7.48080602 C5.5094673,7.7438062 5.51316277,8.16905996 5.25477293,8.43659104 L5.25477293,8.43659104 Z M9.74522711,11.563409 L11.3086361,10 L9.74522711,8.43659104 C9.48683726,8.16905996 9.49053273,7.7438062 9.75353292,7.48080602 C10.0165331,7.21780584 10.4417869,7.21411037 10.7093179,7.47250022 L12.7547723,9.51795461 C13.0209418,9.78420448 13.0209418,10.2157955 12.7547723,10.4820454 L10.7093179,12.5274998 C10.4417869,12.7858897 10.0165331,12.7821942 9.75353292,12.519194 C9.49053273,12.2561938 9.48683726,11.8309401 9.74522711,11.563409 L9.74522711,11.563409 Z M7.89613634,7.34500023 C8.04788899,7.00451793 8.44495301,6.84902457 8.78756291,6.99591064 C9.13017281,7.14279671 9.29130774,7.53760496 9.14931807,7.88227291 L7.10386369,12.6549998 C6.95211104,12.9954821 6.55504702,13.1509755 6.21243712,13.0040894 C5.86982722,12.8572033 5.70869229,12.4623951 5.85068196,12.1177271 L7.89613634,7.34500023 Z" id="形状结合" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>JSON</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-1249, -120)" fill-rule="nonzero">
<g id="编组-18" transform="translate(767, 80)">
<g id="JSON" transform="translate(482, 40)">
<path d="M2.5,4.74444444 C2.5,3.37555556 2.5,2.69111111 2.75153846,2.168 C2.97278135,1.70806874 3.32579916,1.33413136 3.76,1.09977778 C4.25384615,0.833333333 4.9,0.833333333 6.19230769,0.833333333 L11.5496154,0.833333333 C12.2551923,0.833333333 12.6082692,0.833333333 12.94,0.917666667 C13.234294,0.992600867 13.5156205,1.11613072 13.7736538,1.28372222 C14.065,1.47255556 14.3142308,1.73655556 14.8132692,2.26516667 L16.1482692,3.67927778 C16.6473077,4.20788889 16.8965385,4.47188889 17.0748077,4.7805 C17.2328846,5.05366667 17.3494231,5.35188889 17.4203846,5.66355556 C17.5,6.01494444 17.5,6.38894444 17.5,7.13633333 L17.5,15.2555556 C17.5,16.6244444 17.5,17.3088889 17.2484615,17.832 C17.0272186,18.2919313 16.6742008,18.6658686 16.24,18.9002222 C15.7461538,19.1666667 15.1,19.1666667 13.8076923,19.1666667 L6.19230769,19.1666667 C4.9,19.1666667 4.25384615,19.1666667 3.76,18.9002222 C3.32579916,18.6658686 2.97278135,18.2919313 2.75153846,17.832 C2.5,17.3088889 2.5,16.6244444 2.5,15.2555556 L2.5,4.74444444 Z" id="路径" fill="#8A8A8B"></path>
<path d="M7.36951008,7.5 C5.7856094,7.5 5.47189961,7.96036949 5.47189961,8.77128384 L5.47189961,9.88868551 C5.47189961,10.329261 5.38713122,10.5054912 5,10.5054912 L5,11.6816363 C5.38713122,11.6816363 5.47189961,11.8578665 5.47189961,12.298442 L5.47189961,13.4158437 C5.47189961,14.2222884 5.7856094,14.6890431 7.37017755,14.6890431 L7.37017755,13.7127533 C6.95100788,13.7127533 6.88826592,13.598459 6.88826592,13.3143198 L6.88826592,12.1905329 C6.88826592,11.479227 6.52716593,11.1944492 5.95381124,11.0999489 C6.54118275,10.978631 6.88159124,10.7213094 6.88159124,9.99595607 L6.88159124,8.87216925 C6.88159124,8.58866848 6.94366573,8.47373574 7.37017755,8.47373574 L7.37017755,7.5 L7.36951008,7.5 Z M10.0460553,9.86889154 C9.6061941,9.86889154 9.25377119,10.2073046 9.25377119,10.6197855 C9.25851681,11.0354392 9.61152977,11.3702948 10.0460553,11.3713179 C10.4779764,11.3668282 10.8269714,11.0329714 10.8316647,10.6197855 C10.8316647,10.2079431 10.4765719,9.86825302 10.0460553,9.86825302 L10.0460553,9.86889154 Z M10.0460553,12.2920569 C9.59217728,12.2920569 9.25377119,12.6157841 9.25377119,13.0365656 C9.25377119,13.3009109 9.38059004,13.5237528 9.6061941,13.660395 L9.27446269,15 L10.0527299,15 L10.5272994,13.9777371 C10.732212,13.5307764 10.8316647,13.3009109 10.8316647,13.0435893 C10.8316647,12.6157841 10.4905887,12.2920569 10.0460553,12.2920569 Z M12.6304899,7.5 L12.6304899,8.47373574 C13.0536644,8.47373574 13.1184088,8.58866848 13.1184088,8.87280776 L13.1184088,9.99659459 C13.1184088,10.7213094 13.4568148,10.978631 14.0441864,11.0999489 C13.4708317,11.1950877 13.1117341,11.479227 13.1117341,12.1905329 L13.1117341,13.3143198 C13.1117341,13.598459 13.0469897,13.7127533 12.6304899,13.7127533 L12.6304899,14.6896816 C14.2143906,14.6896816 14.526098,14.2222884 14.526098,13.4158437 L14.526098,12.298442 C14.526098,11.8578665 14.6108664,11.6816363 15,11.6816363 L15,10.5054912 C14.6108664,10.5054912 14.526098,10.3286225 14.526098,9.88804699 L14.526098,8.77128384 C14.526098,7.96036949 14.2143906,7.5 12.6304899,7.5 L12.6304899,7.5 Z" id="形状" fill="#FFFFFF" opacity="0.98"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>PDF</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-1082, -80)">
<g id="编组-18" transform="translate(767, 80)">
<g id="PDF" transform="translate(315, 0)">
<g id="编组-9" transform="translate(2.5, 0.8333)">
<path d="M3,0 L10.4985967,0 L10.4985967,0 L15,4.58333333 L15,15.3333333 C15,16.9901876 13.6568542,18.3333333 12,18.3333333 L3,18.3333333 C1.34314575,18.3333333 0,16.9901876 0,15.3333333 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#8A8A8B"></path>
<path d="M10.5,0 L15,4.58333333 L12.228,4.58333333 C11.273652,4.58333333 10.5,3.80968138 10.5,2.85533333 L10.5,0 L10.5,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
</g>
<text id="MD" font-family="Rubik-Medium, Rubik" font-size="9" font-weight="400" fill="#FFFFFF">
<tspan x="3.2" y="14.6666667">MD</tspan>
</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>播放</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-1204, -313)">
<g id="播放" transform="translate(1204, 313)">
<rect id="矩形" fill="#000000" fill-rule="nonzero" opacity="0" x="0" y="0" width="16" height="16"></rect>
<path d="M8,0 C3.6,0 0,3.6 0,8 C0,12.4 3.6,16 8,16 C12.4,16 16,12.4 16,8 C16,3.6 12.4,0 8,0 Z" id="路径" fill="#171719" fill-rule="nonzero"></path>
<g id="编组-4" transform="translate(5.3333, 5.3333)" fill="#FFFFFF">
<rect id="矩形" x="0" y="0" width="1.33333333" height="5.33333333" rx="0.5"></rect>
<rect id="矩形备份-2" x="4" y="0" width="1.33333333" height="5.33333333" rx="0.5"></rect>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>PDF</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-914, -80)">
<g id="编组-18" transform="translate(767, 80)">
<g id="PDF" transform="translate(147, 0)">
<g id="编组-9" transform="translate(2.5, 0.8333)">
<path d="M3,0 L10.4985967,0 L10.4985967,0 L15,4.58333333 L15,15.3333333 C15,16.9901876 13.6568542,18.3333333 12,18.3333333 L3,18.3333333 C1.34314575,18.3333333 0,16.9901876 0,15.3333333 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#8A8A8B"></path>
<path d="M10.5,0 L15,4.58333333 L12.228,4.58333333 C11.273652,4.58333333 10.5,3.80968138 10.5,2.85533333 L10.5,0 L10.5,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
<g id="pdf" transform="translate(2.25, 4.5833)" fill="#FFFFFF" fill-rule="nonzero">
<rect id="矩形" opacity="0" x="0" y="0" width="10.4974603" height="10.6944444"></rect>
<path d="M9.039482,7.98441642 C8.25217247,7.92484363 7.494018,7.6270425 6.88165932,7.09099208 C5.68612776,7.35901729 4.54889605,7.74616714 3.4116746,8.22266571 C2.50772435,9.86058242 1.66209447,10.6944444 0.933105306,10.6944444 C0.787309524,10.6944444 0.612348435,10.6646685 0.49571796,10.5753198 C0.174950838,10.4264192 0,10.0988422 0,9.77124417 C0,9.50321896 0.058320363,8.75871613 2.82848122,7.53770425 C3.46999497,6.34647877 3.96570267,5.12547736 4.37394522,3.84491364 C4.02402304,3.13017625 3.26586857,1.37311396 3.79074159,0.479710552 C3.96570267,0.152112552 4.3156146,-0.0265639412 4.69470209,0.00321198439 C4.98629365,0.00321198439 5.27788521,0.152123022 5.4528463,0.390361836 C5.83192354,0.926412255 5.80275823,2.05808588 5.30705052,3.72578899 C5.77360317,4.61920287 6.38595161,5.4232785 7.11494077,6.10823995 C7.72729946,5.98911531 8.33964789,5.8997666 8.95199633,5.8997666 C10.3225095,5.92955299 10.5266154,6.58472805 10.4974603,6.97186744 C10.4974603,7.98441642 9.5351897,7.98441642 9.039482,7.98441642 L9.039482,7.98441642 Z M0.874784943,9.83079602 L0.962270613,9.8010201 C1.3705029,9.65210906 1.69124952,9.35430793 1.92453097,8.96715807 C1.48714363,9.14584504 1.13722145,9.44364617 0.874784943,9.83080649 L0.874784943,9.83079602 Z M4.7530122,0.89663633 L4.66553678,0.89663633 C4.63638172,0.89663633 4.57806136,0.89663633 4.54889605,0.926412255 C4.43226558,1.43268675 4.519741,1.96873717 4.72385714,2.44522527 C4.89881823,1.93895077 4.89881823,1.40290035 4.7530122,0.89663633 Z M4.95713859,5.21481561 L4.92797329,5.2743884 L4.89881823,5.244602 C4.63638172,5.92955299 4.34477991,6.61450398 4.02402304,7.26967904 L4.08235365,7.23989264 L4.08235365,7.29946544 C4.72385714,7.06121615 5.42369125,6.85275326 6.06519474,6.70384223 L6.03603968,6.6740663 L6.1235151,6.6740663 C5.68612776,6.22735413 5.27788521,5.7210901 4.95713859,5.21481561 Z M8.92284127,6.79319094 C8.66040476,6.79319094 8.42712331,6.79319094 8.1646868,6.85275326 C8.45628862,7.00165383 8.74789043,7.06120568 9.039482,7.09099208 C9.24358789,7.12077847 9.44771429,7.09099208 9.62267537,7.03142976 C9.62267537,6.94209151 9.50603465,6.79319094 8.92284127,6.79319094 L8.92284127,6.79319094 Z" id="形状"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="40px" height="40px" viewBox="0 0 40 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>播放</title>
<defs>
<filter x="-3.4%" y="-9.4%" width="106.8%" height="121.1%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="6" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0.0901960784 0 0 0 0 0.0901960784 0 0 0 0 0.0980392157 0 0 0 0.1 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
</defs>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-1148, -304)" fill-rule="nonzero">
<g id="输入框1" filter="url(#filter-1)" transform="translate(774, 285)">
<g id="文件" transform="translate(206, 12)">
<g id="播放" transform="translate(180, 17)">
<rect id="矩形" fill="#000000" opacity="0" x="0" y="0" width="16" height="16"></rect>
<path d="M8,0 C3.6,0 0,3.6 0,8 C0,12.4 3.6,16 8,16 C12.4,16 16,12.4 16,8 C16,3.6 12.4,0 8,0 Z" id="路径" fill="#171719"></path>
<path d="M10.5,6.9 L7.83332813,5.13332812 C6.93332813,4.53332812 6.16667188,4.93332812 6.16667188,6.03332812 L6.16667188,10.0333281 C6.16667188,11.1333281 6.9,11.5333281 7.83332813,10.9333281 L10.5,9.16667187 C11.4,8.5 11.4,7.5 10.5,6.9 Z" id="路径" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>file-ppt-2-fill</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-1249, -80)" fill-rule="nonzero">
<g id="编组-18" transform="translate(767, 80)">
<g id="file-ppt-2-fill" transform="translate(482, 0)">
<path d="M13.9585312,2.79049848 L17.1250396,2.79049848 C17.5622431,2.79049848 17.9166667,3.14914619 17.9166667,3.59156055 L17.9166667,16.4085535 C17.9166667,16.8509679 17.5622431,17.2096156 17.1250396,17.2096156 L13.9585312,17.2096156 L13.9585312,2.79049848 L13.9585312,2.79049848 Z M2.76413262,2.69196785 L12.7148851,1.25406144 C12.8283777,1.23758625 12.9433539,1.27171968 13.0300621,1.3476293 C13.1167704,1.42353891 13.1666902,1.53376531 13.1669042,1.64978611 L13.1669042,18.350328 C13.1666579,18.4661876 13.1168448,18.5762631 13.0303219,18.6521433 C12.9437991,18.7280235 12.8290444,18.7622733 12.7156767,18.7460527 L2.76334099,17.3081463 C2.3731994,17.2519189 2.08333333,16.9138702 2.08333333,16.5150948 L2.08333333,3.48501929 C2.08333333,3.08624389 2.3731994,2.74819519 2.76334099,2.69196785 L2.76413262,2.69196785 Z" id="形状结合" fill="#8A8A8B"></path>
<path d="M10.2920229,6.79580879 C10.5681653,6.79580879 10.7920229,7.01966642 10.7920229,7.29580879 L10.7920229,11.1021812 C10.7920229,11.3783235 10.5681653,11.6021812 10.2920229,11.6021812 L6.04226039,11.6021812 L6.04226039,12.8333333 C6.04226039,13.1094757 5.81840276,13.3333333 5.54226039,13.3333333 L4.9590062,13.3333333 C4.68286383,13.3333333 4.4590062,13.1094757 4.4590062,12.8333333 L4.4590062,7.29580879 C4.4590062,7.01966642 4.68286383,6.79580879 4.9590062,6.79580879 Z M9.20876872,8.39793293 L6.04226039,8.39793293 L6.04226039,10.0000571 L9.20876872,10.0000571 L9.20876872,8.39793293 Z" id="形状结合" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>txt</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-767, -160)">
<g id="编组-18" transform="translate(767, 80)">
<g id="txt" transform="translate(0, 80)">
<rect id="矩形" fill="#8A8A8B" x="1.66666667" y="1.66666667" width="16.6666667" height="16.6666667" rx="3"></rect>
<path d="M13.3333333,6.66666667 C13.7935706,6.66666667 14.1666667,7.03976271 14.1666667,7.5 C14.1666667,7.96023729 13.7935706,8.33333333 13.3333333,8.33333333 L10.8333333,8.33266667 L10.8333333,14.1666667 C10.8333333,14.626904 10.4602373,15 10,15 C9.53976271,15 9.16666667,14.626904 9.16666667,14.1666667 L9.16633333,8.33266667 L6.66666667,8.33333333 C6.20642938,8.33333333 5.83333333,7.96023729 5.83333333,7.5 C5.83333333,7.03976271 6.20642938,6.66666667 6.66666667,6.66666667 L13.3333333,6.66666667 Z" id="形状结合" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 59</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-914, -160)">
<g id="编组-18" transform="translate(767, 80)">
<g id="编组-59" transform="translate(147, 80)">
<g id="编组-15" transform="translate(0.8333, 3.75)">
<path d="M3.6,0 L10.15,0 C12.1382251,-4.4408921e-16 13.75,1.6117749 13.75,3.6 L13.75,8.9 C13.75,10.8882251 12.1382251,12.5 10.15,12.5 L3.6,12.5 C1.6117749,12.5 0,10.8882251 0,8.9 L0,3.6 C-4.4408921e-16,1.6117749 1.6117749,4.4408921e-16 3.6,0 Z M14.9764532,3.33751624 L16.6615387,2.40035638 C17.2045334,2.09837004 17.8895258,2.29374589 18.1915122,2.83674057 C18.2845209,3.00397734 18.3333333,3.19217494 18.3333333,3.38353521 L18.3333333,4.64537525 L18.3333333,4.64537525 L18.3333333,9.11646479 C18.3333333,9.73778513 17.8296537,10.2414648 17.2083333,10.2414648 C17.0169731,10.2414648 16.8287755,10.1926523 16.6615387,10.0996436 L14.9764532,9.16248376 C14.6909344,9.00369259 14.5138889,8.70264483 14.5138889,8.3759407 L14.5138889,4.1240593 C14.5138889,3.79735517 14.6909344,3.49630741 14.9764532,3.33751624 Z" id="形状结合" fill="#8A8A8B"></path>
<path d="M6.64370071,8.28388268 L8.07534903,7.39798876 C8.70936571,7.00566369 8.90529548,6.17364931 8.51297041,5.53963262 C8.40295288,5.36183886 8.25314279,5.21202877 8.07534903,5.10201124 L6.64370071,4.21611732 C6.00968402,3.82379225 5.17766964,4.01972202 4.78534457,4.65373871 C4.65328835,4.86714809 4.58333333,5.11314311 4.58333333,5.36410608 L4.58333333,7.13589392 C4.58333333,7.88147833 5.18774892,8.48589392 5.93333333,8.48589392 C6.1842963,8.48589392 6.43029132,8.41593891 6.64370071,8.28388268 Z" id="路径-30" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Word</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="记忆验证-交互" transform="translate(-767, -80)">
<g id="编组-18" transform="translate(767, 80)">
<g id="编组-9" transform="translate(2.5, 0.8333)">
<path d="M3,0 L10.4985967,0 L10.4985967,0 L15,4.58333333 L15,15.3333333 C15,16.9901876 13.6568542,18.3333333 12,18.3333333 L3,18.3333333 C1.34314575,18.3333333 0,16.9901876 0,15.3333333 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#8A8A8B"></path>
<path d="M10.5,0 L15,4.58333333 L12.66,4.58333333 C11.4670649,4.58333333 10.5,3.61626839 10.5,2.42333333 L10.5,0 L10.5,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
<path d="M7.50010464,8.75766667 L8.70548376,13.1357111 C8.73757423,13.2522742 8.84606214,13.3333333 8.97000347,13.3333333 L9.69532154,13.3333333 C9.81913985,13.3333333 9.92755938,13.2524513 9.95977278,13.1360444 L11.6572407,7.00271111 C11.6634992,6.98008877 11.6666667,6.95676202 11.6666667,6.93333333 C11.6666667,6.78606667 11.5440382,6.66666667 11.3927895,6.66666667 L10.5808349,6.66666667 C10.4532262,6.66666667 10.3425244,6.75246851 10.3139188,6.87355556 L9.26884912,11.2981111 L8.13317174,6.86875556 C8.10273218,6.75001099 7.99318481,6.66666667 7.86748805,6.66666667 L7.13276687,6.66666667 C7.00705309,6.66666667 6.89748122,6.74999492 6.86703753,6.86875556 L5.73364246,11.2893556 L4.68158892,6.87308889 C4.65280183,6.75222776 4.54221037,6.66666667 4.4147869,6.66666667 L3.60728285,6.66666667 C3.58327617,6.66666667 3.55937335,6.66973488 3.5361889,6.6758 C3.39012107,6.71402222 3.30353023,6.86031111 3.34278596,7.00253333 L5.03564364,13.1358667 C5.06778799,13.2523441 5.17622456,13.3333333 5.30009489,13.3333333 L6.0302058,13.3333333 C6.15414713,13.3333333 6.26263504,13.2522742 6.29472552,13.1357111 L7.50010464,8.75766667 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17 * @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing * @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 { type FC, useRef, useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -131,7 +131,9 @@ const ChatContent: FC<ChatContentProps> = ({
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}> <div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
{data.length === 0 {data.length === 0
? empty // Display empty state ? empty // Display empty state
: data.map((item, index) => ( : data.map((item, index) => {
if (!item) return null
return (
<div key={index} className={clsx("rb:relative", { <div key={index} className={clsx("rb:relative", {
'rb:mt-6': index !== 0, // Add top margin for non-first messages 'rb:mt-6': index !== 0, // Add top margin for non-first messages
'rb:right-0 rb:text-right': item.role === 'user', // User messages right-aligned 'rb:right-0 rb:text-right': item.role === 'user', // User messages right-aligned
@@ -147,7 +149,7 @@ const ChatContent: FC<ChatContentProps> = ({
{labelFormat(item)} {labelFormat(item)}
</div> </div>
} }
{item.meta_data?.files && item.meta_data?.files.length > 0 && <Flex gap={8} vertical align="end" className="rb:mb-2!"> {item?.meta_data?.files && item.meta_data?.files.length > 0 && <Flex gap={8} vertical align="end" className="rb:mb-2!">
{item.meta_data?.files?.map((file) => { {item.meta_data?.files?.map((file) => {
if (file.type.includes('image')) { if (file.type.includes('image')) {
return ( return (
@@ -185,23 +187,23 @@ const ChatContent: FC<ChatContentProps> = ({
"rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]", "rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]",
file.type?.includes('pdf') file.type?.includes('pdf')
? "rb:bg-[url('@/assets/images/file/pdf.svg')]" ? "rb:bg-[url('@/assets/images/file/pdf.svg')]"
: (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) : (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')]" ? "rb:bg-[url('@/assets/images/file/excel.svg')]"
: file.type?.includes('csv') : file.type?.includes('csv')
? "rb:bg-[url('@/assets/images/file/csv.svg')]" ? "rb:bg-[url('@/assets/images/file/csv.svg')]"
: file.type?.includes('html') : file.type?.includes('html')
? "rb:bg-[url('@/assets/images/file/html.svg')]" ? "rb:bg-[url('@/assets/images/file/html.svg')]"
: file.type?.includes('json') : file.type?.includes('json')
? "rb:bg-[url('@/assets/images/file/json.svg')]" ? "rb:bg-[url('@/assets/images/file/json.svg')]"
: file.type?.includes('ppt') : file.type?.includes('ppt')
? "rb:bg-[url('@/assets/images/file/ppt.svg')]" ? "rb:bg-[url('@/assets/images/file/ppt.svg')]"
: file.type?.includes('text') : file.type?.includes('markdown')
? "rb:bg-[url('@/assets/images/file/txt.svg')]" ? "rb:bg-[url('@/assets/images/file/md.svg')]"
: file.type?.includes('markdown') : file.type?.includes('text')
? "rb:bg-[url('@/assets/images/file/md.svg')]" ? "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')) : (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/word.svg')]"
: null : "rb:bg-[url('@/assets/images/file/txt.svg')]"
)} )}
></div> ></div>
<div className="rb:flex-1 rb:w-32.5"> <div className="rb:flex-1 rb:w-32.5">
@@ -218,7 +220,7 @@ const ChatContent: FC<ChatContentProps> = ({
'rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': (item.status && item.status !== 'completed') || (errorDesc && item.role === 'assistant' && item.content === null && !renderRuntime), '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 // Assistant message style
'rb:bg-[#E3EBFD] rb:p-[10px_12px_2px_12px] rb:rounded-lg rb:max-w-130': item.role === 'user', '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 // User message style
'rb:text-[#212332]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'), 'rb:text-[#212332]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'),
'rb:mt-1': labelPosition === 'top', 'rb:mt-1': labelPosition === 'top',
@@ -282,7 +284,7 @@ const ChatContent: FC<ChatContentProps> = ({
} }
</div> </div>
{/* Bottom label (such as timestamp, username, etc.) */} {/* Bottom label (such as timestamp, username, etc.) */}
{labelPosition === 'bottom' && <Flex gap={16} align="center" justify={item.role === 'user' ? 'end' : 'start'}> {(labelPosition === 'bottom' || item.meta_data?.audio_url) && <Flex gap={16} align="center" justify={item.role === 'user' ? 'end' : 'start'}>
{item.meta_data?.audio_url && <> {item.meta_data?.audio_url && <>
{playingIndex !== item.meta_data?.audio_url && item.meta_data?.audio_status === 'pending' {playingIndex !== item.meta_data?.audio_url && item.meta_data?.audio_status === 'pending'
? <Spin /> ? <Spin />
@@ -297,15 +299,15 @@ const ChatContent: FC<ChatContentProps> = ({
/> />
} }
</>} </>}
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular"> {labelPosition === 'bottom' && <div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular">
{labelFormat(item)} {labelFormat(item)}
</div> </div>}
</Flex> </Flex>
} }
</> </>
} }
</div> </div>
)) )})
} }
</div> </div>
) )

View File

@@ -5,10 +5,11 @@
* @Last Modified time: 2026-03-23 17:46:25 * @Last Modified time: 2026-03-23 17:46:25
*/ */
import { type FC, useEffect, useMemo, useState } from 'react' import { type FC, useEffect, useMemo, useState } from 'react'
import { Flex, Input, Spin } from 'antd' import { Flex, Input } from 'antd'
import clsx from 'clsx' import clsx from 'clsx'
import type { ChatInputProps } from './types' import type { ChatInputProps } from './types'
import FileList from './FileList'
/** /**
* Chat Input Component * Chat Input Component
@@ -26,6 +27,7 @@ const ChatInput: FC<ChatInputProps> = ({
}) => { }) => {
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [isFocus, setIsFocus] = useState(false) const [isFocus, setIsFocus] = useState(false)
const [isComposing, setIsComposing] = useState(false)
// Clear input when external message is cleared // Clear input when external message is cleared
useEffect(() => { useEffect(() => {
@@ -52,6 +54,7 @@ const ChatInput: FC<ChatInputProps> = ({
})) || [] })) || []
}, [fileList]) }, [fileList])
const handleSend = () => { const handleSend = () => {
if (loading || !inputValue || inputValue.trim() === '') return if (loading || !inputValue || inputValue.trim() === '') return
onSend(inputValue) onSend(inputValue)
@@ -64,100 +67,9 @@ const ChatInput: FC<ChatInputProps> = ({
<Flex gap={0} vertical justify="space-between" className={clsx("rb-border rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)] rb:rounded-3xl rb:min-h-30", { <Flex gap={0} vertical justify="space-between" className={clsx("rb-border rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)] rb:rounded-3xl rb:min-h-30", {
' rb:border-[#171719]!': isFocus ' rb:border-[#171719]!': isFocus
})}> })}>
{previewFileList.length > 0 && <div className="rb:overflow-x-auto rb:max-w-full"> <div className="rb:overflow-x-auto rb:max-w-full">
<Flex gap={14} className="rb:mx-3! rb:mt-3! rb:w-max!"> <FileList fileList={previewFileList} onDelete={handleDelete} />
{previewFileList.map((file) => { </div>
if (file.type?.includes('image')) {
return (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
<div className={clsx("rb:inline-block rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:border rb:border-[#F6F6F6]", {
'rb:border-[#FF5D34]': file.status === 'error'
})}>
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover" />
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
onClick={() => handleDelete(file)}
></div>
</div>
</Spin>
)
}
if (file.type?.includes('video')) {
return (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
<div className={clsx("rb:w-45 rb:h-12 rb:inline-block rb:group rb:relative rb:rounded-lg rb:border rb:border-[#F6F6F6]", {
'rb:border-[#FF5D34]': file.status === 'error'
})}>
<video src={file.url} controls className="rb:w-45 rb:h-12 rb:rounded-lg rb:object-cover" />
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
onClick={() => handleDelete(file)}
></div>
</div>
</Spin>
)
}
if (file.type?.includes('audio')) {
return (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
<div className={clsx("rb:w-45 rb:h-12rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:py-2 rb:px-2.5 rb:gap-2 rb:border rb:border-[#F6F6F6]", {
'rb:border-[#FF5D34]': file.status === 'error'
})}>
<audio src={file.url} controls className="rb:w-45 rb:h-12" />
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
onClick={() => handleDelete(file)}
></div>
</div>
</Spin>
)
}
return (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
<Flex
align="center"
gap={10}
className={clsx("rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:py-2! rb:px-2.5! rb:border rb:border-[#F6F6F6]", {
'rb:border-[#FF5D34]': file.status === 'error'
})}>
<div
className={clsx(
"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
)}
></div>
<div className="rb:flex-1 rb:w-32.5">
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type?.split('/')[file.type?.split('/').length - 1]} · {file.size}</div>
</div>
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
onClick={() => handleDelete(file)}
></div>
</Flex>
</Spin>
)
})}
</Flex>
</div>}
{/* Message input area */} {/* Message input area */}
<Input.TextArea <Input.TextArea
value={inputValue} value={inputValue}
@@ -167,9 +79,11 @@ const ChatInput: FC<ChatInputProps> = ({
setInputValue(e.target.value) setInputValue(e.target.value)
onChange?.(e.target.value) onChange?.(e.target.value)
}} }}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={(e) => { onKeyDown={(e) => {
// Enter to send, Shift+Enter for new line // 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(); e.preventDefault();
handleSend(); handleSend();
} }

View File

@@ -82,6 +82,7 @@ const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
setVariables: (variables) => { setVariables: (variables) => {
console.log('variables', variables) console.log('variables', variables)
form.setFieldValue('variables', variables) form.setFieldValue('variables', variables)
onVariablesChange?.(variables)
}, },
})) }))

View File

@@ -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<FileListProps> = ({ fileList, onDelete, wrap,
className = "rb:mx-3! rb:mt-3! rb:w-max!"
}) => {
const [playingUid, setPlayingUid] = useState<string | null>(null)
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement>(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 (
<>
<Flex gap={14} wrap={wrap} className={className}>
{fileList.map((file) => {
if (file.type?.includes('image')) {
return (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
<div className={clsx("rb:inline-block rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:border rb:border-[#F6F6F6]", {
'rb:border-[#FF5D34]': file.status === 'error'
})}>
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover" />
{onDelete && <div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
onClick={() => onDelete(file)}
></div>}
</div>
</Spin>
)
}
return (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
<Flex
align="center"
gap={10}
className={clsx("rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:py-2! rb:px-2.5! rb:border rb:border-[#F6F6F6]", {
'rb:border-[#FF5D34]': file.status === 'error',
'rb:w-52': file.status === 'done' && (file.type?.includes('video') || file.type?.includes('audio'))
})}>
<div
className={clsx(
"rb:size-5 rb:cursor-pointer rb:bg-cover",
getFileIconClassName(file),
)}
></div>
<div className="rb:flex-1 rb:w-32.5">
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{[file.type?.split('/').pop(), file.size].filter(item => item).join(' · ')}</div>
</div>
{file.status === 'done' && (file.type?.includes('video') || file.type?.includes('audio')) &&
<div
className={clsx('rb:size-4 rb:cursor-pointer rb:bg-cover', playingUid === file.uid
? "rb:bg-[url('@/assets/images/file/pause.svg')]"
: "rb:bg-[url('@/assets/images/userMemory/play.svg')]"
)}
onClick={() => playingUid === file.uid ? handleClose() : setPlayingUid(file.uid)}
></div>
}
{onDelete && <div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
onClick={() => onDelete(file)}
></div>}
</Flex>
</Spin>
)
})}
</Flex>
{playingFile && (
<div
className="rb:fixed rb:inset-0 rb:z-1000 rb:bg-black/80 rb:flex rb:items-center rb:justify-center"
onClick={handleClose}
>
<button className="ant-image-preview-close"><CloseOutlined /></button>
{playingFile.type?.includes('video') ? (
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
src={playingFile.url}
controls
autoPlay
className="rb:max-w-[90vw] rb:max-h-[90vh] rb:rounded-xl"
onClick={e => e.stopPropagation()}
/>
) : (
<audio
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={playingFile.url}
controls
autoPlay
onClick={e => e.stopPropagation()}
/>
)}
</div>
)}
</>
)
}
export default FileList

View File

@@ -6,12 +6,14 @@
*/ */
import { useEffect, useRef, useMemo } from 'react'; import { useEffect, useRef, useMemo } from 'react';
import { EditorView, basicSetup } from 'codemirror'; import { EditorView, basicSetup } from 'codemirror';
import { placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { java } from '@codemirror/lang-java'; import { java } from '@codemirror/lang-java';
import { cpp } from '@codemirror/lang-cpp'; import { cpp } from '@codemirror/lang-cpp';
import { rust } from '@codemirror/lang-rust'; import { rust } from '@codemirror/lang-rust';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
/** /**
@@ -26,12 +28,14 @@ import { oneDark } from '@codemirror/theme-one-dark';
*/ */
interface CodeMirrorEditorProps { interface CodeMirrorEditorProps {
value?: string; 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; onChange?: (value: string) => void;
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
readOnly?: boolean; readOnly?: boolean;
height?: string; height?: string;
size?: 'default' | 'small'; size?: 'default' | 'small';
placeholder?: string;
variant?: 'outlined' | 'borderless';
} }
/** /**
@@ -47,6 +51,7 @@ const languageExtensions: Record<string, any> = {
cpp: cpp(), cpp: cpp(),
c: cpp(), c: cpp(),
rust: rust(), rust: rust(),
json: json(),
}; };
/** /**
@@ -61,6 +66,8 @@ const CodeMirrorEditor = ({
theme = 'light', theme = 'light',
readOnly = false, readOnly = false,
size, size,
placeholder,
variant = 'borderless',
}: CodeMirrorEditorProps) => { }: CodeMirrorEditorProps) => {
// Reference to the DOM element that will contain the editor // Reference to the DOM element that will contain the editor
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
@@ -88,6 +95,7 @@ const CodeMirrorEditor = ({
} }
}), }),
EditorState.readOnly.of(readOnly), // Set read-only mode EditorState.readOnly.of(readOnly), // Set read-only mode
...(placeholder ? [cmPlaceholder(placeholder)] : []),
]; ];
// Apply dark theme if specified // Apply dark theme if specified
@@ -111,7 +119,7 @@ const CodeMirrorEditor = ({
return () => { return () => {
viewRef.current?.destroy(); viewRef.current?.destroy();
}; };
}, [language, theme, readOnly]); }, [language, theme, readOnly, placeholder]);
/** /**
* Update editor content when the value prop changes externally * Update editor content when the value prop changes externally
@@ -144,7 +152,13 @@ const CodeMirrorEditor = ({
return `${size === 'small' ? 16 : 20}px` return `${size === 'small' ? 16 : 20}px`
}, [size]) }, [size])
return <div ref={editorRef} style={{ minHeight, fontSize, lineHeight }} />; return (
<div
ref={editorRef}
style={{ minHeight, fontSize, lineHeight }}
className={variant === 'borderless' ? '' : 'rb-border rb:rounded-[8px]'}
/>
);
}; };
export default CodeMirrorEditor; export default CodeMirrorEditor;

View File

@@ -10,6 +10,7 @@ interface OptionType {
interface ApiResponse<T> { interface ApiResponse<T> {
items?: T[]; items?: T[];
page: { hasnext: boolean };
} }
export interface DebounceSelectProps extends Omit<SelectProps, 'options'> { export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
@@ -23,8 +24,9 @@ export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
labelKey?: string; labelKey?: string;
/** Key name sent to the API for the search keyword */ /** Key name sent to the API for the search keyword */
searchKey?: string; searchKey?: string;
pageSize?: number;
/** Custom fetch function — mutually exclusive with url */ /** Custom fetch function — mutually exclusive with url */
fetchOptions?: (search: string | null) => Promise<DefaultOptionType[]>; fetchOptions?: (search: string | null, page: number) => Promise<{ options: DefaultOptionType[]; hasMore: boolean }>;
/** Transform raw API items before rendering */ /** Transform raw API items before rendering */
format?: (items: OptionType[]) => OptionType[]; format?: (items: OptionType[]) => OptionType[];
debounceTimeout?: number; debounceTimeout?: number;
@@ -32,10 +34,11 @@ export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
const DebounceSelect: FC<DebounceSelectProps> = ({ const DebounceSelect: FC<DebounceSelectProps> = ({
url, url,
params = { page: 1, pagesize: 20 }, params = {},
valueKey = 'value', valueKey = 'value',
labelKey = 'label', labelKey = 'label',
searchKey = 'search', searchKey = 'search',
pageSize = 20,
fetchOptions, fetchOptions,
format, format,
debounceTimeout = 300, debounceTimeout = 300,
@@ -43,56 +46,81 @@ const DebounceSelect: FC<DebounceSelectProps> = ({
}) => { }) => {
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [options, setOptions] = useState<DefaultOptionType[]>([]); const [options, setOptions] = useState<DefaultOptionType[]>([]);
const [hasMore, setHasMore] = useState(true);
const pageRef = useRef(1);
const keywordRef = useRef<string | null>(null);
const fetchRef = useRef(0); const fetchRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout>>();
// Load initial options on mount const fetchPage = useCallback((keyword: string | null, page: number, replace: boolean) => {
useEffect(() => { fetchRef.current += 1;
debounceFetcher(null); const fetchId = fetchRef.current;
}, []); setFetching(true);
const promise = fetchOptions
? fetchOptions(keyword, page)
: request
.get<ApiResponse<OptionType>>(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) => { const debounceFetcher = useCallback((keyword: string | null) => {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
fetchRef.current += 1; keywordRef.current = keyword;
const fetchId = fetchRef.current; pageRef.current = 1;
setOptions([]); fetchPage(keyword, 1, true);
setFetching(true);
const promise: Promise<DefaultOptionType[]> = fetchOptions
? fetchOptions(keyword)
: request
.get<ApiResponse<OptionType>>(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));
}, debounceTimeout); }, debounceTimeout);
}, [url, params, searchKey, fetchOptions, format, valueKey, labelKey, debounceTimeout]); }, [fetchPage, debounceTimeout]);
useEffect(() => {
debounceFetcher(null);
}, []);
const handlePopupScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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 ( return (
<Select <Select
labelInValue labelInValue
filterOption={false} filterOption={false}
onSearch={debounceFetcher} onSearch={debounceFetcher}
onPopupScroll={handlePopupScroll}
notFoundContent={fetching ? <Spin size="small" /> : null} notFoundContent={fetching ? <Spin size="small" /> : null}
allowClear
{...props} {...props}
options={options} options={options}
dropdownRender={(menu) => (
<>
{menu}
{fetching && options.length > 0 && (
<div style={{ textAlign: 'center', padding: '4px 0' }}><Spin size="small" /></div>
)}
</>
)}
optionRender={(option) => ( optionRender={(option) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{option.data.avatar && <Avatar src={option.data.avatar} size="small" />} {option.data.avatar && <Avatar src={option.data.avatar} size="small" />}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 15:07:49 * @Date: 2026-02-02 15:07:49
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 13:43:59 * @Last Modified time: 2026-04-07 12:18:58
*/ */
/** /**
* AppHeader Component * AppHeader Component
@@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
{ {
key: '1', key: '1',
icon: <Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#155EEF] rb:text-white"> icon: <Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#155EEF] rb:text-white">
{/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username[0]} {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username?.[0]}
</Flex>, </Flex>,
label: (<> label: (<>
<div className="rb:text-[#212332] rb:leading-5">{user.username}</div> <div className="rb:text-[#212332] rb:leading-5">{user.username}</div>
@@ -91,10 +91,10 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
}, },
{ {
key: '3', key: '3',
icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/menuNew/userInfo.svg')]"></div>, icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/userInfo.svg')]"></div>,
label: <Flex justify="space-between" align="center"> label: <Flex justify="space-between" align="center">
{t('header.userInfo')} {t('header.userInfo')}
<div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/menuNew/arrow_t_r.svg')]"></div> <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/arrow_t_r.svg')]"></div>
</Flex>, </Flex>,
className: 'rb:text-[#212332]!', className: 'rb:text-[#212332]!',
onClick: () => { onClick: () => {
@@ -103,10 +103,10 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
}, },
{ {
key: '4', key: '4',
icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/menuNew/settings.svg')]"></div>, icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/settings.svg')]"></div>,
label: <Flex justify="space-between" align="center"> label: <Flex justify="space-between" align="center">
{t('header.settings')} {t('header.settings')}
<div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/menuNew/arrow_t_r.svg')]"></div> <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/arrow_t_r.svg')]"></div>
</Flex>, </Flex>,
className: 'rb:text-[#212332]!', className: 'rb:text-[#212332]!',
onClick: () => { onClick: () => {
@@ -120,7 +120,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
}, },
{ {
key: '6', key: '6',
icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/menuNew/logout_red.svg')]"></div>, icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/logout_red.svg')]"></div>,
label: t('header.logout'), label: t('header.logout'),
danger: true, danger: true,
className: 'rb:hover:rb:bg-transparent rb:hover:text-[#FF5D34]!', className: 'rb:hover:rb:bg-transparent rb:hover:text-[#FF5D34]!',
@@ -180,7 +180,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
> >
<Flex align="center" className="rb:cursor-pointer rb:font-medium"> <Flex align="center" className="rb:cursor-pointer rb:font-medium">
<Flex align="center" justify="center" className="rb:size-8 rb:rounded-xl rb:bg-[#155EEF] rb:text-white rb:mr-2!"> <Flex align="center" justify="center" className="rb:size-8 rb:rounded-xl rb:bg-[#155EEF] rb:text-white rb:mr-2!">
{/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username[0]} {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(user.username.length, -2) : user.username[0]}
</Flex> </Flex>
<span className="rb:text-[#212332] rb:text-[12px] rb:leading-4 rb:mr-1">{user.username}</span> <span className="rb:text-[#212332] rb:text-[12px] rb:leading-4 rb:mr-1">{user.username}</span>
<div className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')]", { <div className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')]", {

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 15:15:11 * @Date: 2026-02-02 15:15:11
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:15:11 * @Last Modified time: 2026-04-07 14:04:33
*/ */
/** /**
* CodeBlock Component * CodeBlock Component
@@ -27,6 +27,7 @@ type ICodeBlockProps = {
needCopy?: boolean; needCopy?: boolean;
size?: 'small' | 'default'; size?: 'small' | 'default';
showLineNumbers?: boolean; showLineNumbers?: boolean;
background?: string;
} }
/** Code block component for displaying formatted code with optional copy functionality */ /** Code block component for displaying formatted code with optional copy functionality */
@@ -34,7 +35,8 @@ const CodeBlock: FC<ICodeBlockProps> = ({
value, value,
needCopy = true, needCopy = true,
size = 'default', size = 'default',
showLineNumbers = false showLineNumbers = false,
background = '#F0F3F8'
}) => { }) => {
return ( return (
@@ -43,7 +45,7 @@ const CodeBlock: FC<ICodeBlockProps> = ({
style={atelierHeathLight} style={atelierHeathLight}
customStyle={{ customStyle={{
padding: '8px 12px 8px 12px', padding: '8px 12px 8px 12px',
backgroundColor: '#F0F3F8', backgroundColor: background,
borderRadius: 8, borderRadius: 8,
fontSize: size === 'small' ? 12 : 14, fontSize: size === 'small' ? 12 : 14,
wordBreak: 'break-all' wordBreak: 'break-all'

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-02 15:17:31 * @Date: 2026-02-02 15:17:31
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 16:06:03 * @Last Modified time: 2026-04-07 21:56:00
*/ */
/** /**
* RbMarkdown Component * RbMarkdown Component
@@ -22,7 +22,7 @@
* @component * @component
*/ */
import { useState, useRef, useEffect, type FC, createContext, useContext, useCallback } from 'react' import { useState, useRef, useEffect, type FC, createContext, useContext, useCallback, useMemo } from 'react'
import { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd' import { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import RemarkGfm from 'remark-gfm' import RemarkGfm from 'remark-gfm'
@@ -44,6 +44,11 @@ const FormContext = createContext<{
onSubmit?: (values: Record<string, any>) => void; onSubmit?: (values: Record<string, any>) => void;
} | null>(null) } | null>(null)
/** Stable form wrapper component — state lives in RbMarkdown, survives components object rebuilds */
const RbForm: FC<any> = ({ children, ...props }) => (
<Form {...props}>{children}</Form>
)
/** Props interface for RbMarkdown component */ /** Props interface for RbMarkdown component */
interface RbMarkdownProps { interface RbMarkdownProps {
/** Markdown content to render */ /** Markdown content to render */
@@ -60,8 +65,8 @@ interface RbMarkdownProps {
onFormSubmit?: (values: Record<string, any>) => void; onFormSubmit?: (values: Record<string, any>) => void;
} }
/** Build components with onFormSubmit callback */ /** Build stable components map — form submission handled via FormContext */
const buildComponents = (onFormSubmit?: (values: Record<string, any>) => void) => ({ const buildComponents = () => ({
h1: ({ children, ...props }: any) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2" {...props}>{children}</h1>, h1: ({ children, ...props }: any) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2" {...props}>{children}</h1>,
h2: ({ children, ...props }: any) => <h2 className="rb:text-xl rb:font-bold rb:mb-2" {...props}>{children}</h2>, h2: ({ children, ...props }: any) => <h2 className="rb:text-xl rb:font-bold rb:mb-2" {...props}>{children}</h2>,
h3: ({ children, ...props }: any) => <h3 className="rb:text-lg rb:font-bold rb:mb-2" {...props}>{children}</h3>, h3: ({ children, ...props }: any) => <h3 className="rb:text-lg rb:font-bold rb:mb-2" {...props}>{children}</h3>,
@@ -95,53 +100,50 @@ const buildComponents = (onFormSubmit?: (values: Record<string, any>) => void) =
td: ({ children, ...props }: any) => <td className="rb:border rb:border-[#EBEBEB] rb:px-2 rb:py-1 rb:text-left" {...props}>{children}</td>, td: ({ children, ...props }: any) => <td className="rb:border rb:border-[#EBEBEB] rb:px-2 rb:py-1 rb:text-left" {...props}>{children}</td>,
button: ({ children, ...props }: any) => { button: ({ children, ...props }: any) => {
const ctx = useContext(FormContext) const ctx = useContext(FormContext)
return <RbButton {...props} onClick={() => ctx?.onSubmit?.(ctx.values)}>{[children]}</RbButton> return <RbButton {...props} onClick={() => ctx?.onSubmit?.(ctx?.values ?? {})}>{[children]}</RbButton>
}, },
table: ({ children, ...props }: any) => <div className="rb:overflow-x-auto rb:max-w-full"><table className="rb:border rb:border-[#D9D9D9] rb:mb-2" {...props}>{children}</table></div>, input: ({ children, value, ...props }: any) => {
tr: ({ children, ...props }: any) => <tr className="rb:border rb:border-[#D9D9D9]" {...props}>{children}</tr>,
th: ({ children, ...props }: any) => <th className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left rb:font-bold" {...props}>{children}</th>,
td: ({ children, ...props }: any) => <td className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left" {...props}>{children}</td>,
input: ({ children, ...props }: any) => {
const ctx = useContext(FormContext) const ctx = useContext(FormContext)
const handleChange = useCallback((val: any) => { const handleChange = useCallback((val: any) => {
if (props.name) ctx?.setValue(props.name, val) if (props.name) ctx?.setValue(props.name, val)
}, [ctx, props.name]) }, [ctx, props.name])
console.log('props', props)
switch (props.type) { switch (props.type) {
case 'color': case 'color':
return <ColorPicker className="rb:mb-4!" {...props} onChange={handleChange} /> return <ColorPicker className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'time': case 'time':
return <TimePicker className="rb:mb-4!" {...props} onChange={handleChange} /> return <TimePicker className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'date': case 'date':
return <DatePicker className="rb:mb-4!" {...props} onChange={handleChange} /> return <DatePicker className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'datetime': case 'datetime':
case 'datetime-local': case 'datetime-local':
return <DatePicker className="rb:mb-4!" showTime={true} {...props} onChange={handleChange} /> return <DatePicker className="rb:mb-4!" defaultValue={value} showTime={true} {...props} onChange={handleChange} />
case 'week': case 'week':
return <DatePicker className="rb:mb-4!" picker="week" {...props} onChange={handleChange} /> return <DatePicker className="rb:mb-4!" defaultValue={value} picker="week" {...props} onChange={handleChange} />
case 'month': case 'month':
return <DatePicker className="rb:mb-4!" picker="month" {...props} onChange={handleChange} /> return <DatePicker className="rb:mb-4!" defaultValue={value} picker="month" {...props} onChange={handleChange} />
case 'number': case 'number':
return <InputNumber className="rb:mb-4!" {...props} onChange={handleChange} /> return <InputNumber className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'search': case 'search':
return <Input.Search className="rb:mb-4!" {...props} onChange={(e) => handleChange(e.target.value)} /> return <Input.Search className="rb:mb-4!" defaultValue={value} {...props} onChange={(e) => handleChange(e.target.value)} />
case 'range': case 'range':
return <Slider className="rb:mb-4!" {...props} onChange={handleChange} /> return <Slider className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'submit': case 'submit':
case 'button': case 'button':
return <RbButton className="rb:mb-4!" {...props} onClick={() => ctx?.onSubmit?.(ctx.values)}>{[props.value || children]}</RbButton> return <RbButton className="rb:mb-4!" defaultValue={value} {...props} onClick={() => ctx?.onSubmit?.(ctx?.values ?? {})}>{[props.value || children]}</RbButton>
case 'checkbox': case 'checkbox':
return <Checkbox className="rb:mb-4!" {...props} onChange={(e) => handleChange(e.target.checked)}>{children}</Checkbox> return <Checkbox className="rb:mb-4!" defaultValue={value} {...props} onChange={(e) => handleChange(e.target.checked)}>{children}</Checkbox>
case 'password': case 'password':
return <Input.Password className="rb:mb-4!" {...props} onChange={(e) => handleChange(e.target.value)} /> return <Input.Password className="rb:mb-4!" defaultValue={value} {...props} onChange={(e) => handleChange(e.target.value)} />
case 'radio': case 'radio':
return <Radio className="rb:mb-4!" {...props} onChange={(e) => handleChange(e.target.value)}>{children}</Radio> return <Radio className="rb:mb-4!" defaultValue={value} {...props} onChange={(e) => handleChange(e.target.value)}>{children}</Radio>
case 'select': { case 'select': {
const raw = props['data-options'] const raw = props['data-options']
const options = (typeof raw === 'string' ? JSON.parse(raw) : raw || []).map((v: string) => ({ label: v, value: v })) const options = (typeof raw === 'string' ? JSON.parse(raw) : raw || []).map((v: string) => ({ label: v, value: v }))
return <Select className="rb:mb-4! rb:w-full!" options={options} onChange={(val) => { if (props.name) ctx?.setValue(props.name, val) }} /> return <Select className="rb:mb-4! rb:w-full!" defaultValue={value} options={options} onChange={(val) => { if (props.name) ctx?.setValue(props.name, val) }} />
} }
default: default:
return <Input className="rb:mb-4!" value={children} {...props} onChange={(e) => handleChange(e.target.value)} /> return <Input className="rb:mb-4!" defaultValue={value} {...props} onChange={(e) => handleChange(e.target.value)} />
} }
}, },
select: ({ children, ...props }: any) => { select: ({ children, ...props }: any) => {
@@ -152,15 +154,7 @@ const buildComponents = (onFormSubmit?: (values: Record<string, any>) => void) =
const ctx = useContext(FormContext) const ctx = useContext(FormContext)
return <Input.TextArea className="rb:mb-4!" {...props} onChange={(e) => { if (props.name) ctx?.setValue(props.name, e.target.value) }}>{children}</Input.TextArea> return <Input.TextArea className="rb:mb-4!" {...props} onChange={(e) => { if (props.name) ctx?.setValue(props.name, e.target.value) }}>{children}</Input.TextArea>
}, },
form: ({ children, ...props }: any) => { form: RbForm,
const [values, setValues] = useState<Record<string, any>>({})
const setValue = useCallback((name: string, value: any) => setValues(prev => ({ ...prev, [name]: value })), [])
return (
<FormContext.Provider value={{ values, setValue, onSubmit: onFormSubmit }}>
<Form {...props}>{children}</Form>
</FormContext.Provider>
)
},
label: ({ children, ...props }: any) => { label: ({ children, ...props }: any) => {
return <label className="rb:block rb:font-medium rb:text-[#212332] rb:mb-2" {...props}>{children}</label> return <label className="rb:block rb:font-medium rb:text-[#212332] rb:mb-2" {...props}>{children}</label>
}, },
@@ -175,7 +169,10 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
className, className,
onFormSubmit, onFormSubmit,
}) => { }) => {
const components = buildComponents(onFormSubmit) const [formValues, setFormValues] = useState<Record<string, any>>({})
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 [editContent, setEditContent] = useState(content)
const textareaRef = useRef<any>(null) const textareaRef = useRef<any>(null)
@@ -242,6 +239,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
/** Render markdown preview mode */ /** Render markdown preview mode */
return ( return (
<FormContext.Provider value={formCtx}>
<div className={`rb:relative ${className || ''}`} onKeyDown={handleKeyDown} tabIndex={0}> <div className={`rb:relative ${className || ''}`} onKeyDown={handleKeyDown} tabIndex={0}>
<style>{` <style>{`
.html-comment { .html-comment {
@@ -278,6 +276,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
{processedContent} {processedContent}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</FormContext.Provider>
) )
} }
export default RbMarkdown export default RbMarkdown

View File

@@ -169,6 +169,8 @@ const RbTable = forwardRef(<T = Record<string, unknown>, Q = Record<string, unkn
const paginationConfig = pagination ? ({ const paginationConfig = pagination ? ({
...(typeof pagination === 'object' ? pagination : {}), ...(typeof pagination === 'object' ? pagination : {}),
...currentPagination, ...currentPagination,
current: currentPagination.page,
pageSize: currentPagination.pagesize,
total, total,
onChange: handlePageChange, onChange: handlePageChange,
showSizeChanger: true, showSizeChanger: true,

View File

@@ -1270,6 +1270,7 @@ export const en = {
participle: 'Participle retrieval', participle: 'Participle retrieval',
semantic: 'Semantic retrieval', semantic: 'Semantic retrieval',
hybrid: 'Hybrid Retrieval', hybrid: 'Hybrid Retrieval',
graph: 'Graph Retrieval',
similarity_threshold: 'Semantic similarity threshold', similarity_threshold: 'Semantic similarity threshold',
similarity_threshold_desc: 'Only return results with semantic similarity higher than this threshold', similarity_threshold_desc: 'Only return results with semantic similarity higher than this threshold',

View File

@@ -657,6 +657,7 @@ export const zh = {
participle: '分词检索', participle: '分词检索',
semantic: '语义检索', semantic: '语义检索',
hybrid: '混合检索', hybrid: '混合检索',
graph: '图谱检索',
similarity_threshold: '语义相似度阈值', similarity_threshold: '语义相似度阈值',
similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果', similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果',

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21 * @Date: 2026-02-03 16:29:21
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-31 16:50:10 * @Last Modified time: 2026-04-07 18:04:49
*/ */
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo } from 'react'; import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -354,6 +354,25 @@ const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigF
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => { const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
form.setFieldValue('features', value) form.setFieldValue('features', value)
const { statement = '' } = value?.opening_statement || {}
onFeaturesLoad?.(value)
const usedVars = [...new Set([...(statement?.matchAll(/\{\{(\w+)\}\}/g) ?? [])].map(m => m[1]))]
const variables = values?.variables
const validNames = new Set(variables.map(v => v.name))
const invalid = usedVars.filter(v => !validNames.has(v))
if (invalid.length > 0) {
const newVars = invalid.map((name, i) => ({
index: variables.length + i,
name,
display_name: name,
type: 'text',
required: true,
max_length: 48,
}))
form.setFieldValue('variables', [...variables, ...newVars])
}
} }
const modelLogo = useMemo(() => { const modelLogo = useMemo(() => {
return defaultModel?.name && getListLogoUrl(defaultModel.provider, defaultModel.logo as string) return defaultModel?.name && getListLogoUrl(defaultModel.provider, defaultModel.logo as string)
@@ -385,7 +404,8 @@ const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigF
if (vo.list?.length === 0) { if (vo.list?.length === 0) {
return { ...vo, list: [assistantMsg] } return { ...vo, list: [assistantMsg] }
} else if (vo.list && vo.list[0].role === 'assistant') { } else if (vo.list && vo.list[0].role === 'assistant') {
return { ...vo, list: [assistantMsg, ...vo.list.slice(1)] } vo.list[0] = assistantMsg
return { ...vo, list: [...vo.list] }
} else { } else {
return { ...vo, list: [assistantMsg, ...(vo.list || [])] } return { ...vo, list: [assistantMsg, ...(vo.list || [])] }
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:33 * @Date: 2026-02-03 16:29:33
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 18:14:25 * @Last Modified time: 2026-04-07 20:37:43
*/ */
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react' import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -115,7 +115,8 @@ const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesCon
console.log({ ids: sub_agents?.map(item => item.agent_id) }) console.log({ ids: sub_agents?.map(item => item.agent_id) })
getApplicationList({ ids: sub_agents?.map(item => item.agent_id).join(',')}) getApplicationList({ ids: sub_agents?.map(item => item.agent_id).join(',')})
.then(res => { .then(res => {
const applicationList = (res as Application[]) || [] const applicationList = ((res as { items: Application[] }).items) || []
setSubAgents(sub_agents.map(vo => { setSubAgents(sub_agents.map(vo => {
const filterVO = applicationList.find(item => item.id === vo.agent_id) const filterVO = applicationList.find(item => item.id === vo.agent_id)
if (filterVO) { if (filterVO) {
@@ -194,6 +195,9 @@ const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesCon
// form.setFieldValue('features', value) // form.setFieldValue('features', value)
// } // }
console.log('subAgents', subAgents)
return ( return (
<> <>
{loading && <Spin fullscreen></Spin>} {loading && <Spin fullscreen></Spin>}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-13 17:27:52 * @Date: 2026-03-13 17:27:52
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:58:07 * @Last Modified time: 2026-04-07 21:48:30
*/ */
import { type FC, useState, useRef, useEffect } from 'react' import { type FC, useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -27,6 +27,7 @@ import type { TestChatProps } from './type'
import type { SSEMessage } from '@/utils/stream' import type { SSEMessage } from '@/utils/stream'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types' import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import { getFileStatusById } from '@/api/fileStorage' import { getFileStatusById } from '@/api/fileStorage'
import { replaceVariables } from '@/views/ApplicationConfig/Agent'
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => { const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
return { return {
@@ -86,13 +87,15 @@ const TestChat: FC<TestChatProps> = ({
const [message, setMessage] = useState<string | undefined>(undefined) const [message, setMessage] = useState<string | undefined>(undefined)
const [fileList, setFileList] = useState<any[]>([]) const [fileList, setFileList] = useState<any[]>([])
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
const [variables, setVariables] = useState<Variable[]>([])
const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map()) const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
const streamLoadingRef = useRef(false)
const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({}) const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
getVariables() getVariables()
}, [application, config]) }, [application, JSON.stringify(config)])
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -107,7 +110,7 @@ const TestChat: FC<TestChatProps> = ({
setFeatures(config?.features || {} as FeaturesConfigForm) setFeatures(config?.features || {} as FeaturesConfigForm)
if (config?.features?.opening_statement?.statement && config?.features?.opening_statement?.statement.trim() !== '') { if (config?.features?.opening_statement?.enabled && config?.features?.opening_statement?.statement && config?.features?.opening_statement?.statement.trim() !== '') {
setChatList(prev => [...prev, { setChatList(prev => [...prev, {
role: 'assistant', role: 'assistant',
created_at: Date.now(), created_at: Date.now(),
@@ -144,6 +147,7 @@ const TestChat: FC<TestChatProps> = ({
} }
toolbarRef.current?.setVariables([...initVariables]) toolbarRef.current?.setVariables([...initVariables])
setVariables([...initVariables])
} }
const addUserMessage = (message: string, files: any[]) => { const addUserMessage = (message: string, files: any[]) => {
@@ -188,7 +192,10 @@ const TestChat: FC<TestChatProps> = ({
} }
const updateAssistantReasoningMessage = (content: string) => { const updateAssistantReasoningMessage = (content: string) => {
if (!content) return if (!content) return
if (streamLoading) setStreamLoading(false) if (streamLoadingRef.current) {
streamLoadingRef.current = false
setStreamLoading(false)
}
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastMsg = newList[newList.length - 1] const lastMsg = newList[newList.length - 1]
@@ -248,6 +255,7 @@ const TestChat: FC<TestChatProps> = ({
toolbarRef.current?.setFiles([]) toolbarRef.current?.setFiles([])
setFileList([]) setFileList([])
addAssistantMessage() addAssistantMessage()
streamLoadingRef.current = true
setStreamLoading(true) setStreamLoading(true)
setLoading(true) setLoading(true)
@@ -262,6 +270,7 @@ const TestChat: FC<TestChatProps> = ({
}) })
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)
streamLoadingRef.current = false
setStreamLoading(false) setStreamLoading(false)
}) })
} }
@@ -338,6 +347,7 @@ const TestChat: FC<TestChatProps> = ({
updateAssistantMessage(content, audio_url, undefined, citations) updateAssistantMessage(content, audio_url, undefined, citations)
} }
updateErrorAssistantMessage(message_length) updateErrorAssistantMessage(message_length)
streamLoadingRef.current = false
setStreamLoading(false) setStreamLoading(false)
break break
} }
@@ -358,6 +368,7 @@ const TestChat: FC<TestChatProps> = ({
setFileList([]) setFileList([])
setMessage(undefined) setMessage(undefined)
setStreamLoading(true) setStreamLoading(true)
streamLoadingRef.current = true
draftRun( draftRun(
application.id, application.id,
@@ -378,6 +389,7 @@ const TestChat: FC<TestChatProps> = ({
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)
setStreamLoading(false) setStreamLoading(false)
streamLoadingRef.current = false
}) })
} }
@@ -416,6 +428,7 @@ const TestChat: FC<TestChatProps> = ({
updateWorkflowEndMessage(item.data as NodeData, citations) updateWorkflowEndMessage(item.data as NodeData, citations)
} }
setStreamLoading(false) setStreamLoading(false)
streamLoadingRef.current = false
setLoading(false) setLoading(false)
break break
} }
@@ -560,6 +573,24 @@ const TestChat: FC<TestChatProps> = ({
}) })
} }
useEffect(() => {
const opening_statement = features?.opening_statement
if (opening_statement?.enabled && opening_statement?.statement && opening_statement?.statement.trim() !== '') {
const assistantMsg: ChatItem = {
role: 'assistant',
content: replaceVariables(opening_statement.statement, variables as any),
meta_data: {
suggested_questions: opening_statement?.suggested_questions
}
}
setChatList(prev => {
prev[0] = assistantMsg
return [...prev]
})
}
}, [chatList.length, features?.opening_statement, variables])
return ( return (
<div className="rb:w-250 rb:mx-auto rb:h-full"> <div className="rb:w-250 rb:mx-auto rb:h-full">
<RbCard <RbCard
@@ -592,6 +623,7 @@ const TestChat: FC<TestChatProps> = ({
ref={toolbarRef} ref={toolbarRef}
features={features} features={features}
onFilesChange={setFileList} onFilesChange={setFileList}
onVariablesChange={setVariables}
/> />
</Chat> </Chat>
</RbCard> </RbCard>

View File

@@ -185,6 +185,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
handleOpen, handleOpen,
})); }));
const [isFocus, setIsFocus] = useState(false) const [isFocus, setIsFocus] = useState(false)
const [isComposing, setIsComposing] = useState(false)
const handleFocus = () => { const handleFocus = () => {
setIsFocus(true) setIsFocus(true)
} }
@@ -236,7 +237,9 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
<Form.Item name="message" className="rb:flex-1 rb:mb-0!"> <Form.Item name="message" className="rb:flex-1 rb:mb-0!">
<Input <Input
placeholder={t(`${source}.promptChatPlaceholder`)} placeholder={t(`${source}.promptChatPlaceholder`)}
onPressEnter={handleSend} onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing) handleSend() }}
variant="borderless" variant="borderless"
className="rb:p-0!" className="rb:p-0!"
onFocus={handleFocus} onFocus={handleFocus}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:27:52 * @Date: 2026-02-03 16:27:52
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 19:07:24 * @Last Modified time: 2026-04-07 16:28:33
*/ */
import { type FC, useRef, useMemo, useCallback } from 'react'; import { type FC, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -18,7 +18,6 @@ import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigFor
import { deleteApplication, appExport } from '@/api/application' import { deleteApplication, appExport } from '@/api/application'
import CopyModal from './CopyModal' import CopyModal from './CopyModal'
import PageHeader from '@/components/Layout/PageHeader' import PageHeader from '@/components/Layout/PageHeader'
import FeaturesConfig from './FeaturesConfig'
/** /**
* Tab keys for application configuration * Tab keys for application configuration
@@ -70,7 +69,6 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
application, activeTab, handleChangeTab, refresh, application, activeTab, handleChangeTab, refresh,
workflowRef, workflowRef,
appRef, appRef,
features,
onFeaturesChange, onFeaturesChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -175,10 +173,9 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
return items return items
}, [t, handleClick, application]) }, [t, handleClick, application])
const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => { const handleFeaturesConfig = () => {
appRef?.current?.handleSaveFeaturesConfig?.(value) workflowRef.current?.handleFeaturesConfig?.()
onFeaturesChange?.(value) }
}, [appRef, onFeaturesChange])
return ( return (
<> <>
@@ -209,12 +206,12 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
</Flex>} </Flex>}
extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement' extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement'
? <Flex align="center" justify="end" gap={10} className="rb:h-8"> ? <Flex align="center" justify="end" gap={10} className="rb:h-8">
<FeaturesConfig <Popover content={t('application.features')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}>
source={application?.type} <div
value={features as FeaturesConfigForm} className="rb:cursor-pointer rb:size-7.5 rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6] rb:rounded-[10px] rb:bg-[url('@/assets/images/workflow/features.svg')] rb:bg-size-[16px_16px] rb:bg-center rb:bg-no-repeat"
refresh={handleSaveFeaturesConfig} onClick={handleFeaturesConfig}
chatVariables={(workflowRef.current?.chatVariables || []).map(v => ({ ...v, display_name: v.name }))} ></div>
/> </Popover>
<Popover content={t('workflow.clear')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}> <Popover content={t('workflow.clear')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}>
<div <div
className="rb:cursor-pointer rb:size-7.5 rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6] rb:rounded-[10px] rb:bg-[url('@/assets/images/workflow/clear.svg')] rb:bg-size-[16px_16px] rb:bg-center rb:bg-no-repeat" className="rb:cursor-pointer rb:size-7.5 rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6] rb:rounded-[10px] rb:bg-[url('@/assets/images/workflow/clear.svg')] rb:bg-size-[16px_16px] rb:bg-center rb:bg-no-repeat"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:27:56 * @Date: 2026-02-03 16:27:56
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:49:51 * @Last Modified time: 2026-04-07 16:13:44
*/ */
/** /**
* Copy Application Modal * Copy Application Modal
@@ -205,6 +205,7 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
/> />
<OpenStatementSettingModal <OpenStatementSettingModal
ref={openStatementSettingModalRef} ref={openStatementSettingModalRef}
source={source}
chatVariables={chatVariables} chatVariables={chatVariables}
onSave={handleSaveStatement} onSave={handleSaveStatement}
/> />

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-05 * @Date: 2026-03-05
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 14:38:28 * @Last Modified time: 2026-04-07 16:58:10
*/ */
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState } from 'react';
import { Button, Form, Input, Flex, App } from 'antd'; import { Button, Form, Input, Flex, App } from 'antd';
@@ -12,6 +12,8 @@ import RbModal from '@/components/RbModal';
import type { FeaturesConfigForm } from '../../types' import type { FeaturesConfigForm } from '../../types'
import type { Variable } from '../VariableList/types' import type { Variable } from '../VariableList/types'
import Tag from '@/components/Tag' import Tag from '@/components/Tag'
import type { Application } from '@/views/ApplicationManagement/types';
import Editor from '@/views/Workflow/components/Editor';
export interface OpenStatementSettingModalRef { export interface OpenStatementSettingModalRef {
handleOpen: (values?: FeaturesConfigForm['opening_statement']) => void; handleOpen: (values?: FeaturesConfigForm['opening_statement']) => void;
@@ -21,17 +23,21 @@ export interface OpenStatementSettingModalRef {
interface OpenStatementSettingModalProps { interface OpenStatementSettingModalProps {
onSave: (values: FeaturesConfigForm['opening_statement']) => void; onSave: (values: FeaturesConfigForm['opening_statement']) => void;
chatVariables?: Variable[]; chatVariables?: Variable[];
source?: Application['type'];
} }
const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenStatementSettingModalProps>(({ const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenStatementSettingModalProps>(({
onSave, onSave,
chatVariables = [] chatVariables = [],
source
}, ref) => { }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { modal } = App.useApp() const { modal } = App.useApp()
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FeaturesConfigForm['opening_statement']>(); const [form] = Form.useForm<FeaturesConfigForm['opening_statement']>();
console.log('chatVariables', chatVariables)
const handleClose = () => { const handleClose = () => {
setVisible(false); setVisible(false);
form.resetFields(); form.resetFields();
@@ -45,10 +51,11 @@ const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenS
const handleSave = async () => { const handleSave = async () => {
form.validateFields().then(values => { form.validateFields().then(values => {
const { suggested_questions, ...rest } = values const { suggested_questions, ...rest } = values
const filterSuggestedQuestions = suggested_questions.filter(vo => vo && vo.trim() !== '' && vo !== null) const filterSuggestedQuestions = suggested_questions?.filter(vo => vo && vo.trim() !== '' && vo !== null)
if (values?.enabled && values?.statement && values?.statement?.trim() !== '') { if (values?.enabled && values?.statement && values?.statement?.trim() !== '') {
const usedVars = [...new Set([...values.statement?.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]))] const usedVars = [...new Set([...values.statement?.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]))]
console.log('usedVars', usedVars, chatVariables)
const validNames = new Set(chatVariables.map(v => v.name)) const validNames = new Set(chatVariables.map(v => v.name))
const invalid = usedVars.filter(v => !validNames.has(v)) const invalid = usedVars.filter(v => !validNames.has(v))
if (invalid.length > 0) { if (invalid.length > 0) {
@@ -100,9 +107,12 @@ const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenS
label={t('application.opening_statement')} label={t('application.opening_statement')}
name="statement" name="statement"
> >
<Input.TextArea {source === 'workflow'
placeholder={t('common.pleaseEnter')} ? <Editor options={chatVariables as any} variant="outlined" />
/> : <Input.TextArea
placeholder={t('common.pleaseEnter')}
/>
}
</Form.Item> </Form.Item>
<Form.List name="suggested_questions"> <Form.List name="suggested_questions">

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:25:37 * @Date: 2026-02-03 16:25:37
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-24 11:47:27 * @Last Modified time: 2026-04-07 22:35:08
*/ */
/** /**
* Knowledge Configuration Modal * Knowledge Configuration Modal
@@ -32,7 +32,9 @@ interface KnowledgeConfigModalProps {
/** /**
* Available retrieval types * Available retrieval types
*/ */
const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid'] const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid',
// 'graph'
]
/** /**
* Modal for configuring knowledge base retrieval settings * Modal for configuring knowledge base retrieval settings

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:25:53 * @Date: 2026-02-03 16:25:53
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:25:53 * @Last Modified time: 2026-04-07 17:16:47
*/ */
/** /**
* Type definitions for knowledge base configuration in application settings * Type definitions for knowledge base configuration in application settings
@@ -28,7 +28,7 @@ export interface RerankerConfig {
* - semantic: Semantic similarity based retrieval * - semantic: Semantic similarity based retrieval
* - hybrid: Combination of both methods * - hybrid: Combination of both methods
*/ */
export type RetrieveType = 'participle' | 'semantic' | 'hybrid' export type RetrieveType = 'participle' | 'semantic' | 'hybrid' | 'graph'
/** /**
* Knowledge base configuration form data * Knowledge base configuration form data

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49 * @Date: 2026-02-03 16:29:49
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-31 15:45:17 * @Last Modified time: 2026-04-07 15:46:19
*/ */
import type { KnowledgeConfig } from './components/Knowledge/types' import type { KnowledgeConfig } from './components/Knowledge/types'
import type { Variable } from './components/VariableList/types' import type { Variable } from './components/VariableList/types'
@@ -168,6 +168,7 @@ export interface WorkflowRef {
chatVariables: ChatVariable[]; chatVariables: ChatVariable[];
config: WorkflowConfig | null; config: WorkflowConfig | null;
features: WorkflowConfig['features']; features: WorkflowConfig['features'];
handleFeaturesConfig?: () => void;
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void; handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
} }

View File

@@ -235,10 +235,13 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
name: rcFile.name, name: rcFile.name,
status: 'uploading' as UploadFileStatus, status: 'uploading' as UploadFileStatus,
percent: 0, percent: 0,
type: rcFile.type, type: (rcFile.type && transform_file_type[rcFile.type as keyof typeof transform_file_type]) || rcFile.type || 'document',
originFileObj: rcFile, originFileObj: rcFile,
thumbUrl: URL.createObjectURL(rcFile) thumbUrl: URL.createObjectURL(rcFile),
size: rcFile.size,
} }
console.log('fileVo', fileVo)
onChange?.(fileVo) onChange?.(fileVo)
request.uploadFile(action, formData, requestConfig) request.uploadFile(action, formData, requestConfig)
.then(res => { .then(res => {

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:58:03 * @Date: 2026-02-03 16:58:03
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-31 16:24:47 * @Last Modified time: 2026-04-07 21:21:52
*/ */
/** /**
* Conversation Page * Conversation Page
@@ -178,10 +178,11 @@ const Conversation: FC = () => {
})) }))
}) })
} else { } else {
if (features?.opening_statement?.statement) { if (features?.opening_statement?.enabled && features?.opening_statement?.statement) {
const variables = toolbarRef.current?.getVariables() || []
setChatList([{ setChatList([{
role: 'assistant', role: 'assistant',
content: features.opening_statement.statement, content: replaceVariables(features?.opening_statement.statement, variables as unknown as AppVariable[]),
created_at: Date.now(), created_at: Date.now(),
meta_data: { meta_data: {
suggested_questions: features.opening_statement?.suggested_questions suggested_questions: features.opening_statement?.suggested_questions
@@ -435,11 +436,11 @@ const Conversation: FC = () => {
const handleChangeVariables = (variables: Variable[]) => { const handleChangeVariables = (variables: Variable[]) => {
setChatList(prev => { setChatList(prev => {
const firstMsg = prev[0] const firstMsg = prev[0]
console.log('firstMsg', firstMsg) if (firstMsg && firstMsg.role === 'assistant' && firstMsg.content && features?.opening_statement?.enabled && features?.opening_statement.statement && variables.length > 0) {
if (firstMsg && firstMsg.role === 'assistant' && firstMsg.content && features?.opening_statement.enabled && features?.opening_statement.statement && variables.length > 0) {
firstMsg.content = replaceVariables(features?.opening_statement.statement, variables as unknown as AppVariable[]) firstMsg.content = replaceVariables(features?.opening_statement.statement, variables as unknown as AppVariable[])
return [firstMsg, ...prev.slice(1)]
} }
return [firstMsg, ...prev.slice(1)] return prev
}) })
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 17:28:07 * @Date: 2026-02-03 17:28:07
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-11 14:57:55 * @Last Modified time: 2026-04-07 23:23:04
*/ */
/** /**
* Top Card List Component * Top Card List Component
@@ -62,8 +62,8 @@ const TopCardList: FC<{data?: DashboardData}> = ({ data }) => {
'rb:text-[#FF5D34]': data?.[`${item.key}_change` as keyof DashboardData] && data?.[`${item.key}_change` as keyof DashboardData] < 0, 'rb:text-[#FF5D34]': data?.[`${item.key}_change` as keyof DashboardData] && data?.[`${item.key}_change` as keyof DashboardData] < 0,
'rb:text-[#369F21]': !data?.[`${item.key}_change` as keyof DashboardData] || data?.[`${item.key}_change` as keyof DashboardData] >= 0, 'rb:text-[#369F21]': !data?.[`${item.key}_change` as keyof DashboardData] || data?.[`${item.key}_change` as keyof DashboardData] >= 0,
})}> })}>
{data?.[`${item.key}_change` as keyof DashboardData] && data?.[item.key as keyof DashboardData] > 0 {data?.[`${item.key}_change` as keyof DashboardData] && typeof data?.[item.key as keyof DashboardData] === 'number'
? (100 * data?.[`${item.key}_change` as keyof DashboardData] / data?.[item.key as keyof DashboardData]).toFixed(2) ? (100 * data?.[`${item.key}_change` as keyof DashboardData]).toFixed(2)
: 0 : 0
}% }%
<div className={clsx("rb:size-3.5 rb:cursor-pointer rb:bg-cover", { <div className={clsx("rb:size-3.5 rb:cursor-pointer rb:bg-cover", {

View File

@@ -562,83 +562,88 @@ const KnowledgeBaseManagement: FC = () => {
{data.length === 0 && !loading ? ( {data.length === 0 && !loading ? (
<Empty size={200} /> <Empty size={200} />
) : ( ) : (
<div style={{ columns: '3 280px', columnGap: 12, marginBottom: 8 }}> <Flex align="flex-start" gap={12} className="rb:mb-2!">
{data.map((item) => { {[0, 1, 2].map(colIdx => (
const modelInfo = modelMenus[item.id]; <div key={colIdx} style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12 }}>
const hasModelInfo = modelInfo && modelInfo.menu.length > 1; {data.filter((_, i) => i % 3 === colIdx).map((item) => {
return ( const modelInfo = modelMenus[item.id];
<div key={item.id} className="rb:break-inside-avoid rb:mb-3"> const hasModelInfo = modelInfo && modelInfo.menu.length > 1;
<RbCard return (
title={item.name} <div key={item.id}>
headerType="borderless" <RbCard
headerClassName="rb:py-3!" title={item.name}
extra={ headerType="borderless"
<div onClick={(e) => e.stopPropagation()}> headerClassName="rb:py-3!"
<Dropdown extra={
menu={{ items: getOptMenuItems(item) }} <div onClick={(e) => e.stopPropagation()}>
placement="bottomRight" <Dropdown
> menu={{ items: getOptMenuItems(item) }}
<div onClick={(e) => e.stopPropagation()} className="rb:cursor-pointer rb:size-5.5 rb:bg-[url('@/assets/images/common/more.svg')] rb:hover:bg-[url('@/assets/images/common/more_hover.svg')]"></div> placement="bottomRight"
</Dropdown> >
</div> <div onClick={(e) => e.stopPropagation()} className="rb:cursor-pointer rb:size-5.5 rb:bg-[url('@/assets/images/common/more.svg')] rb:hover:bg-[url('@/assets/images/common/more_hover.svg')]"></div>
} </Dropdown>
>
<div className='' onClick={() => handleToDetail(item)}>
<div className="rb:flex rb:text-[#5B6167] rb:h-5 rb:line-clamp-1 rb:text-sm rb:leading-5 rb:mb-3">
{/* <div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div> */}
<Tooltip title={item.description}>
<div className='rb:flex-1 rb:text-left rb:leading-5 rb:text-gray-800 rb:wrap-break-word rb:line-clamp-2'>{(item.description && item.description != '') ? item.description : t('knowledgeBase.noDescription')}</div>
</Tooltip>
</div>
<Flex vertical gap={4} className='rb:min-h-15 rb:py-2.5! rb:px-3! rb:bg-[#F6F6F6] rb:rounded-lg rb:mb-3'>
{item.descriptionItems?.map((description: Record<string, unknown>) => (
<div
key={description.key as string}
className="rb:grid rb:grid-cols-2 rb:text-[#5B6167] rb:text-[14px] rb:leading-5"
>
<div className={clsx('rb:whitespace-nowrap rb:w-20', {"rb:text-gray-800 rb:font-medium" : (description.key as string) === 'permission_id'})}>{(description.label as string)}</div>
<div className={clsx('rb:flex-inline rb:text-left rb:py-px rb:rounded',{
"rb:text-[#155eef] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.private'),
"rb:text-[#FF8A4C] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.share'),
})}>{(description.children as string)}</div>
</div>
))}
</Flex>
{hasModelInfo && (
<div onClick={(e) => e.stopPropagation()}>
<div
className="rb:flex rb:items-center rb:pt-2 rb:px-2 rb:text-[12px] rb:leading-5 rb:cursor-pointer rb:rounded rb:transition-colors"
onClick={() => {
setData(prev => prev.map(d => d.id === item.id ? { ...d, _expanded: !d._expanded } : d));
}}
>
{/* <span className='rb:text-gray-500'>{t('knowledgeBase.models')}:</span> */}
<span className="rb:ml-1 rb:truncate rb:flex-1 rb:text-gray-500">
{modelInfo.summary[0].split(':')[0]}:<span className="rb:text-gray-900">{modelInfo.summary[0].split(':').slice(1).join(':')}</span>
</span>
<span className="rb:ml-auto rb:text-gray-400 rb:text-[10px]">
{item._expanded ? <DownOutlined /> : <RightOutlined />}
</span>
</div>
{item._expanded && (
<div className="rb:py-1 rb:px-2 rb:text-[12px]">
{modelInfo.summary.slice(1).map((text, idx) => {
const [label, value] = text.split(':');
return (
<div key={idx} className="rb:py-1 rb:text-gray-500">
{label}:<span className="rb:text-gray-900">{value}</span>
</div>
);
})}
</div> </div>
)} }
</div> >
)} <div className='' onClick={() => handleToDetail(item)}>
</div> <div className="rb:flex rb:text-[#5B6167] rb:h-5 rb:line-clamp-1 rb:text-sm rb:leading-5 rb:mb-3">
</RbCard> {/* <div className="rb:font-medium rb:w-20">{t('knowledgeBase.description')} </div> */}
<Tooltip title={item.description}>
<div className='rb:flex-1 rb:text-left rb:leading-5 rb:text-gray-800 rb:wrap-break-word rb:line-clamp-2'>{(item.description && item.description != '') ? item.description : t('knowledgeBase.noDescription')}</div>
</Tooltip>
</div>
<Flex vertical gap={4} className='rb:min-h-15 rb:py-2.5! rb:px-3! rb:bg-[#F6F6F6] rb:rounded-lg rb:mb-3'>
{item.descriptionItems?.map((description: Record<string, unknown>) => (
<div
key={description.key as string}
className="rb:grid rb:grid-cols-2 rb:text-[#5B6167] rb:text-[14px] rb:leading-5"
>
<div className={clsx('rb:whitespace-nowrap rb:w-20', {"rb:text-gray-800 rb:font-medium" : (description.key as string) === 'permission_id'})}>{(description.label as string)}</div>
<div className={clsx('rb:flex-inline rb:text-left rb:py-px rb:rounded',{
"rb:text-[#155eef] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.private'),
"rb:text-[#FF8A4C] rb:font-medium": (description.key as string) === 'permission_id' && (description.children as string) === t('knowledgeBase.share'),
})}>{(description.children as string)}</div>
</div>
))}
</Flex>
{hasModelInfo && (
<div onClick={(e) => e.stopPropagation()}>
<div
className="rb:flex rb:items-center rb:pt-2 rb:px-2 rb:text-[12px] rb:leading-5 rb:cursor-pointer rb:rounded rb:transition-colors"
onClick={() => {
setData(prev => prev.map(d => d.id === item.id ? { ...d, _expanded: !d._expanded } : d));
}}
>
{/* <span className='rb:text-gray-500'>{t('knowledgeBase.models')}:</span> */}
<span className="rb:ml-1 rb:truncate rb:flex-1 rb:text-gray-500">
{modelInfo.summary[0].split(':')[0]}:<span className="rb:text-gray-900">{modelInfo.summary[0].split(':').slice(1).join(':')}</span>
</span>
<span className="rb:ml-auto rb:text-gray-400 rb:text-[10px]">
{item._expanded ? <DownOutlined /> : <RightOutlined />}
</span>
</div>
{item._expanded && (
<div className="rb:py-1 rb:px-2 rb:text-[12px]">
{modelInfo.summary.slice(1).map((text, idx) => {
const [label, value] = text.split(':');
return (
<div key={idx} className="rb:py-1 rb:text-gray-500">
{label}:<span className="rb:text-gray-900">{value}</span>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</RbCard>
</div>
)
})}
</div> </div>
)})} ))}
</div> </Flex>
)} )}
</InfiniteScroll> </InfiniteScroll>

View File

@@ -169,6 +169,7 @@ const Prompt: FC = () => {
updateSession() updateSession()
} }
const [isFocus, setIsFocus] = useState(false) const [isFocus, setIsFocus] = useState(false)
const [isComposing, setIsComposing] = useState(false)
const handleFocus = () => { const handleFocus = () => {
setIsFocus(true) setIsFocus(true)
} }
@@ -209,7 +210,9 @@ const Prompt: FC = () => {
<Form.Item name="message" className="rb:flex-1 rb:mb-0!"> <Form.Item name="message" className="rb:flex-1 rb:mb-0!">
<Input <Input
placeholder={t(`prompt.promptChatPlaceholder`)} placeholder={t(`prompt.promptChatPlaceholder`)}
onPressEnter={handleSend} onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing) handleSend() }}
variant="borderless" variant="borderless"
className="rb:p-0!" className="rb:p-0!"
onFocus={handleFocus} onFocus={handleFocus}

View File

@@ -48,8 +48,8 @@ const ConversationMemory: FC = () => {
gap={12} gap={12}
> >
<div className={clsx("rb:size-8 rb:bg-cover", { <div className={clsx("rb:size-8 rb:bg-cover", {
'rb:bg-[url(src/assets/images/conversation/user.png)]': item.role === 'user', 'rb:bg-[url(@/assets/images/conversation/user.png)]': item.role === 'user',
'rb:bg-[url(src/assets/images/conversation/ai.png)]': item.role === 'assistant', 'rb:bg-[url(@/assets/images/conversation/ai.png)]': item.role === 'assistant',
})}></div> })}></div>
<div <div
className="rb:flex-1" className="rb:flex-1"

View File

@@ -2,11 +2,11 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-30 13:59:36 * @Date: 2025-12-30 13:59:36
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 19:01:12 * @Last Modified time: 2026-04-08 11:05:34
*/ */
import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
import { Form, Input, Select, InputNumber, Button, Row, Col, Flex, Spin } from 'antd'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex, Spin } from 'antd';
import clsx from 'clsx'; import { PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { ChatVariableModalRef } from './types' import type { ChatVariableModalRef } from './types'
@@ -18,9 +18,31 @@ import UploadFileListModal from '@/views/Conversation/components/UploadFileListM
import type { UploadFileListModalRef } from '@/views/Conversation/types' import type { UploadFileListModalRef } from '@/views/Conversation/types'
import { getFileInfoByUrl } from '@/api/fileStorage' import { getFileInfoByUrl } from '@/api/fileStorage'
import { transform_file_type } from '@/views/Conversation/components/FileUpload' import { transform_file_type } from '@/views/Conversation/components/FileUpload'
import RadioGroupBtn from '../Properties/RadioGroupBtn';
import CodeMirrorEditor from '@/components/CodeMirrorEditor';
import FileList from '@/components/Chat/FileList'
const FormItem = Form.Item; const FormItem = Form.Item;
const object_placeholder = `# example
# {
# "name": "redbear",
# "age": 2
# }`
const array_object_placeholder = `# example
# [
# {
# "name": "redbear",
# "age": 2
# },
# {
# "name": "redbear",
# "age": 2
# }
# ]
`
interface ChatVariableModalProps { interface ChatVariableModalProps {
refresh: (value: ChatVariable, editIndex?: number) => void; refresh: (value: ChatVariable, editIndex?: number) => void;
} }
@@ -30,7 +52,7 @@ const types = [
'number', 'number',
'boolean', 'boolean',
'object', 'object',
'file', // 'file',
'array[file]', 'array[file]',
'array[string]', 'array[string]',
'array[number]', 'array[number]',
@@ -51,37 +73,32 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const [editIndex, setEditIndex] = useState<number | undefined>(undefined); const [editIndex, setEditIndex] = useState<number | undefined>(undefined);
const type = Form.useWatch('type', form); const type = Form.useWatch('type', form);
const max_size = 50;
const allowed_transfer_methods = Form.useWatch('allowed_transfer_methods', form); const allowed_transfer_methods = Form.useWatch('allowed_transfer_methods', form);
const image_enabled = Form.useWatch('image_enabled', form); const image_enabled = Form.useWatch('image_enabled', form);
const audio_enabled = Form.useWatch('audio_enabled', form); const audio_enabled = Form.useWatch('audio_enabled', form);
const document_enabled = Form.useWatch('document_enabled', form); const document_enabled = Form.useWatch('document_enabled', form);
const video_enabled = Form.useWatch('video_enabled', form); const video_enabled = Form.useWatch('video_enabled', form);
const image_max_size_mb = Form.useWatch('image_max_size_mb', form);
const audio_max_size_mb = Form.useWatch('audio_max_size_mb', form);
const document_max_size_mb = Form.useWatch('document_max_size_mb', form);
const video_max_size_mb = Form.useWatch('video_max_size_mb', form);
const image_allowed_extensions = Form.useWatch('image_allowed_extensions', form); const image_allowed_extensions = Form.useWatch('image_allowed_extensions', form);
const audio_allowed_extensions = Form.useWatch('audio_allowed_extensions', form); const audio_allowed_extensions = Form.useWatch('audio_allowed_extensions', form);
const document_allowed_extensions = Form.useWatch('document_allowed_extensions', form); const document_allowed_extensions = Form.useWatch('document_allowed_extensions', form);
const video_allowed_extensions = Form.useWatch('video_allowed_extensions', form); const video_allowed_extensions = Form.useWatch('video_allowed_extensions', form);
const max_file_count = Form.useWatch('max_file_count', form); const max_file_count = Form.useWatch('max_file_count', form);
const hasEnabledFileType = !!(image_enabled || audio_enabled || document_enabled || video_enabled);
const featureConfig = useMemo(() => ({ const featureConfig = useMemo(() => ({
enabled: hasEnabledFileType, enabled: true,
allowed_transfer_methods, allowed_transfer_methods,
max_file_count, max_file_count,
image_enabled, image_max_size_mb, image_allowed_extensions, image_enabled, image_max_size_mb: max_size, image_allowed_extensions,
audio_enabled, audio_max_size_mb, audio_allowed_extensions, audio_enabled, audio_max_size_mb: max_size, audio_allowed_extensions,
document_enabled, document_max_size_mb, document_allowed_extensions, document_enabled, document_max_size_mb: max_size, document_allowed_extensions,
video_enabled, video_max_size_mb, video_allowed_extensions, video_enabled, video_max_size_mb: max_size, video_allowed_extensions,
}), [ }), [
hasEnabledFileType, allowed_transfer_methods, max_file_count, allowed_transfer_methods, max_file_count,
image_enabled, image_max_size_mb, image_allowed_extensions, image_enabled, image_allowed_extensions,
audio_enabled, audio_max_size_mb, audio_allowed_extensions, audio_enabled, audio_allowed_extensions,
document_enabled, document_max_size_mb, document_allowed_extensions, document_enabled, document_allowed_extensions,
video_enabled, video_max_size_mb, video_allowed_extensions, video_enabled, video_allowed_extensions, max_size
]); ]);
const handleClose = () => { const handleClose = () => {
@@ -104,6 +121,8 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const list = Array.isArray(defaultVal) ? defaultVal : [defaultVal]; const list = Array.isArray(defaultVal) ? defaultVal : [defaultVal];
setFileList(list); setFileList(list);
} }
} else if (variable.type.includes('object') && variable.defaultValue) {
form.setFieldValue('defaultValue', JSON.stringify(variable.defaultValue, null, 2))
} }
} else { } else {
form.resetFields(); form.resetFields();
@@ -113,7 +132,12 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const handleSave = () => { const handleSave = () => {
form.validateFields().then((values) => { form.validateFields().then((values) => {
refresh({ ...values, default: values.defaultValue }, editIndex); const defaultValue = Array.isArray(values.defaultValue)
? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '')
: values.type.includes('object')
? JSON.parse(values.defaultValue)
: values.defaultValue;
refresh({ ...values, defaultValue }, editIndex);
handleClose(); handleClose();
}); });
}; };
@@ -233,7 +257,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
<Select <Select
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
onChange={(value) => { onChange={(value) => {
form.setFieldValue('defaultValue', undefined); form.setFieldValue('defaultValue', value === 'array[string]' ? [] : undefined);
setFileList([]); setFileList([]);
if (value === 'file' || value === 'array[file]') form.setFieldsValue(defaultFileUploadValues as any); if (value === 'file' || value === 'array[file]') form.setFieldsValue(defaultFileUploadValues as any);
}} }}
@@ -244,7 +268,8 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
/> />
</FormItem> </FormItem>
{type === 'file' || type === 'array[file]' ? ( {type?.includes('file')
? (
<> <>
<UploadFileListModal <UploadFileListModal
ref={uploadFileListModalRef} ref={uploadFileListModalRef}
@@ -273,67 +298,53 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
</Col> </Col>
</Row> </Row>
{previewFileList.length > 0 && ( {previewFileList.length > 0 && (
<Flex gap={8} wrap className="rb:mt-2!"> <FileList wrap="wrap" fileList={previewFileList} onDelete={handleDelete} className="rb:mt-2!" />
{previewFileList.map((file) => (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
{file.type?.includes('image') ? (
<div className={clsx('rb:inline-block rb:group rb:relative rb:rounded-lg rb:border', {
'rb:border-[#FF5D34]': file.status === 'error',
'rb:border-[#F6F6F6]': file.status !== 'error',
})}>
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover" />
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
onClick={() => handleDelete(file)}
/>
</div>
) : (
<Flex
align="center"
gap={10}
className={clsx('rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:py-2! rb:px-2.5! rb:border', {
'rb:border-[#FF5D34]': file.status === 'error',
'rb:border-[#F6F6F6]': file.status !== 'error',
})}
>
<div className={clsx(
"rb:size-5 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')) ? "rb:bg-[url('@/assets/images/file/excel.svg')]" :
file.type?.includes('csv') ? "rb:bg-[url('@/assets/images/file/csv.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('word')) ? "rb:bg-[url('@/assets/images/file/word.svg')]" : null
)} />
<div className="rb:flex-1 rb:w-32.5">
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
{file.type?.split('/').pop()} · {file.size}
</div>
</div>
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
onClick={() => handleDelete(file)}
/>
</Flex>
)}
</Spin>
))}
</Flex>
)} )}
</Form.Item> </Form.Item>
</> </>
) : ( )
: ['array[string]', 'array[number]', 'array[boolean]'].includes(type)
? (
<Form.Item label={t('workflow.config.parameter-extractor.default')}>
<Form.List name="defaultValue">
{(fields, { add, remove }) => (
<Flex vertical gap={8}>
{fields.map(({ key, name }) => (
<Flex key={key} align="center" gap={4}>
<Form.Item name={name} noStyle>
{type === 'array[number]'
? <InputNumber placeholder={t('common.enter')} className="rb:flex-1!" />
: type === 'array[boolean]'
? <RadioGroupBtn size="large" options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} className="rb:flex-1!" />
: <Input placeholder={t('common.enter')} className="rb:flex-1!" />
}
</Form.Item>
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(name)}
></div>
</Flex>
))}
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />} block>
{t('common.add')}
</Button>
</Flex>
)}
</Form.List>
</Form.Item>
)
: (
<Form.Item name="defaultValue" label={t('workflow.config.parameter-extractor.default')}> <Form.Item name="defaultValue" label={t('workflow.config.parameter-extractor.default')}>
{type === 'number' {type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} /> ? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
: type === 'boolean' : type === 'boolean'
? <Select ? <RadioGroupBtn size="large" options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} />
placeholder={t('common.pleaseSelect')} : type === 'object' || type === 'array[object]'
options={[{ value: true, label: 'true' }, { value: false, label: 'false' }]} ? <CodeMirrorEditor
/> language="json"
placeholder={type === 'object' ? object_placeholder : array_object_placeholder}
variant="outlined"
/>
: <Input placeholder={t('common.enter')} /> : <Input placeholder={t('common.enter')} />
} }
</Form.Item> </Form.Item>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:10:56 * @Date: 2026-02-06 21:10:56
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 18:01:09 * @Last Modified time: 2026-04-07 18:07:38
*/ */
/** /**
* Workflow Chat Component * Workflow Chat Component
@@ -40,6 +40,7 @@ import ChatToolbar from '@/components/Chat/ChatToolbar'
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar' import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import Runtime from './Runtime'; import Runtime from './Runtime';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
import { replaceVariables } from '@/views/ApplicationConfig/Agent';
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null; features?: FeaturesConfigForm }>(({ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null; features?: FeaturesConfigForm }>(({
appId, graphRef, features appId, graphRef, features
@@ -67,8 +68,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
const handleOpen = () => { const handleOpen = () => {
setOpen(true) setOpen(true)
if (features?.opening_statement?.statement && features?.opening_statement?.statement.trim() !== '') { if (features?.opening_statement?.enabled && features?.opening_statement?.statement && features?.opening_statement?.statement.trim() !== '') {
setChatList(prev => [...prev, { setChatList([{
role: 'assistant', role: 'assistant',
created_at: Date.now(), created_at: Date.now(),
content: features?.opening_statement?.statement, content: features?.opening_statement?.statement,
@@ -419,6 +420,26 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
handleClose handleClose
})); }));
useEffect(() => {
const opening_statement = features?.opening_statement
if (opening_statement?.enabled && opening_statement?.statement && opening_statement?.statement.trim() !== '') {
const assistantMsg: ChatItem = {
role: 'assistant',
content: replaceVariables(opening_statement.statement, variables as any),
meta_data: {
suggested_questions: opening_statement?.suggested_questions
}
}
setChatList(prev => {
if (prev[0]?.role === 'assistant') {
prev[0] = assistantMsg
}
return [...prev]
})
}
}, [chatList.length, features?.opening_statement, variables])
return ( return (
<RbDrawer <RbDrawer
title={<Flex align="center" gap={10}> title={<Flex align="center" gap={10}>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-24 17:57:08 * @Date: 2026-02-24 17:57:08
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 13:39:24 * @Last Modified time: 2026-04-07 14:05:50
*/ */
/* /*
* Runtime Component * Runtime Component
@@ -18,13 +18,15 @@
import { type FC, useState } from 'react' import { type FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import clsx from 'clsx' import clsx from 'clsx'
import { Space, Button, Collapse, Flex } from 'antd' import { App, Button, Collapse, Flex } from 'antd'
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, RightOutlined, ArrowLeftOutlined } from '@ant-design/icons' import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, RightOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import copy from 'copy-to-clipboard'
import styles from './chat.module.css' import styles from './chat.module.css'
import type { ChatItem } from '@/components/Chat/types' import type { ChatItem } from '@/components/Chat/types'
import Markdown from '@/components/Markdown' import Markdown from '@/components/Markdown'
import CodeBlock from '@/components/Markdown/CodeBlock' import CodeBlock from '@/components/Markdown/CodeBlock'
import RbAlert from '@/components/RbAlert'
/** /**
* Runtime component props * Runtime component props
@@ -36,10 +38,12 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
index index
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { message } = App.useApp()
// Stores the currently selected detail view (for nested loop/iteration exploration) // Stores the currently selected detail view (for nested loop/iteration exploration)
const [detail, setDetail] = useState<any>(null) const [detail, setDetail] = useState<any>(null)
// Tracks whether the current detail view is for a loop (true) or iteration (false) // Tracks whether the current detail view is for a loop (true) or iteration (false)
const [loop, setLoop] = useState<boolean | null>(null) const [loop, setLoop] = useState<boolean | null>(null)
const [expanded, setExpanded] = useState(false)
/** /**
* Handles navigation into nested loop/iteration details * Handles navigation into nested loop/iteration details
@@ -57,7 +61,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
* @returns Tailwind CSS class for appropriate color * @returns Tailwind CSS class for appropriate color
*/ */
const getStatus = (status?: string) => { const getStatus = (status?: string) => {
return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]' return status === 'completed' ? 'rb:text-[#369F21]!' : status === 'failed' ? 'rb:text-[#FF5D34]!' : 'rb:text-[#5B6167]!'
} }
/** /**
@@ -76,7 +80,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
return ( return (
<Space size={8} direction="vertical" className="rb:w-full!"> <Flex gap={8} vertical>
{Object.entries(groupedByCycle).map(([cycleIdx, items]: [string, any]) => { {Object.entries(groupedByCycle).map(([cycleIdx, items]: [string, any]) => {
return ( return (
<Collapse <Collapse
@@ -92,7 +96,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
/> />
) )
})} })}
</Space> </Flex>
) )
} }
@@ -103,7 +107,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
*/ */
const renderChild = (list: any) => { const renderChild = (list: any) => {
if (Array.isArray(list)) { if (Array.isArray(list)) {
return <Space size={8} direction="vertical" className="rb:w-full!"> return <Flex gap={8} vertical>
{list?.map(vo => { {list?.map(vo => {
const isLoop = vo.node_type === 'loop'; const isLoop = vo.node_type === 'loop';
// Render cycle variables for loop nodes without node_name // Render cycle variables for loop nodes without node_name
@@ -114,6 +118,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
<Button <Button
className="rb:py-0! rb:px-1! rb:text-[12px]!" className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small" size="small"
onClick={() => handleCopy(typeof vo.content === 'object' && vo.content?.input ? JSON.stringify(vo.content.input, null, 2) : '{}')}
>{t('common.copy')}</Button> >{t('common.copy')}</Button>
</div> </div>
<div className="rb:max-h-40 rb:overflow-auto"> <div className="rb:max-h-40 rb:overflow-auto">
@@ -133,35 +138,44 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
return ( return (
<Collapse <Collapse
key={vo.node_id} key={vo.node_id}
bordered={false}
className="rb:bg-[#F6F6F6]"
items={[{ items={[{
key: vo.node_id, key: vo.node_id,
label: <div className={clsx("rb:flex rb:justify-between rb:items-center", getStatus(vo.status))}> label: <div className={clsx("rb:flex rb:justify-between rb:items-center")}>
<div className="rb:flex rb:items-center rb:gap-1 rb:flex-1"> <Flex gap={6} align="center" className="rb:flex-1!">
{vo.icon && <div className={`rb:size-4 rb:bg-cover ${vo.icon}`} />} {vo.icon && <div className={`rb:size-6 rb:bg-cover ${vo.icon}`} />}
<div className="rb:wrap-break-word rb:line-clamp-1">{vo.node_name}</div> <div className="rb:wrap-break-word rb:line-clamp-1 rb:font-medium">{vo.node_name}</div>
</div> </Flex>
<span> <Flex align="center" gap={8} className="rb:text-[12px]">
{typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms</>} {typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms</>}
{vo.status === 'completed' ? <CheckCircleFilled className="rb:ml-1" /> : vo.status === 'failed' ? <CloseCircleFilled className="rb:ml-1" /> : <LoadingOutlined className="rb:ml-1" />} {vo.status === 'completed'
</span> ? <CheckCircleFilled className={`rb:mr-1 ${getStatus(vo.status)}`} />
: vo.status === 'failed'
? <CloseCircleFilled className={`rb:mr-1 ${getStatus(vo.status)}`} />
: <LoadingOutlined className={`rb:mr-1 ${getStatus(vo.status)}`} />
}
</Flex>
</div>, </div>,
className: styles.collapseItem, className: styles.collapseItem,
children: ( children: (
<Space size={8} direction="vertical" className="rb:w-full!"> <Flex gap={8} vertical>
{/* Display error message for failed nodes */} {/* Display error message for failed nodes */}
{vo.status === 'failed' &&
<div className={clsx("rb:bg-[#F0F3F8] rb:rounded-md", getStatus(vo.status))}> {item.error &&
<div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]"> <RbAlert color="orange" className="rb:pb-0!">
{t(`workflow.error`)} <Flex vertical className="rb:w-full!">
<Button <Flex align="center" justify="space-between">
className="rb:py-0! rb:px-1! rb:text-[12px]!" {t(`workflow.error`)}
size="small" <Button
>{t('common.copy')}</Button> className="rb:py-0! rb:px-1! rb:text-[12px]!"
</div> size="small"
<div className="rb:pb-2 rb:px-3 rb:max-h-40 rb:overflow-auto"> onClick={() => handleCopy(vo.content?.error || '')}
>{t('common.copy')}</Button>
</Flex>
<Markdown content={vo.content?.error || ''} /> <Markdown content={vo.content?.error || ''} />
</div> </Flex>
</div> </RbAlert>
} }
{/* Display navigation to nested cycles if subContent exists */} {/* Display navigation to nested cycles if subContent exists */}
{vo.subContent?.length > 0 && ( {vo.subContent?.length > 0 && (
@@ -172,12 +186,13 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
)} )}
{/* Display input and output data as JSON code blocks */} {/* Display input and output data as JSON code blocks */}
{['input', 'output'].map(key => ( {['input', 'output'].map(key => (
<div key={key} className="rb:bg-[#F0F3F8] rb:rounded-md"> <div key={key} className="rb:bg-[#EBEBEB] rb:rounded-lg">
<div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]"> <div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]">
{isLoop ? t(`workflow.runtime.${key}_cycle_vars`) : t(`workflow.${key}`)} {isLoop ? t(`workflow.runtime.${key}_cycle_vars`) : t(`workflow.${key}`)}
<Button <Button
className="rb:py-0! rb:px-1! rb:text-[12px]!" className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small" size="small"
onClick={() => handleCopy(typeof vo.content === 'object' && vo.content?.[key] ? JSON.stringify(vo.content[key], null, 2) : '{}')}
>{t('common.copy')}</Button> >{t('common.copy')}</Button>
</div> </div>
<div className="rb:max-h-40 rb:overflow-auto"> <div className="rb:max-h-40 rb:overflow-auto">
@@ -186,55 +201,80 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
value={typeof vo.content === 'object' && vo.content?.[key] ? JSON.stringify(vo.content[key], null, 2) : '{}'} value={typeof vo.content === 'object' && vo.content?.[key] ? JSON.stringify(vo.content[key], null, 2) : '{}'}
needCopy={false} needCopy={false}
showLineNumbers={true} showLineNumbers={true}
background="#EBEBEB"
/> />
</div> </div>
</div> </div>
))} ))}
</Space> </Flex>
) )
}]} }]}
/> />
) )
})} })}
</Space> </Flex>
} }
return <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}> return <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}>
<Markdown content={list || ''} /> <Markdown content={list || ''} />
</div> </div>
} }
/** Copy value to clipboard and show success message */
const handleCopy = (value: string) => {
copy(value)
message.success(t('common.copySuccess'))
}
return ( return (
<div key={index} className="rb:min-w-100 rb:max-w-full rb:mb-2"> <div
<Collapse key={index}
className={styles[item.status || 'default']} className={clsx("rb:mb-4 rb-border rb:rounded-xl rb:px-4 rb:pt-3 rb:bg-white rb:max-w-full", {
items={[{ 'rb:hover:bg-[#F6F6F6] rb:w-64': !expanded
key: 0, })}
label: <div className={getStatus(item.status)}> >
{item.status === 'completed' ? <CheckCircleFilled className="rb:mr-1" /> : item.status === 'failed' ? <CloseCircleFilled className="rb:mr-1" /> : <LoadingOutlined className="rb:mr-1" />} <Flex align="center" justify="space-between" className="rb:font-medium rb:pb-3!">
{t('application.workflow')} <span className="rb:font-medium rb:leading-5">
</div>, {item.status === 'completed'
className: styles.collapseItem, ? <CheckCircleFilled className={`rb:mr-1 ${getStatus(item.status)}`} />
children: ( : item.status === 'failed'
detail ? <CloseCircleFilled className={`rb:mr-1 ${getStatus(item.status)}`} />
? ( : <LoadingOutlined className={`rb:mr-1 ${getStatus(item.status)}`} />
<div className="rb:bg-[#FBFDFF] rb:rounded-md"> }
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => setDetail(null)} className="rb:px-0! rb:text-[12px]!"> {t('application.workflow')}
{t('common.return')} </span>
</Button> <Flex
{renderDetailChild(detail.subContent)} align="center"
</div> justify="center"
) className={clsx("rb:size-6.5 rb:cursor-pointer rb-border rb:rounded-lg", {
: <> 'rb:hover:bg-[#F6F6F6]!': expanded
{item.error && })}
<div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 rb:mb-2 rb:-mt-4", getStatus('failed'))}> onClick={() => { setExpanded(v => !v); setDetail(null) }}
<Markdown content={item.error} /> >
</div> <div
} className={clsx("rb:size-4 rb:bg-cover", {
{renderChild(item.subContent)} 'rb:bg-[url("@/assets/images/conversation/compress.svg")]': expanded,
</> 'rb:bg-[url("@/assets/images/conversation/expand.svg")]': !expanded
})}
/>
</Flex>
</Flex>
{expanded && (
detail
? (
<div className="rb:bg-[#FBFDFF] rb:rounded-md rb:mb-4">
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => setDetail(null)} className="rb:px-0! rb:text-[12px]!">
{t('common.return')}
</Button>
{renderDetailChild(detail.subContent)}
</div>
) )
}]} : <div className="rb:mb-4">
/> {item.error &&
<RbAlert color="orange" className="rb:pb-0! rb:mb-2!"><Markdown content={item.error} /></RbAlert>
}
{renderChild(item.subContent)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -21,7 +21,9 @@
line-height: 16px; line-height: 16px;
} }
.collapse-item:global(.ant-collapse-item>.ant-collapse-header) { .collapse-item:global(.ant-collapse-item>.ant-collapse-header) {
padding: 8px 12px; padding: 8px 16px 8px 10px;
display: flex;
align-items: center;
} }
.collapse-item:global(.ant-collapse-item>.ant-collapse-header .ant-collapse-expand-icon) { .collapse-item:global(.ant-collapse-item>.ant-collapse-header .ant-collapse-expand-icon) {
height: 16px; height: 16px;
@@ -46,3 +48,6 @@
.collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { .collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) {
padding: 0 4px 4px 4px; padding: 0 4px 4px 4px;
} }
:global(.ant-collapse-borderless)>.collapse-item:global(.ant-collapse-item>.ant-collapse-content>.ant-collapse-content-box) {
padding: 0 12px 12px 12px;
}

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-04-02 15:15:36 * @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 15:15:36 * @Last Modified time: 2026-04-07 14:48:00
*/ */
import { type FC, useEffect, useMemo } from 'react'; import { type FC, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -12,7 +12,7 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { type Suggestion } from './plugin/AutocompletePlugin'; import { type Suggestion } from './plugin/AutocompletePlugin';
import CharacterCountPlugin from './plugin/CharacterCountPlugin'; import Jinjia2CharacterCountPlugin from './plugin/Jinjia2CharacterCountPlugin';
import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin'; import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin';
import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin'; import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin'; import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
@@ -89,7 +89,7 @@ export interface Jinja2EditorProps {
const Jinja2Editor: FC<Jinja2EditorProps> = ({ const Jinja2Editor: FC<Jinja2EditorProps> = ({
placeholder = '请输入内容...', placeholder = '请输入内容...',
value = '', value,
onChange, onChange,
options = [], options = [],
variant = 'borderless', variant = 'borderless',
@@ -174,8 +174,8 @@ const Jinja2Editor: FC<Jinja2EditorProps> = ({
<Jinja2HighlightPlugin /> <Jinja2HighlightPlugin />
<LineNumberPlugin /> <LineNumberPlugin />
<Jinja2AutocompletePlugin options={options} /> <Jinja2AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={() => {}} onChange={onChange} waitForInit /> <Jinjia2CharacterCountPlugin setCount={() => {}} />
<Jinja2InitialValuePlugin value={value} /> <Jinja2InitialValuePlugin value={value} onChange={onChange} />
<Jinja2BlurPlugin /> <Jinja2BlurPlugin />
</div> </div>
</LexicalComposer> </LexicalComposer>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51 * @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-03 20:44:16 * @Last Modified time: 2026-04-07 16:29:36
*/ */
import { type FC, useState, useMemo } from 'react'; import { type FC, useState, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -57,7 +57,6 @@ const Editor: FC<LexicalEditorProps> =({
language = 'string', language = 'string',
height, height,
className, className,
waitForInit = false,
}) => { }) => {
console.log('Editor value', value) console.log('Editor value', value)
const [_count, setCount] = useState(0); const [_count, setCount] = useState(0);
@@ -149,10 +148,10 @@ const Editor: FC<LexicalEditorProps> =({
/> />
<HistoryPlugin /> <HistoryPlugin />
<CommandPlugin /> <CommandPlugin />
<AutocompletePlugin options={options} enableJinja2={false} /> <AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={setCount} onChange={onChange} /> <CharacterCountPlugin setCount={setCount} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={false} /> <InitialValuePlugin value={value} options={options} />
<BlurPlugin enableJinja2={false} /> <BlurPlugin />
</div> </div>
</LexicalComposer> </LexicalComposer>
); );

View File

@@ -33,6 +33,18 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
setSelected(!isSelected); setSelected(!isSelected);
}; };
if (!data.nodeData?.name) {
return (
<span
onClick={handleClick}
className="rb:inline rb:cursor-pointer rb:text-[#171719]"
contentEditable={false}
>
{data.value}
</span>
);
}
return ( return (
<span <span
onClick={handleClick} onClick={handleClick}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51 * @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:12:41 * @Last Modified time: 2026-04-07 16:51:04
*/ */
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
@@ -168,7 +168,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
// Group suggestions by node ID // Group suggestions by node ID
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, suggestion) => { const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, suggestion) => {
const { nodeData } = suggestion const { nodeData } = suggestion
const nodeId = nodeData.id as string; const nodeId = nodeData?.id as string;
if (!groups[nodeId]) { if (!groups[nodeId]) {
groups[nodeId] = []; groups[nodeId] = [];
} }
@@ -291,67 +291,67 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
}} }}
> >
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto"> <div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
<Flex vertical gap={12}> <Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
const nodeIcon = nodeOptions[0]?.nodeData?.icon; const nodeIcon = nodeOptions[0]?.nodeData?.icon;
return ( return (
<div key={nodeId}> <div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]"> {nodeName !== 'undefined' && <Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
{nodeIcon && <div className={`rb:size-3 rb:bg-cover ${nodeIcon}`} />} {nodeIcon && <div className={`rb:size-3 rb:bg-cover ${nodeIcon}`} />}
{nodeName} {nodeName}
</Flex> </Flex>}
{nodeOptions.map((option) => { {nodeOptions.map((option) => {
const globalIndex = flatOptions.indexOf(option); const globalIndex = flatOptions.indexOf(option);
const isExpanded = expandedParent?.key === option.key; const isExpanded = expandedParent?.key === option.key;
const hasChildren = !!option.children?.length; const hasChildren = !!option.children?.length;
return ( return (
<Flex <Flex
key={option.key} key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex} data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!" className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center" align="center"
justify="space-between" justify="space-between"
style={{ style={{
cursor: option.disabled ? 'not-allowed' : 'pointer', cursor: option.disabled ? 'not-allowed' : 'pointer',
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white', background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1, opacity: option.disabled ? 0.5 : 1,
}} }}
onClick={() => { onClick={() => {
if (option.disabled) return; if (option.disabled) return;
insertMention(option); insertMention(option);
}} }}
onMouseEnter={() => { onMouseEnter={() => {
setSelectedIndex(globalIndex); setSelectedIndex(globalIndex);
if (hasChildren) { if (hasChildren) {
const el = itemRefs.current.get(option.key); const el = itemRefs.current.get(option.key);
if (el && popupRef.current) { if (el && popupRef.current) {
const elRect = el.getBoundingClientRect(); const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect(); const popupRect = popupRef.current.getBoundingClientRect();
setChildPanelTop(calcChildPanelTop(elRect, popupRect)); setChildPanelTop(calcChildPanelTop(elRect, popupRect));
}
setExpandedParent(option);
} else {
setExpandedParent(null);
} }
setExpandedParent(option); }}
} else { >
setExpandedParent(null); {option.label && <Space size={4}>
} <span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span>
}} <span>{option.label}</span>
> </Space>}
<Space size={4}> <Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span> {option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
<span>{option.label}</span> {hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
</Space> </Space>
<Space size={4}> </Flex>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>} );
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>} })}
</Space> </div>
</Flex> );
); })}
})} </Flex>
</div>
);
})}
</Flex>
</div> </div>
{/* Child variables panel - floats to the left */} {/* Child variables panel - floats to the left */}
{expandedParent?.children?.length && ( {expandedParent?.children?.length && (

View File

@@ -52,7 +52,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
const root = $getRoot(); const root = $getRoot();
root.clear(); root.clear();
const parts = value.split(/(\{\{[^}]+\}\}|\n)/); const parts = (value ?? '').split(/(\{\{[^}]+\}\}|\n)/);
let paragraph = $createParagraphNode(); let paragraph = $createParagraphNode();
parts.forEach(part => { parts.forEach(part => {
@@ -100,15 +100,29 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
} }
if (match) { if (match) {
const [_, nodeId, label] = match; const [_, nodeId, rest] = match;
const restParts = rest.split('.');
const isThreeLevel = restParts.length >= 2;
const parentLabel = isThreeLevel ? restParts.slice(0, -1).join('.') : undefined;
const label = restParts[restParts.length - 1];
const suggestion = optionsRef.current.find(s => { let suggestion = optionsRef.current.find(s => {
if (nodeId === 'sys') { if (nodeId === 'sys') {
return s.nodeData.type === 'start' && s.label === `sys.${label}` return s.nodeData.type === 'start' && s.label === `sys.${rest}`
} }
return s.nodeData.id === nodeId && s.label === label return s.nodeData.id === nodeId && s.label === rest
}); });
// Search in children for three-level variables (e.g. nodeId.parentLabel.label)
if (!suggestion && isThreeLevel) {
for (const s of optionsRef.current) {
if (s.nodeData.id === nodeId && s.label === parentLabel && s.children) {
const child = s.children.find(c => c.label === label);
if (child) { suggestion = child; break; }
}
}
}
if (suggestion) { if (suggestion) {
paragraph.append($createVariableNode(suggestion)); paragraph.append($createVariableNode(suggestion));
} else { } else {

View File

@@ -1,10 +1,10 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-04-02 17:10:59 * @Date: 2026-04-02 17:10:59
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:10:59 * @Last Modified time: 2026-04-07 14:50:14
*/ */
import { useEffect, useState, useRef, type FC } from 'react'; import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { import {
$getSelection, $isRangeSelection, $isTextNode, $getSelection, $isRangeSelection, $isTextNode,
@@ -20,8 +20,35 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [childPanelTop, setChildPanelTop] = useState(0);
const popupRef = useRef<HTMLDivElement>(null); const popupRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const CHILD_PANEL_HEIGHT = 280;
useLayoutEffect(() => {
if (!popupRef.current || !showSuggestions) return;
const { top, anchorBottom } = popupPosition;
const popupHeight = popupRef.current.offsetHeight;
const MARGIN = 10;
let finalTop: number;
if (top - popupHeight - MARGIN >= 0) {
finalTop = top - popupHeight - MARGIN;
} else {
finalTop = anchorBottom + MARGIN;
if (finalTop + popupHeight > window.innerHeight - MARGIN)
finalTop = window.innerHeight - popupHeight - MARGIN;
}
if (finalTop !== top) setPopupPosition(prev => ({ ...prev, top: finalTop }));
}, [showSuggestions, popupPosition.anchorBottom]);
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => {
const relativeTop = elRect.top - popupRect.top;
const overflow = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT - (window.innerHeight - 10);
return overflow > 0 ? relativeTop - overflow : relativeTop;
};
const scrollSelectedIntoView = () => { const scrollSelectedIntoView = () => {
if (!popupRef.current) return; if (!popupRef.current) return;
@@ -51,19 +78,16 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset); const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/'); const shouldShow = textBeforeCursor.endsWith('/');
setShowSuggestions(shouldShow); setShowSuggestions(shouldShow);
if (!shouldShow) { setSelectedIndex(0); return; } if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); setChildPanelTop(0); return; }
const domSelection = window.getSelection(); const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) { if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect(); const rect = domSelection.getRangeAt(0).getBoundingClientRect();
const popupWidth = 280, popupHeight = 200; const popupWidth = 280;
const vw = window.innerWidth, vh = window.innerHeight; let left = rect.left;
let left = Math.min(Math.max(rect.left, 10), vw - popupWidth - 10); if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10;
let top = rect.top - 10; if (left < 10) left = 10;
if (top - popupHeight < 10) { setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
top = Math.min(rect.bottom + 10, vh - popupHeight - 10);
}
setPopupPosition({ top, left });
} }
}); });
}); });
@@ -72,7 +96,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
useEffect(() => { useEffect(() => {
return editor.registerCommand( return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND,
() => { setShowSuggestions(false); return true; }, () => { setShowSuggestions(false); setExpandedParent(null); setChildPanelTop(0); return true; },
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
); );
}, [editor]); }, [editor]);
@@ -94,7 +118,10 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
selection.focus.offset = newOffset; selection.focus.offset = newOffset;
} }
}); });
document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } }));
setShowSuggestions(false); setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
}; };
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => { const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
@@ -104,7 +131,9 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
return groups; return groups;
}, {}); }, {});
const allOptions = Object.values(groupedSuggestions).flat(); const allOptions = Object.values(groupedSuggestions).flat().flatMap(o =>
o.key === expandedParent?.key && o.children?.length ? [o, ...o.children] : [o]
);
useEffect(() => { useEffect(() => {
if (!showSuggestions) return; if (!showSuggestions) return;
@@ -154,44 +183,99 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
ref={popupRef} ref={popupRef}
data-autocomplete-popup="true" data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{ top: popupPosition.top, left: popupPosition.left }} style={{ top: popupPosition.top, left: popupPosition.left }}
> >
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
<Flex vertical gap={12}> <Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => ( {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
<div key={nodeId}> <div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]"> <Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
{nodeOptions[0]?.nodeData?.icon && <img src={nodeOptions[0].nodeData.icon} className="rb:size-3" alt="" />} {nodeOptions[0]?.nodeData?.icon && <div className={`rb:size-3 rb:bg-cover ${nodeOptions[0].nodeData.icon}`} />}
{nodeOptions[0]?.nodeData?.name || nodeId} {nodeOptions[0]?.nodeData?.name || nodeId}
</Flex> </Flex>
{nodeOptions.map((option) => { {nodeOptions.map((option) => {
const globalIndex = allOptions.indexOf(option); const globalIndex = allOptions.indexOf(option);
const hasChildren = !!option.children?.length;
const isExpanded = expandedParent?.key === option.key;
return ( return (
<Flex <Flex
key={option.key} key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex} data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!" className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center" align="center"
justify="space-between" justify="space-between"
style={{ style={{
cursor: option.disabled ? 'not-allowed' : 'pointer', cursor: option.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white', background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1, opacity: option.disabled ? 0.5 : 1,
}} }}
onClick={() => !option.disabled && insertMention(option)} onClick={() => { if (option.disabled || hasChildren) return; insertMention(option); }}
onMouseEnter={() => setSelectedIndex(globalIndex)} onMouseEnter={() => {
setSelectedIndex(globalIndex);
if (hasChildren) {
const el = itemRefs.current.get(option.key);
if (el && popupRef.current) {
setChildPanelTop(calcChildPanelTop(el.getBoundingClientRect(), popupRef.current.getBoundingClientRect()));
}
setExpandedParent(option);
} else {
setExpandedParent(null);
}
}}
> >
<Space size={4}> <Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span> <span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
<span>{option.label}</span> <span>{option.label}</span>
</Space> </Space>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>} <Space size={4}>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
</Space>
</Flex> </Flex>
); );
})} })}
</div> </div>
))} ))}
</Flex> </Flex>
</div>
{expandedParent?.children?.length && (
<div
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{ top: childPanelTop, right: 'calc(100% + 8px)', transform: 'translateY(-8px)' }}
onMouseEnter={() => setExpandedParent(expandedParent)}
>
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]">
<Flex justify="space-between" align="center">
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span>
</Flex>
</div>
{expandedParent.children.map((child) => {
const childIndex = allOptions.indexOf(child);
return (
<Flex
key={child.key}
data-selected={selectedIndex === childIndex}
className="rb:px-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)}
>
<span>{child.label}</span>
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
</Flex>
);
})}
</div>
)}
</div> </div>
); );
}; };

View File

@@ -6,53 +6,50 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical'; import { $getRoot, $createParagraphNode, $createTextNode, $isParagraphNode } from 'lexical';
interface Jinja2InitialValuePluginProps { interface Jinja2InitialValuePluginProps {
value: string; value?: string;
onChange?: (value: string) => void;
} }
const Jinja2InitialValuePlugin: React.FC<Jinja2InitialValuePluginProps> = ({ value }) => { const Jinja2InitialValuePlugin: React.FC<Jinja2InitialValuePluginProps> = ({ value, onChange }) => {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const prevValueRef = useRef<string>(''); const internalValueRef = useRef<string | undefined>(undefined);
const isUserInputRef = useRef(false); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({ editorState, tags }) => { return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return; if (tags.has('programmatic')) return;
if (internalValueRef.current === undefined) return;
editorState.read(() => { editorState.read(() => {
const textContent = $getRoot().getTextContent(); const paragraphs = $getRoot().getChildren()
if (textContent !== prevValueRef.current) { .filter($isParagraphNode)
isUserInputRef.current = true; .map(p => p.getChildren().map(n => n.getTextContent()).join(''));
prevValueRef.current = textContent; const text = paragraphs.join('\n');
if (text !== internalValueRef.current) {
internalValueRef.current = text;
onChangeRef.current?.(text);
} }
}); });
}); });
}, [editor]); }, [editor]);
useEffect(() => { useEffect(() => {
if (value === prevValueRef.current) return; if (value === undefined) return;
if (value === internalValueRef.current) return;
if (isUserInputRef.current) { internalValueRef.current = value;
prevValueRef.current = value; editor.update(() => {
isUserInputRef.current = false; const root = $getRoot();
return; root.clear();
} value.split('\n').forEach((line) => {
const paragraph = $createParagraphNode();
prevValueRef.current = value; paragraph.append($createTextNode(line));
isUserInputRef.current = false; root.append(paragraph);
});
queueMicrotask(() => { }, { tag: 'programmatic' });
editor.update(() => {
const root = $getRoot();
root.clear();
value.split('\n').forEach((line) => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(line));
root.append(paragraph);
});
}, { tag: 'programmatic' });
});
}, [value, editor]); }, [value, editor]);
return null; return null;

View File

@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { $getRoot, $isParagraphNode } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
const Jinjia2CharacterCountPlugin = ({ setCount }: { setCount: (count: number) => void }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
const paragraphs = root.getChildren()
.filter($isParagraphNode)
.map(p => p.getChildren().map(n => n.getTextContent()).join(''));
setCount(paragraphs.join('\n').length);
});
});
}, [editor, setCount]);
return null;
}
export default Jinjia2CharacterCountPlugin

View File

@@ -80,8 +80,8 @@ const AssignmentList: FC<AssignmentListProps> = ({
form.setFieldValue([parentName, name, 'value'], undefined); form.setFieldValue([parentName, name, 'value'], undefined);
}} }}
size={size} size={size}
className="rb:w-39! rb:bg-[#F6F6F6]!" className="rb:flex-1!"
variant="borderless" variant="filled"
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -121,8 +121,8 @@ const AssignmentList: FC<AssignmentListProps> = ({
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options} options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
size={size} size={size}
variant="borderless" className="rb:flex-1!"
className="select" variant="filled"
/> />
: dataType === 'number' : dataType === 'number'
? <InputNumber ? <InputNumber
@@ -152,8 +152,8 @@ const AssignmentList: FC<AssignmentListProps> = ({
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options} options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
size={size} size={size}
variant="borderless" className="rb:flex-1!"
className="select" variant="filled"
/> />
} }
</Form.Item> </Form.Item>

View File

@@ -281,7 +281,7 @@ const CaseList: FC<CaseListProps> = ({
options={options} options={options}
size="small" size="small"
allowClear={false} allowClear={false}
onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val)} onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val as string)}
variant="borderless" variant="borderless"
className="rb:w-36!" className="rb:w-36!"
/> />

View File

@@ -34,15 +34,23 @@ const JinjaRender: FC<JinjaRenderProps> = ({ selectedNode, options, templateOpti
const prevMappingNamesRef = useRef<string[]>([]) const prevMappingNamesRef = useRef<string[]>([])
const prevTemplateVarsRef = useRef<string[]>([]) const prevTemplateVarsRef = useRef<string[]>([])
const isSyncingRef = useRef(false) const isSyncingRef = useRef(false)
const lastSyncSourceRef = useRef<'mapping' | 'template' | null>(null)
const editorKeyRef = useRef(0) const editorKeyRef = useRef(0)
const insertedVarsRef = useRef<Set<string>>(new Set())
// Collect variables inserted via autocomplete
useEffect(() => {
const handler = (e: Event) => {
insertedVarsRef.current.add((e as CustomEvent).detail.value)
}
document.addEventListener('jinja2-variable-inserted', handler)
return () => document.removeEventListener('jinja2-variable-inserted', handler)
}, [])
// Reset refs when node changes // Reset refs when node changes
useEffect(() => { useEffect(() => {
if (selectedNode?.getData()?.id) { if (selectedNode?.getData()?.id) {
prevMappingNamesRef.current = [] prevMappingNamesRef.current = []
prevTemplateVarsRef.current = [] prevTemplateVarsRef.current = []
lastSyncSourceRef.current = null
} }
}, [selectedNode?.getData()?.id]) }, [selectedNode?.getData()?.id])
@@ -50,7 +58,6 @@ const JinjaRender: FC<JinjaRenderProps> = ({ selectedNode, options, templateOpti
useEffect(() => { useEffect(() => {
if ( if (
isSyncingRef.current || isSyncingRef.current ||
lastSyncSourceRef.current === 'mapping' ||
selectedNode?.data?.type !== 'jinja-render' || selectedNode?.data?.type !== 'jinja-render' ||
!values?.mapping || !values?.mapping ||
!values?.template !values?.template
@@ -81,95 +88,63 @@ const JinjaRender: FC<JinjaRenderProps> = ({ selectedNode, options, templateOpti
if (updatedTemplate !== form.getFieldValue('template')) { if (updatedTemplate !== form.getFieldValue('template')) {
isSyncingRef.current = true isSyncingRef.current = true
lastSyncSourceRef.current = 'mapping'
prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate) prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate)
prevMappingNamesRef.current = currentMappingNames prevMappingNamesRef.current = currentMappingNames
form.setFieldValue('template', updatedTemplate) form.setFieldValue('template', updatedTemplate)
editorKeyRef.current++ editorKeyRef.current++
setTimeout(() => { setTimeout(() => { isSyncingRef.current = false }, 0)
isSyncingRef.current = false
lastSyncSourceRef.current = null
}, 0)
} else { } else {
prevMappingNamesRef.current = currentMappingNames prevMappingNamesRef.current = currentMappingNames
} }
}, [values?.mapping, selectedNode?.data?.type, form]) }, [values?.mapping, selectedNode?.data?.type, form])
// Sync mapping when template variables change // Track template vars; add mapping only for autocomplete-inserted variables
useEffect(() => { useEffect(() => {
if ( if (isSyncingRef.current || selectedNode?.data?.type !== 'jinja-render' || !values?.template) return
isSyncingRef.current ||
lastSyncSourceRef.current === 'template' ||
selectedNode?.data?.type !== 'jinja-render' ||
!values?.template ||
!values?.mapping
) return
const templateVars = extractTemplateVars(String(values.template)) const templateVars = extractTemplateVars(String(values.template))
if (JSON.stringify(prevTemplateVarsRef.current) === JSON.stringify(templateVars)) return const prevVars = prevTemplateVarsRef.current
const isTemplateEditor = document.activeElement?.closest('[data-editor-type="template"]') if (JSON.stringify(prevVars) === JSON.stringify(templateVars)) return
if (!isTemplateEditor) {
prevTemplateVarsRef.current = templateVars const newVars = templateVars.filter(v => !prevVars.includes(v))
return const insertedNew = newVars.filter(v => insertedVarsRef.current.has(v))
} insertedVarsRef.current.clear()
prevTemplateVarsRef.current = templateVars
if (insertedNew.length === 0 || !values?.mapping) return
const updatedMapping: MappingItem[] = Array.isArray(values.mapping) const updatedMapping: MappingItem[] = Array.isArray(values.mapping)
? [...values.mapping.filter((item: MappingItem) => item)] ? [...values.mapping.filter((item: MappingItem) => item)]
: [] : []
const existingNames = getMappingNames(updatedMapping)
let updatedTemplate = String(values.template) let updatedTemplate = String(values.template)
// Update existing mapping names based on position insertedNew.forEach(varName => {
if (prevTemplateVarsRef.current.length > 0) { const alreadyExists = updatedMapping.some(item => item.value === `{{${varName}}}`)
prevTemplateVarsRef.current.forEach((oldVar, index) => { const baseName = varName.includes('.') ? varName.split('.').pop()! : varName
const newVar = templateVars[index] const regex = new RegExp(`{{\\s*${varName.replace(/\./, '\\.')}\\s*}}`, 'g')
if (newVar && oldVar !== newVar && updatedMapping[index]) { if (alreadyExists) {
updatedMapping[index] = { ...updatedMapping[index], name: newVar } const existing = updatedMapping.find(item => item.value === `{{${varName}}}`)!
} updatedTemplate = updatedTemplate.replace(regex, `{{${existing.name}}}`)
}) return
}
// Add new mappings and normalize template
templateVars.forEach(varName => {
const existingMapping = updatedMapping.find(item => item.value === `{{${varName}}}`)
const regex = new RegExp(`{{\\s*${varName.replace(/\./g, '\\.')}\\s*}}`, 'g')
if (existingMapping) {
updatedTemplate = updatedTemplate.replace(regex, `{{${existingMapping.name}}}`)
} else if (!existingNames.includes(varName)) {
const mappingName = varName.includes('.') ? varName.split('.').pop() || varName : varName
updatedMapping.push({ name: mappingName, value: `{{${varName}}}` })
updatedTemplate = updatedTemplate.replace(regex, `{{${mappingName}}}`)
} }
}) const usedNames = getMappingNames(updatedMapping)
let mappingName = baseName
// Remove duplicates only let counter = 1
const seenNames = new Set<string>() while (usedNames.includes(mappingName)) mappingName = `${baseName}_${counter++}`
const finalMapping = updatedMapping.filter(item => { updatedMapping.push({ name: mappingName, value: `{{${varName}}}` })
if (!item.name || seenNames.has(item.name)) return false updatedTemplate = updatedTemplate.replace(regex, `{{${mappingName}}}`)
seenNames.add(item.name)
return true
}) })
isSyncingRef.current = true isSyncingRef.current = true
lastSyncSourceRef.current = 'template' prevMappingNamesRef.current = getMappingNames(updatedMapping)
prevMappingNamesRef.current = getMappingNames(finalMapping) prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate)
prevTemplateVarsRef.current = templateVars form.setFieldValue('mapping', updatedMapping)
form.setFieldValue('template', updatedTemplate)
if (JSON.stringify(finalMapping) !== JSON.stringify(values.mapping)) { editorKeyRef.current++
form.setFieldValue('mapping', finalMapping) setTimeout(() => { isSyncingRef.current = false }, 0)
}
if (updatedTemplate !== String(values.template)) {
form.setFieldValue('template', updatedTemplate)
}
setTimeout(() => {
isSyncingRef.current = false
lastSyncSourceRef.current = null
}, 50)
}, [values?.template, selectedNode?.data?.type, form]) }, [values?.template, selectedNode?.data?.type, form])
return ( return (

View File

@@ -12,7 +12,9 @@ const FormItem = Form.Item;
interface KnowledgeConfigModalProps { interface KnowledgeConfigModalProps {
refresh: (values: KnowledgeConfigForm, type: 'knowledgeConfig') => void; refresh: (values: KnowledgeConfigForm, type: 'knowledgeConfig') => void;
} }
const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid'] const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid',
// 'graph'
]
const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfigModalProps>(({ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfigModalProps>(({
refresh, refresh,

View File

@@ -5,7 +5,7 @@ export interface RerankerConfig {
reranker_id?: string | undefined; reranker_id?: string | undefined;
reranker_top_k?: number | undefined; reranker_top_k?: number | undefined;
} }
export type RetrieveType = 'participle' | 'semantic' | 'hybrid' export type RetrieveType = 'participle' | 'semantic' | 'hybrid' | 'graph'
export interface KnowledgeConfigForm { export interface KnowledgeConfigForm {
kb_id?: string; kb_id?: string;
similarity_threshold?: number; similarity_threshold?: number;

View File

@@ -91,7 +91,8 @@ const FilterConditions: FC<FilterConditionsProps> = ({
const keyFieldValue = currentCondition.key; const keyFieldValue = currentCondition.key;
const keyFieldOption = fileSubVariable.find(option => option.filed === keyFieldValue); const keyFieldOption = fileSubVariable.find(option => option.filed === keyFieldValue);
const keyFieldType = keyFieldOption?.dataType; const keyFieldType = keyFieldOption?.dataType;
const operatorList = operatorsObj[keyFieldValue === 'type' ? 'type' : keyFieldType || 'default'] || operatorsObj.default || []; const innerType = variableType?.match(/^array\[(.+)\]$/)?.[1];
const operatorList = operatorsObj[innerType !== 'file' ? (innerType || 'default') : keyFieldValue === 'type' ? 'type' : keyFieldType || 'default'] || operatorsObj.default || [];
return ( return (
<Flex <Flex
@@ -111,7 +112,7 @@ const FilterConditions: FC<FilterConditionsProps> = ({
fieldNames={{ value: 'filed', label: 'label' }} fieldNames={{ value: 'filed', label: 'label' }}
onChange={(value) => handleKeyFieldChange(index, value)} onChange={(value) => handleKeyFieldChange(index, value)}
variant="borderless" variant="borderless"
className="rb:w-full!" className="rb:w-full! rb:h-7!"
/> />
</Form.Item> </Form.Item>
</Col> </Col>
@@ -129,15 +130,15 @@ const FilterConditions: FC<FilterConditionsProps> = ({
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
variant="borderless" variant="borderless"
className="rb:w-full!" className="rb:w-full! rb:h-7!"
/> />
</Form.Item> </Form.Item>
</Col> </Col>
{!hideValueField && ( {!hideValueField && (
<Col flex="1"> <Col flex="1">
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!"> <Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!">
{variableType?.includes('boolean') {innerType === 'boolean'
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} /> ? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: keyFieldValue === 'type' : keyFieldValue === 'type'
? <Select ? <Select
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}

View File

@@ -23,6 +23,29 @@ const ListOperator: FC<ListOperatorProps> = ({ options }) => {
const variableOption = options.find(option => `{{${option.value}}}` === values?.input_list) const variableOption = options.find(option => `{{${option.value}}}` === values?.input_list)
const variableType = variableOption?.dataType const variableType = variableOption?.dataType
const handleChangeInputList = (value: string | string[]) => {
form.setFieldsValue({
input_list: value,
filter_by: {
enabled: false,
conditions: [{}]
},
order_by: {
enabled: false,
key: variableType === 'array[file]' ? 'name' : '',
value: 'asc'
},
extract_by: {
enabled: false,
serial: undefined
},
limit: {
enabled: false,
size: 1
}
})
}
return ( return (
<> <>
<Form.Item name="input_list" label={t('workflow.config.list-operator.variable')} required> <Form.Item name="input_list" label={t('workflow.config.list-operator.variable')} required>
@@ -30,6 +53,7 @@ const ListOperator: FC<ListOperatorProps> = ({ options }) => {
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType.includes('array') && vo.dataType !== 'array[object]')} options={options.filter(vo => vo.dataType.includes('array') && vo.dataType !== 'array[object]')}
size="small" size="small"
onChange={handleChangeInputList}
/> />
</Form.Item> </Form.Item>
@@ -58,6 +82,7 @@ const ListOperator: FC<ListOperatorProps> = ({ options }) => {
<Select <Select
options={fileSubVariable} options={fileSubVariable}
fieldNames={{ value: 'filed', label: 'label' }} fieldNames={{ value: 'filed', label: 'label' }}
placeholder={t('common.pleaseSelect')}
/> />
</Form.Item> </Form.Item>
</Col> </Col>

View File

@@ -63,6 +63,7 @@ const RadioGroupBtn: FC<RadioCardProps> = ({
allowClear = true, allowClear = true,
block = false, block = false,
type, type,
className,
}) => { }) => {
/** Listen to value changes and trigger side effects via onValueChange callback */ /** Listen to value changes and trigger side effects via onValueChange callback */
useEffect(() => { useEffect(() => {
@@ -86,7 +87,7 @@ const RadioGroupBtn: FC<RadioCardProps> = ({
} }
return ( return (
<div className={clsx(`rb:grid rb:grid-cols-${block ? 1 : options.length} rb:gap-1`)}> <div className={clsx(`rb:grid rb:grid-cols-${block ? 1 : options.length} rb:gap-1`, className)}>
{/* Render each option as a selectable card */} {/* Render each option as a selectable card */}
{options.map(option => ( {options.map(option => (
<div key={String(option.value)} className={clsx("rb:border rb:w-full rb:leading-4.5 rb:px-2.5 rb:text-center rb:text-[12px] rb:font-medium rb:cursor-pointer", { <div key={String(option.value)} className={clsx("rb:border rb:w-full rb:leading-4.5 rb:px-2.5 rb:text-center rb:text-[12px] rb:font-medium rb:cursor-pointer", {

View File

@@ -1,6 +1,6 @@
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState, useMemo } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Form, Select, InputNumber, Switch, Cascader, type CascaderProps, Tooltip } from 'antd' import { Form, Select, Switch, Cascader, type CascaderProps, Tooltip } from 'antd'
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import { getToolMethods, getToolDetail, getTools } from '@/api/tools' import { getToolMethods, getToolDetail, getTools } from '@/api/tools'
import type { ToolType, ToolItem } from '@/views/ToolManagement/types' import type { ToolType, ToolItem } from '@/views/ToolManagement/types'
@@ -163,6 +163,25 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
form.setFieldsValue(inititalValue) form.setFieldsValue(inititalValue)
} }
const getNumberOptions = useMemo(() => {
const list: Suggestion[] = []
options.forEach(vo => {
if (vo.children && vo?.children?.length > 0) {
const filterChild = vo.children.filter(child => child.dataType === 'number')
if (filterChild.length > 0) {
list.push({ ...vo, disabled: vo.dataType !== 'number', children: filterChild })
} else if (vo.dataType === 'number') {
list.push({ ...vo, children: [] })
}
} else if (vo.dataType === 'number') {
list.push({ ...vo })
}
})
console.log('options', options, list)
return list
}, [options])
return ( return (
<> <>
@@ -202,15 +221,14 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
: parameter.type === 'boolean' : parameter.type === 'boolean'
? <Switch size="small" /> ? <Switch size="small" />
: parameter.type === 'integer' || parameter.type === 'number' : parameter.type === 'integer' || parameter.type === 'number'
? <InputNumber ? <Editor
min={parameter.minimum} variant="outlined"
max={parameter.maximum} type="input"
step={parameter.type === 'integer' ? 1 : 0.01} size="small"
placeholder={t('common.pleaseEnter')} height={28}
className="rb:w-full!" options={getNumberOptions}
size="small" placeholder={t('common.pleaseEnter')}
onChange={(value) => form.setFieldValue(['tool_parameters', parameter.name], value)} />
/>
: <Editor : <Editor
variant="outlined" variant="outlined"
type="input" type="input"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:40:13 * @Date: 2026-02-03 15:40:13
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-03 20:19:34 * @Last Modified time: 2026-04-08 10:48:21
*/ */
import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -20,7 +20,7 @@ interface VariableSelectProps {
multiple?: boolean; multiple?: boolean;
size?: 'small' | 'middle' | 'large'; size?: 'small' | 'middle' | 'large';
placeholder?: string; placeholder?: string;
variant?: 'outlined' | 'borderless'; variant?: 'outlined' | 'borderless' | 'filled';
className?: string; className?: string;
onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void; onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void;
} }
@@ -190,12 +190,13 @@ const VariableSelect: FC<VariableSelectProps> = ({
{/* Trigger */} {/* Trigger */}
<div <div
className={clsx( className={clsx(
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:bg-white rb:px-2 rb:transition-colors', 'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:px-2 rb:transition-colors',
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff]', variant === 'filled' && 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none',
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white',
variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]', variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]',
variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent', variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent',
multiple && size === 'small' ? 'rb:min-h-6 rb:py-0.75' : multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-6 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]', multiple && size === 'small' ? 'rb:min-h-7 rb:py-0.75' : multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-7 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]',
!multiple && (size === 'small' ? 'rb:text-[10px]' : 'rb:text-[12px]'), !multiple && (size === 'small' ? 'rb:text-[12px]' : 'rb:text-[12px]'),
className className
)} )}
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
@@ -232,15 +233,16 @@ const VariableSelect: FC<VariableSelectProps> = ({
<span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span> <span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span>
) )
) : selectedSuggestion ? ( ) : selectedSuggestion ? (
<span className="rb:flex rb:flex-1 rb:min-w-0"> <div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full">
<span className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full"> <span className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full rb:overflow-hidden">
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />} {!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />}
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{nodeData.name}{sep}</span>} {!isConversation && nodeData?.name && <span className="rb:text-[#5B6167] rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
<span className="rb:text-[#171719]"> {!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{sep}</span>}
<span className="rb:text-[#171719] rb:shrink rb:min-w-0 rb:truncate">
{parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label} {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label}
</span> </span>
</span> </span>
</span> </div>
) : ( ) : (
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span> <span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span>
)} )}
@@ -264,7 +266,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
{open && createPortal( {open && createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-lg rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)] rb:p-1"
style={{ top: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }} style={{ top: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }}
> >
<div className="rb:min-w-70 rb:max-h-60 rb:overflow-y-auto rb:py-1"> <div className="rb:min-w-70 rb:max-h-60 rb:overflow-y-auto rb:py-1">
@@ -272,8 +274,8 @@ const VariableSelect: FC<VariableSelectProps> = ({
const nd = suggestions[0].nodeData; const nd = suggestions[0].nodeData;
return ( return (
<div key={nodeId}> <div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:py-1.25! rb:text-[12px] rb:font-medium rb:text-[#5B6167]"> <Flex align="center" gap={4} className="rb:px-3! rb:py-1.25! rb:text-[12px] rb:text-[#5B6167]">
{nd.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />} {nd.icon && <div className={`rb:size-4 rb:bg-cover ${nd.icon}`} />}
{nd.name} {nd.name}
</Flex> </Flex>
{suggestions.map(s => { {suggestions.map(s => {
@@ -286,14 +288,15 @@ const VariableSelect: FC<VariableSelectProps> = ({
<Flex <Flex
key={s.key} key={s.key}
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }} ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
className="rb:mx-3! rb:pl-3! rb:pr-3! rb:py-1.5! rb:rounded-lg!" className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", {
'rb:bg-[#e6f4ff]': isSelected || isExpanded,
'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded),
'rb:opacity-60': s.disabled,
'rb:cursor-not-allowed': s.disabled,
'rb:cursor-pointer': !s.disabled,
})}
align="center" align="center"
justify="space-between" justify="space-between"
style={{
cursor: s.disabled ? 'not-allowed' : 'pointer',
background: isSelected || isExpanded ? '#f0f8ff' : 'white',
opacity: s.disabled ? 0.5 : 1,
}}
onClick={() => { onClick={() => {
if (s.disabled) return; if (s.disabled) return;
if (hasChildren) { if (hasChildren) {

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-01-19 17:00:26 * @Date: 2026-01-19 17:00:26
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 16:58:40 * @Last Modified time: 2026-04-08 10:12:27
*/ */
/** /**
* useVariableList Hook * useVariableList Hook
@@ -26,7 +26,8 @@ export const fileSubVariable = [
{ label: 'url', dataType: 'string', filed: 'url' }, { label: 'url', dataType: 'string', filed: 'url' },
{ label: 'extension', dataType: 'string', filed: 'extension' }, { label: 'extension', dataType: 'string', filed: 'extension' },
{ label: 'mime_type', dataType: 'string', filed: 'mime_type' }, { label: 'mime_type', dataType: 'string', filed: 'mime_type' },
{ label: 'related_id', dataType: 'string', filed: 'related_id' }, { label: 'origin_file_type', dataType: 'string', filed: 'origin_file_type' },
{ label: 'file_id', dataType: 'string', filed: 'file_id' },
]; ];
/** /**
@@ -125,7 +126,7 @@ const processNodeVariables = (
if (type in NODE_VARIABLES) { if (type in NODE_VARIABLES) {
if (type === 'list-operator') { if (type === 'list-operator') {
// Determine output type from the first variable in config // Determine output type from the first variable in config
const variableValue = config?.variable; const variableValue = config?.input_list?.defaultValue;
let itemType = 'string'; let itemType = 'string';
if (variableValue) { if (variableValue) {
const refVar = variableList.find(v => `{{${v.value}}}` === variableValue); const refVar = variableList.find(v => `{{${v.value}}}` === variableValue);
@@ -321,7 +322,6 @@ export const getChildNodeVariables = (
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData); if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
}); });
} }
// Add code node variables // Add code node variables
if (type === 'code') { if (type === 'code') {
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => { (nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
@@ -393,8 +393,18 @@ export const useVariableList = (
// Add chat variables // Add chat variables
chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' })); chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' }));
// Process each relevant node // Process each relevant node: non-list-operator first, then list-operator
const listOperatorIds: string[] = [];
relevantIds.forEach(id => { relevantIds.forEach(id => {
const node = nodes.find(n => n.id === id);
if (!node) return;
if (node.getData()?.type === 'list-operator') {
listOperatorIds.push(id);
} else {
processNodeVariables(node.getData(), node.getData().id, list, keys);
}
});
listOperatorIds.forEach(id => {
const node = nodes.find(n => n.id === id); const node = nodes.find(n => n.id === id);
if (node) processNodeVariables(node.getData(), node.getData().id, list, keys); if (node) processNodeVariables(node.getData(), node.getData().id, list, keys);
}); });

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59 * @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 11:30:44 * @Last Modified time: 2026-04-08 14:10:40
*/ */
import { type FC, useEffect, useState, useMemo } from "react"; import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx' import clsx from 'clsx'
@@ -229,7 +229,6 @@ const Properties: FC<PropertiesProps> = ({
} }
return filteredList; return filteredList;
}; };
if (nodeType === 'llm') { if (nodeType === 'llm') {
// For LLM nodes that are children of iteration or loop nodes, include parent variables // For LLM nodes that are children of iteration or loop nodes, include parent variables
const parentLoopNode = selectedNode ? (() => { const parentLoopNode = selectedNode ? (() => {
@@ -790,8 +789,25 @@ const Properties: FC<PropertiesProps> = ({
return nodeTypeMatch || variableNameMatch; return nodeTypeMatch || variableNameMatch;
}); });
} }
if (config.onFilterVariableNames) { if (config.onFilterVariableType) {
return baseVariableList.filter(variable => Array.isArray(config.onFilterVariableNames) && config.onFilterVariableNames.includes(variable.label)); const types = config.onFilterVariableType as string[];
let list: Suggestion[] = []
baseVariableList.forEach((variable) => {
if (variable.children?.length) {
const filteredChildren = variable.children.filter((c: Suggestion) => types.includes(c.dataType));
console.log('filteredChildren', filteredChildren)
if (filteredChildren.length > 0) {
list.push({ ...variable, children: filteredChildren });
} else if (types.includes(variable.dataType)) {
list.push({ ...variable, children: [] });
}
} else if (types.includes(variable.dataType)) {
list.push(variable);
}
});
console.log('list', list)
return list
} }
// Filter child nodes for iteration output // Filter child nodes for iteration output
if (config.filterChildNodes && selectedNode) { if (config.filterChildNodes && selectedNode) {
@@ -812,7 +828,7 @@ const Properties: FC<PropertiesProps> = ({
} }
return baseVariableList; return baseVariableList;
})()} })()}
onChange={(value, option) => handleChangeVariableList(value, option, key)} onChange={(value, option) => handleChangeVariableList(value as string, option, key)}
size="small" size="small"
/> />
: config.type === 'switch' : config.type === 'switch'

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18 * @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-03 20:28:08 * @Last Modified time: 2026-04-07 19:56:56
*/ */
import LoopNode from './components/Nodes/LoopNode'; import LoopNode from './components/Nodes/LoopNode';
import NormalNode from './components/Nodes/NormalNode'; import NormalNode from './components/Nodes/NormalNode';
@@ -128,7 +128,7 @@ export const nodeLibrary: NodeLibrary[] = [
}, },
vision_input: { vision_input: {
type: 'variableList', type: 'variableList',
onFilterVariableNames: ['sys.files'] onFilterVariableType: ['array[file]']
} }
} }
}, },
@@ -457,7 +457,7 @@ export const nodeLibrary: NodeLibrary[] = [
file_selector: { file_selector: {
type: 'variableList', type: 'variableList',
placeholder: 'common.pleaseSelect', placeholder: 'common.pleaseSelect',
onFilterVariableNames: ['sys.files'] onFilterVariableType: ['array[file]', 'file']
} }
} }
}, },
@@ -579,7 +579,7 @@ export const noteNode = {
export const nodeWidth = 240; export const nodeWidth = 240;
export const conditionNodePortItemArgsY = 60; export const conditionNodePortItemArgsY = 56.5;
export const conditionNodeItemHeight = 26; export const conditionNodeItemHeight = 26;
export const conditionNodeHeight = 110; export const conditionNodeHeight = 110;
/** /**
@@ -703,7 +703,7 @@ export const portTextAttrs = { fontSize: 12, fill: '#5B6167' }
/** /**
* Port position arguments * Port position arguments
*/ */
export const portItemArgsY = 26; export const portItemArgsY = 26.5;
export const portArgs = { x: nodeWidth, y: portItemArgsY } export const portArgs = { x: nodeWidth, y: portItemArgsY }
const defaultPortGroup = { const defaultPortGroup = {

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48 * @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-31 11:13:23 * @Last Modified time: 2026-04-07 23:17:50
*/ */
import { useRef, useEffect, useState } from 'react'; import { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -18,6 +18,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import { useUser } from '@/store/user'; import { useUser } from '@/store/user';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types' import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils' import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'
import type { Suggestion } from '../components/Editor/plugin/AutocompletePlugin';
/** /**
* Props for useWorkflowGraph hook * Props for useWorkflowGraph hook
@@ -73,6 +74,8 @@ export interface UseWorkflowGraphReturn {
handleAddNotes: () => void; handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void; handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
features?: FeaturesConfigForm; features?: FeaturesConfigForm;
/** Get start node output variable list (user-defined + system variables) */
getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>;
} }
/** /**
@@ -441,7 +444,12 @@ export const useWorkflowGraph = ({
setTimeout(() => { setTimeout(() => {
if (graphRef.current) { if (graphRef.current) {
graphRef.current.centerContent() graphRef.current.centerContent()
graphRef.current.getNodes().forEach(node => node.toFront()); // graphRef.current.getNodes().forEach(node => node.toFront());
// Bring edges to front first, then child nodes above edges; parent nodes stay behind
graphRef.current.getEdges().forEach(edge => edge.toFront());
graphRef.current.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront();
});
} }
}, 200) }, 200)
} }
@@ -722,6 +730,41 @@ export const useWorkflowGraph = ({
// Delete all collected nodes and edges // Delete all collected nodes and edges
if (cells.length > 0) { if (cells.length > 0) {
graphRef.current?.removeCells(cells); graphRef.current?.removeCells(cells);
// If parent is iteration/loop and only cycle-start remains, add add-node connected to it
parentNodesToUpdate.forEach(parentNode => {
const parentShape = parentNode.shape;
if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return;
const parentData = parentNode.getData();
const remainingChildren = graphRef.current!.getNodes().filter(
n => n.getData()?.cycle === parentData.id
);
const cycleStartNodes = remainingChildren.filter(n => n.getData()?.type === 'cycle-start');
if (cycleStartNodes.length === 1 && remainingChildren.length === 1) {
const cycleStartNode = cycleStartNodes[0];
const bbox = cycleStartNode.getBBox();
const addNode = graphRef.current!.addNode({
...graphNodeLibrary.addStart,
x: bbox.x + 84,
y: bbox.y + 4,
data: {
type: 'add-node',
parentId: parentNode.id,
cycle: parentData.id,
label: t('workflow.addNode'),
icon: '+',
},
});
parentNode.addChild(addNode);
const sourcePort = cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right';
const targetPort = addNode.getPorts().find(p => p.group === 'left')?.id || 'left';
graphRef.current!.addEdge({
source: { cell: cycleStartNode.id, port: sourcePort },
target: { cell: addNode.id, port: targetPort },
...edgeAttrs,
});
}
});
} }
return false; return false;
}; };
@@ -877,12 +920,13 @@ export const useWorkflowGraph = ({
if (!view) return null if (!view) return null
const cell = view.cell const cell = view.cell
if (cell.isNode()) { if (cell.isNode()) {
// Parent (iteration/loop) nodes are not restricted
if (cell.getData()?.type === 'iteration' || cell.getData()?.type === 'loop') return null
const parent = cell.getParent() const parent = cell.getParent()
if (parent) { if (parent) {
return parent.getBBox() return parent.getBBox()
} }
} }
return null return null
}, },
}, },
@@ -984,10 +1028,30 @@ export const useWorkflowGraph = ({
graphRef.current.on('scale', scaleEvent); graphRef.current.on('scale', scaleEvent);
// Listen to node move event // Listen to node move event
graphRef.current.on('node:moved', nodeMoved); graphRef.current.on('node:moved', nodeMoved);
// When parent (isGroup) node position changes, move children with it
graphRef.current.on('node:change:position', ({ node, current, previous }: { node: Node; current: { x: number; y: number }; previous: { x: number; y: number } }) => {
if (!(node.getData()?.type === 'iteration' && node.getData()?.type === 'loop') || !current || !previous) return;
const dx = current.x - previous.x;
const dy = current.y - previous.y;
const parentId = node.getData()?.id || node.id;
graphRef.current?.getNodes().forEach(child => {
if (child.getData()?.cycle === parentId) {
const cp = child.getPosition();
child.setPosition(cp.x + dx, cp.y + dy, { silent: true });
}
});
});
graphRef.current.on('node:removed', blankClick) graphRef.current.on('node:removed', blankClick)
// When edge connected, bring connected nodes' ports to front // When edge connected, bring connected nodes' ports to front
graphRef.current.on('edge:connected', ({ isNew }) => { graphRef.current.on('edge:connected', ({ isNew, edge }) => {
graphRef.current?.getNodes().forEach(node => node.toFront()); // Bring edge to front first, then bring child nodes above edges
// Parent (loop/iteration) nodes stay behind to avoid covering edges
edge.toFront();
graphRef.current?.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront();
});
// Reset any port hover state left from dragging // Reset any port hover state left from dragging
if (isNew) { if (isNew) {
graphRef.current?.getNodes().forEach(node => { graphRef.current?.getNodes().forEach(node => {
@@ -1363,9 +1427,49 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData }, data: { ...cleanNodeData },
}); });
} }
const getStartNodeVariables = (): Array<{ name: string; type: string; readonly?: boolean }> => {
const startNode = graphRef.current?.getNodes().find(n => n.getData()?.type === 'start')
if (!startNode) return []
const data = startNode.getData()
const userVars: Array<{ name: string; type: string; readonly?: boolean }> =
(data?.config?.variables?.defaultValue ?? []).map((v: any) => ({ name: v.name, type: v.type }))
return userVars
}
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => { const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
const { statement = '' } = value?.opening_statement || {}
featuresRef.current = value featuresRef.current = value
onFeaturesLoad?.(value) onFeaturesLoad?.(value)
const usedVars = [...new Set([...(statement?.matchAll(/\{\{(\w+)\}\}/g) ?? [])].map(m => m[1]))]
const startVars = getStartNodeVariables()
const validNames = new Set(startVars.map(v => v.name))
const invalid = usedVars.filter(v => !validNames.has(v))
if (invalid.length > 0) {
const newVars = invalid.map(name => ({
name,
description: name,
type: 'string',
required: true,
defaultValue: '',
}))
const startNode = graphRef.current?.getNodes().find(n => n.getData()?.type === 'start')
if (startNode) {
const data = startNode.getData()
console.log('startNode', [...startVars, ...newVars])
startNode.setData({
...data,
config: {
...data.config,
variables: {
...data.config.variables,
defaultValue: [...startVars, ...newVars],
},
},
})
}
}
} }
return { return {
@@ -1389,5 +1493,6 @@ export const useWorkflowGraph = ({
handleAddNotes, handleAddNotes,
handleSaveFeaturesConfig, handleSaveFeaturesConfig,
features: featuresRef.current, features: featuresRef.current,
getStartNodeVariables,
}; };
}; };

View File

@@ -6,10 +6,11 @@ import Properties from './components/Properties';
import CanvasToolbar from './components/CanvasToolbar'; import CanvasToolbar from './components/CanvasToolbar';
import PortClickHandler from './components/PortClickHandler'; import PortClickHandler from './components/PortClickHandler';
import { useWorkflowGraph } from './hooks/useWorkflowGraph'; import { useWorkflowGraph } from './hooks/useWorkflowGraph';
import type { WorkflowRef, FeaturesConfigForm } from '@/views/ApplicationConfig/types' import type { WorkflowRef, FeaturesConfigForm, FeaturesConfigModalRef } from '@/views/ApplicationConfig/types'
import Chat from './components/Chat/Chat'; import Chat from './components/Chat/Chat';
import type { ChatRef, AddChatVariableRef } from './types' import type { ChatRef, AddChatVariableRef } from './types'
import AddChatVariable from './components/AddChatVariable'; import AddChatVariable from './components/AddChatVariable';
import FeaturesConfigModal from '@/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal'
const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => { const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -35,7 +36,8 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
setChatVariables, setChatVariables,
handleAddNotes, handleAddNotes,
handleSaveFeaturesConfig, handleSaveFeaturesConfig,
features features,
getStartNodeVariables,
} = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad }); } = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
const onDragOver = (event: React.DragEvent) => { const onDragOver = (event: React.DragEvent) => {
@@ -51,6 +53,15 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
addChatVariableRef.current?.handleOpen() addChatVariableRef.current?.handleOpen()
} }
// Ref used to imperatively open the config modal
const funConfigModalRef = useRef<FeaturesConfigModalRef>(null)
/** Open the feature config modal pre-populated with the current values */
const handleFeaturesConfig = () => {
blankClick()
funConfigModalRef.current?.handleOpen(features as FeaturesConfigForm)
}
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
handleSave, handleSave,
handleRun, handleRun,
@@ -59,6 +70,7 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
chatVariables, chatVariables,
config, config,
features: features, features: features,
handleFeaturesConfig,
handleSaveFeaturesConfig handleSaveFeaturesConfig
})) }))
return ( return (
@@ -112,6 +124,13 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
variables={chatVariables} variables={chatVariables}
onChange={setChatVariables} onChange={setChatVariables}
/> />
{/* Modal for editing feature settings; calls refresh on save */}
<FeaturesConfigModal
ref={funConfigModalRef}
refresh={handleSaveFeaturesConfig}
source="workflow"
chatVariables={getStartNodeVariables().map(v => ({ name: v.name, key: `start_${v.name}`, label: v.name, type: 'variable', dataType: v.type, value:`{{${v.name}}}` })) as any}
/>
</div> </div>
); );
}); });

View File

@@ -108,7 +108,7 @@ export interface ChatVariable {
required: boolean; required: boolean;
description: string; description: string;
default?: string; default?: string;
defaultValue: string; defaultValue: string | any[];
} }
export interface AddChatVariableRef { export interface AddChatVariableRef {
handleOpen: (value?: ChatVariable) => void; handleOpen: (value?: ChatVariable) => void;

View File

@@ -12,16 +12,7 @@ export default defineConfig({
proxy: { proxy: {
// 主要API代理支持 /api 和 /api/* 格式 // 主要API代理支持 /api 和 /api/* 格式
'/api': { '/api': {
// target: 'http://192.168.110.83:8000', // wxy target: 'http://localhost:5173',
// target: 'http://192.168.110.86:8000', // lxy
// target: 'http://192.168.110.2:8000', // xjn
// target: 'http://192.168.110.72:8000', // llq
// target: 'http://192.168.110.39:8000', // myh
target: 'https://devmemorybear.redbearai.com/', // 开发后端服务地址
// target: 'https://devcopymemorybear.redbearai.com/', // 开发sass后端服务地址
// target: 'https://testmemorybear.redbearai.com/', // 测试后端服务地址
// target: 'https://memorybear.redbearai.com/', // 预发服务地址
// target: 'https://cloud.memorybear.ai/', // AMAZON 生产地址
changeOrigin: true, changeOrigin: true,
// 匹配所有以/api开头的请求包括/api/token // 匹配所有以/api开头的请求包括/api/token
@@ -93,7 +84,7 @@ export default defineConfig({
minify: 'terser', minify: 'terser',
terserOptions: { terserOptions: {
compress: { compress: {
drop_console: true, // 移除 console drop_console: false, // 移除 console
drop_debugger: true, // 移除 debugger drop_debugger: true, // 移除 debugger
}, },
}, },