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

View File

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

View File

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

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

View File

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

View File

@@ -131,7 +131,7 @@ class DifyConverter(BaseConverter):
selector = var_selector.split('.')
if len(selector) not in [2, 3] and var_selector != "context":
raise Exception(f"invalid variable selector: {var_selector}")
if len(selector) == 3:
if len(selector) == 3 and selector[0] in ("conversation", "sys"):
selector = selector[1:]
if selector[0] == "conversation":
selector[0] = "conv"
@@ -483,11 +483,11 @@ class DifyConverter(BaseConverter):
node_data = node["data"]
result = IterationNodeConfig.model_construct(
input=self._process_list_variable_literal(node_data["iterator_selector"]),
parallel=node_data["is_parallel"],
parallel_count=node_data["parallel_nums"],
parallel=node_data.get("is_parallel", False),
parallel_count=node_data.get("parallel_nums", 4),
output=self._process_list_variable_literal(node_data["output_selector"]),
output_type=self.variable_type_map(node_data.get("output_type")),
flatten=node_data["flatten_output"],
flatten=node_data.get("flatten_output", False),
).model_dump()
self.config_validate(node["id"], node["data"]["title"], IterationNodeConfig, result)
@@ -496,7 +496,23 @@ class DifyConverter(BaseConverter):
def convert_assigner_node_config(self, node: dict) -> dict:
node_data = node["data"]
assignments = []
for assignment in node_data["items"]:
# Support both formats:
# 1. New format: node_data["items"] list
# 2. Flat format: assigned_variable_selector + input_variable_selector + write_mode
if "items" in node_data:
raw_items = node_data["items"]
elif "assigned_variable_selector" in node_data and "input_variable_selector" in node_data:
raw_items = [{
"variable_selector": node_data["assigned_variable_selector"],
"value": node_data["input_variable_selector"],
"input_type": ValueInputType.VARIABLE,
"operation": node_data.get("write_mode", "over-write"),
}]
else:
raw_items = []
for assignment in raw_items:
if assignment.get("operation") is None or assignment.get("value") is None:
continue
assignments.append(

View File

@@ -1,5 +1,5 @@
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.core.workflow.nodes.base_config import BaseNodeConfig
from app.core.workflow.nodes.enums import ComparisonOperator
@@ -31,6 +31,11 @@ class ExtractConfig(BaseModel):
enabled: bool = False
serial: str = "1" # 1-based index string, e.g. "1" = first
@field_validator("serial", mode="before")
@classmethod
def coerce_serial(cls, v):
return str(v)
class ListOperatorNodeConfig(BaseNodeConfig):
"""

View File

@@ -11,7 +11,7 @@ from app.core.workflow.variable.base_variable import VariableType
logger = logging.getLogger(__name__)
# File object fields that hold string values
_FILE_STRING_KEYS = {"name", "extension", "mime_type", "url", "transfer_method", "origin_file_type", "file_id"}
_FILE_STRING_KEYS = {"type", "name", "url", "extension", "mime_type", "transfer_method", "origin_file_type", "file_id"}
_FILE_NUMBER_KEYS = {"size"}
@@ -52,7 +52,7 @@ class ListOperatorNode(BaseNode):
result = [result[idx]]
# 3. Order
if cfg.order_by.enabled and cfg.order_by.key:
if cfg.order_by.enabled:
reverse = cfg.order_by.value == "desc"
key_fn = self._make_sort_key(cfg.order_by.key)
result = sorted(result, key=key_fn, reverse=reverse)
@@ -100,10 +100,17 @@ class ListOperatorNode(BaseNode):
else:
left = item # primitive array: compare element directly
# Determine if this field should be compared as a string
is_string_field = isinstance(item, dict) and cond.key in _FILE_STRING_KEYS
# Numeric operators
if op == ComparisonOperator.EQ:
if is_string_field:
return str(left) == str(value)
return self._safe_num(left) == self._safe_num(value)
if op == ComparisonOperator.NE:
if is_string_field:
return str(left) != str(value)
return self._safe_num(left) != self._safe_num(value)
if op == ComparisonOperator.LT:
return self._safe_num(left) < self._safe_num(value)

View File

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

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.nodes.base_node import BaseNode
from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig
from app.core.workflow.variable.base_variable import VariableType
from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE
from app.db import get_db_read
from app.models import ModelType
from app.services.model_service import ModelConfigService
@@ -45,6 +45,12 @@ class ParameterExtractorNode(BaseNode):
"model_id": str(self.typed_config.model_id),
}
def _extract_output(self, business_result: Any) -> Any:
final_output = {}
for param in self.typed_config.params:
final_output[param.name] = business_result.get(param.name) or DEFAULT_VALUE(self.output_types[param.name])
return final_output
def _output_types(self) -> dict[str, VariableType]:
outputs = {}
for param in self.typed_config.params:
@@ -202,7 +208,10 @@ class ParameterExtractorNode(BaseNode):
])
model_resp = await llm.ainvoke(messages)
self.response_metadata = model_resp.response_metadata
self.response_metadata = {
**model_resp.response_metadata,
"token_usage": getattr(model_resp, 'usage_metadata', None) or model_resp.response_metadata.get('token_usage')
}
model_message = self.process_model_output(model_resp.content)
result = json_repair.repair_json(model_message, return_objects=True)
logger.info(f"node: {self.node_id} get params:{result}")

View File

@@ -136,7 +136,10 @@ class QuestionClassifierNode(BaseNode):
response = await llm.ainvoke(messages)
result = self.process_model_output(response.content)
self.response_metadata = response.response_metadata
self.response_metadata = {
**response.response_metadata,
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
}
if result in category_names:
category = result

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ class ChatRequest(BaseModel):
web_search: bool = Field(default=False, description="是否启用网络搜索")
memory: bool = Field(default=True, description="是否启用记忆功能")
thinking: bool = Field(default=False, description="是否启用深度思考需Agent配置支持")
files: Optional[List[FileInput]] = Field(default=None, description="附件列表(支持多文件)")
files: List[FileInput] = Field(default_factory=list, description="附件列表(支持多文件)")
# ---------- Output Schemas ----------

View File

@@ -231,8 +231,13 @@ class AppChatService:
if memory_flag:
connected_config = get_end_user_connected_config(user_id, self.db)
memory_config_id: str = connected_config.get("memory_config_id")
file_list = []
for file in files:
file_dict = file.model_dump()
file_dict["upload_file_id"] = str(file_dict["upload_file_id"]) if file_dict["upload_file_id"] else None
file_list.append(file_dict)
messages = [
{"role": "user", "content": message, "files": [file.model_dump() for file in files]},
{"role": "user", "content": message, "files": file_list},
{"role": "assistant", "content": result["content"]}
]
if memory_config_id:
@@ -506,8 +511,13 @@ class AppChatService:
if memory_flag:
connected_config = get_end_user_connected_config(user_id, self.db)
memory_config_id: str = connected_config.get("memory_config_id")
file_list = []
for file in files:
file_dict = file.model_dump()
file_dict["upload_file_id"] = str(file_dict["upload_file_id"]) if file_dict["upload_file_id"] else None
file_list.append(file_dict)
messages = [
{"role": "user", "content": message, "files": [file.model_dump() for file in files]},
{"role": "user", "content": message, "files": file_list},
{"role": "assistant", "content": full_content}
]
if memory_config_id:

View File

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

View File

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

View File

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

View File

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

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
* @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 16:05:01
* @Last Modified time: 2026-04-08 11:23:18
*/
import { type FC, useRef, useEffect, useState } from 'react'
import clsx from 'clsx'
@@ -131,7 +131,9 @@ const ChatContent: FC<ChatContentProps> = ({
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
{data.length === 0
? empty // Display empty state
: data.map((item, index) => (
: data.map((item, index) => {
if (!item) return null
return (
<div key={index} className={clsx("rb:relative", {
'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
@@ -147,7 +149,7 @@ const ChatContent: FC<ChatContentProps> = ({
{labelFormat(item)}
</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) => {
if (file.type.includes('image')) {
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')]",
file.type?.includes('pdf')
? "rb:bg-[url('@/assets/images/file/pdf.svg')]"
: (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet'))
? "rb:bg-[url('@/assets/images/file/excel.svg')]"
: file.type?.includes('csv')
? "rb:bg-[url('@/assets/images/file/csv.svg')]"
: file.type?.includes('html')
? "rb:bg-[url('@/assets/images/file/html.svg')]"
: file.type?.includes('json')
? "rb:bg-[url('@/assets/images/file/json.svg')]"
: file.type?.includes('ppt')
? "rb:bg-[url('@/assets/images/file/ppt.svg')]"
: file.type?.includes('text')
? "rb:bg-[url('@/assets/images/file/txt.svg')]"
: file.type?.includes('markdown')
? "rb:bg-[url('@/assets/images/file/md.svg')]"
: (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document'))
? "rb:bg-[url('@/assets/images/file/word.svg')]"
: null
: (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) || file.type?.includes('xls') || file.type?.includes('xlsx')
? "rb:bg-[url('@/assets/images/file/excel.svg')]"
: file.type?.includes('csv')
? "rb:bg-[url('@/assets/images/file/csv.svg')]"
: file.type?.includes('html')
? "rb:bg-[url('@/assets/images/file/html.svg')]"
: file.type?.includes('json')
? "rb:bg-[url('@/assets/images/file/json.svg')]"
: file.type?.includes('ppt')
? "rb:bg-[url('@/assets/images/file/ppt.svg')]"
: file.type?.includes('markdown')
? "rb:bg-[url('@/assets/images/file/md.svg')]"
: file.type?.includes('text')
? "rb:bg-[url('@/assets/images/file/txt.svg')]"
: (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document'))
? "rb:bg-[url('@/assets/images/file/word.svg')]"
: "rb:bg-[url('@/assets/images/file/txt.svg')]"
)}
></div>
<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),
// Assistant message style
'rb:bg-[#E3EBFD] rb:p-[10px_12px_2px_12px] rb:rounded-lg rb:max-w-130': item.role === 'user',
'rb:max-w-full': item.role === 'assistant',
'rb:max-w-full rb:w-full': item.role === 'assistant',
// User message style
'rb:text-[#212332]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'),
'rb:mt-1': labelPosition === 'top',
@@ -282,7 +284,7 @@ const ChatContent: FC<ChatContentProps> = ({
}
</div>
{/* 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 && <>
{playingIndex !== item.meta_data?.audio_url && item.meta_data?.audio_status === 'pending'
? <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)}
</div>
</div>}
</Flex>
}
</>
}
</div>
))
)})
}
</div>
)

View File

@@ -5,10 +5,11 @@
* @Last Modified time: 2026-03-23 17:46:25
*/
import { type FC, useEffect, useMemo, useState } from 'react'
import { Flex, Input, Spin } from 'antd'
import { Flex, Input } from 'antd'
import clsx from 'clsx'
import type { ChatInputProps } from './types'
import FileList from './FileList'
/**
* Chat Input Component
@@ -26,6 +27,7 @@ const ChatInput: FC<ChatInputProps> = ({
}) => {
const [inputValue, setInputValue] = useState('')
const [isFocus, setIsFocus] = useState(false)
const [isComposing, setIsComposing] = useState(false)
// Clear input when external message is cleared
useEffect(() => {
@@ -52,6 +54,7 @@ const ChatInput: FC<ChatInputProps> = ({
})) || []
}, [fileList])
const handleSend = () => {
if (loading || !inputValue || inputValue.trim() === '') return
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", {
' rb:border-[#171719]!': isFocus
})}>
{previewFileList.length > 0 && <div className="rb:overflow-x-auto rb:max-w-full">
<Flex gap={14} className="rb:mx-3! rb:mt-3! rb:w-max!">
{previewFileList.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" />
<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>}
<div className="rb:overflow-x-auto rb:max-w-full">
<FileList fileList={previewFileList} onDelete={handleDelete} />
</div>
{/* Message input area */}
<Input.TextArea
value={inputValue}
@@ -167,9 +79,11 @@ const ChatInput: FC<ChatInputProps> = ({
setInputValue(e.target.value)
onChange?.(e.target.value)
}}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={(e) => {
// Enter to send, Shift+Enter for new line
if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) {
if (e.key === 'Enter' && !e.shiftKey && !isComposing && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) {
e.preventDefault();
handleSend();
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:07:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 13:43:59
* @Last Modified time: 2026-04-07 12:18:58
*/
/**
* AppHeader Component
@@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
{
key: '1',
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>,
label: (<>
<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',
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">
{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>,
className: 'rb:text-[#212332]!',
onClick: () => {
@@ -103,10 +103,10 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
},
{
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">
{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>,
className: 'rb:text-[#212332]!',
onClick: () => {
@@ -120,7 +120,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
},
{
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'),
danger: true,
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" 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>
<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')]", {

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:17:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 16:06:03
* @Last Modified time: 2026-04-07 21:56:00
*/
/**
* RbMarkdown Component
@@ -22,7 +22,7 @@
* @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 ReactMarkdown from 'react-markdown'
import RemarkGfm from 'remark-gfm'
@@ -44,6 +44,11 @@ const FormContext = createContext<{
onSubmit?: (values: Record<string, any>) => void;
} | 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 */
interface RbMarkdownProps {
/** Markdown content to render */
@@ -60,8 +65,8 @@ interface RbMarkdownProps {
onFormSubmit?: (values: Record<string, any>) => void;
}
/** Build components with onFormSubmit callback */
const buildComponents = (onFormSubmit?: (values: Record<string, any>) => void) => ({
/** Build stable components map — form submission handled via FormContext */
const buildComponents = () => ({
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>,
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>,
button: ({ children, ...props }: any) => {
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>,
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) => {
input: ({ children, value, ...props }: any) => {
const ctx = useContext(FormContext)
const handleChange = useCallback((val: any) => {
if (props.name) ctx?.setValue(props.name, val)
}, [ctx, props.name])
console.log('props', props)
switch (props.type) {
case 'color':
return <ColorPicker className="rb:mb-4!" {...props} onChange={handleChange} />
return <ColorPicker className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'time':
return <TimePicker className="rb:mb-4!" {...props} onChange={handleChange} />
return <TimePicker className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
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-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':
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':
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':
return <InputNumber className="rb:mb-4!" {...props} onChange={handleChange} />
return <InputNumber className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
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':
return <Slider className="rb:mb-4!" {...props} onChange={handleChange} />
return <Slider className="rb:mb-4!" defaultValue={value} {...props} onChange={handleChange} />
case 'submit':
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':
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':
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':
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': {
const raw = props['data-options']
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:
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) => {
@@ -152,15 +154,7 @@ const buildComponents = (onFormSubmit?: (values: Record<string, any>) => void) =
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>
},
form: ({ children, ...props }: any) => {
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>
)
},
form: RbForm,
label: ({ children, ...props }: any) => {
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,
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 textareaRef = useRef<any>(null)
@@ -242,6 +239,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
/** Render markdown preview mode */
return (
<FormContext.Provider value={formCtx}>
<div className={`rb:relative ${className || ''}`} onKeyDown={handleKeyDown} tabIndex={0}>
<style>{`
.html-comment {
@@ -278,6 +276,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
{processedContent}
</ReactMarkdown>
</div>
</FormContext.Provider>
)
}
export default RbMarkdown

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21
* @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 { useTranslation } from 'react-i18next'
@@ -354,6 +354,25 @@ const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigF
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
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(() => {
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) {
return { ...vo, list: [assistantMsg] }
} 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 {
return { ...vo, list: [assistantMsg, ...(vo.list || [])] }
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:33
* @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 { 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) })
getApplicationList({ ids: sub_agents?.map(item => item.agent_id).join(',')})
.then(res => {
const applicationList = (res as Application[]) || []
const applicationList = ((res as { items: Application[] }).items) || []
setSubAgents(sub_agents.map(vo => {
const filterVO = applicationList.find(item => item.id === vo.agent_id)
if (filterVO) {
@@ -194,6 +195,9 @@ const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesCon
// form.setFieldValue('features', value)
// }
console.log('subAgents', subAgents)
return (
<>
{loading && <Spin fullscreen></Spin>}

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:56
* @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
@@ -205,6 +205,7 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
/>
<OpenStatementSettingModal
ref={openStatementSettingModalRef}
source={source}
chatVariables={chatVariables}
onSave={handleSaveStatement}
/>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-03-05
* @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 { Button, Form, Input, Flex, App } from 'antd';
@@ -12,6 +12,8 @@ import RbModal from '@/components/RbModal';
import type { FeaturesConfigForm } from '../../types'
import type { Variable } from '../VariableList/types'
import Tag from '@/components/Tag'
import type { Application } from '@/views/ApplicationManagement/types';
import Editor from '@/views/Workflow/components/Editor';
export interface OpenStatementSettingModalRef {
handleOpen: (values?: FeaturesConfigForm['opening_statement']) => void;
@@ -21,17 +23,21 @@ export interface OpenStatementSettingModalRef {
interface OpenStatementSettingModalProps {
onSave: (values: FeaturesConfigForm['opening_statement']) => void;
chatVariables?: Variable[];
source?: Application['type'];
}
const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenStatementSettingModalProps>(({
onSave,
chatVariables = []
chatVariables = [],
source
}, ref) => {
const { t } = useTranslation();
const { modal } = App.useApp()
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FeaturesConfigForm['opening_statement']>();
console.log('chatVariables', chatVariables)
const handleClose = () => {
setVisible(false);
form.resetFields();
@@ -45,10 +51,11 @@ const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenS
const handleSave = async () => {
form.validateFields().then(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() !== '') {
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 invalid = usedVars.filter(v => !validNames.has(v))
if (invalid.length > 0) {
@@ -100,9 +107,12 @@ const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenS
label={t('application.opening_statement')}
name="statement"
>
<Input.TextArea
placeholder={t('common.pleaseEnter')}
/>
{source === 'workflow'
? <Editor options={chatVariables as any} variant="outlined" />
: <Input.TextArea
placeholder={t('common.pleaseEnter')}
/>
}
</Form.Item>
<Form.List name="suggested_questions">

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:25:37
* @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
@@ -32,7 +32,9 @@ interface KnowledgeConfigModalProps {
/**
* Available retrieval types
*/
const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid']
const retrieveTypes: RetrieveType[] = ['participle', 'semantic', 'hybrid',
// 'graph'
]
/**
* Modal for configuring knowledge base retrieval settings

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49
* @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 { Variable } from './components/VariableList/types'
@@ -168,6 +168,7 @@ export interface WorkflowRef {
chatVariables: ChatVariable[];
config: WorkflowConfig | null;
features: WorkflowConfig['features'];
handleFeaturesConfig?: () => void;
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 17:28:07
* @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
@@ -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-[#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
? (100 * data?.[`${item.key}_change` as keyof DashboardData] / data?.[item.key as keyof DashboardData]).toFixed(2)
{data?.[`${item.key}_change` as keyof DashboardData] && typeof data?.[item.key as keyof DashboardData] === 'number'
? (100 * data?.[`${item.key}_change` as keyof DashboardData]).toFixed(2)
: 0
}%
<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 ? (
<Empty size={200} />
) : (
<div style={{ columns: '3 280px', columnGap: 12, marginBottom: 8 }}>
{data.map((item) => {
const modelInfo = modelMenus[item.id];
const hasModelInfo = modelInfo && modelInfo.menu.length > 1;
return (
<div key={item.id} className="rb:break-inside-avoid rb:mb-3">
<RbCard
title={item.name}
headerType="borderless"
headerClassName="rb:py-3!"
extra={
<div onClick={(e) => e.stopPropagation()}>
<Dropdown
menu={{ items: getOptMenuItems(item) }}
placement="bottomRight"
>
<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>
}
>
<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>
);
})}
<Flex align="flex-start" gap={12} className="rb:mb-2!">
{[0, 1, 2].map(colIdx => (
<div key={colIdx} style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12 }}>
{data.filter((_, i) => i % 3 === colIdx).map((item) => {
const modelInfo = modelMenus[item.id];
const hasModelInfo = modelInfo && modelInfo.menu.length > 1;
return (
<div key={item.id}>
<RbCard
title={item.name}
headerType="borderless"
headerClassName="rb:py-3!"
extra={
<div onClick={(e) => e.stopPropagation()}>
<Dropdown
menu={{ items: getOptMenuItems(item) }}
placement="bottomRight"
>
<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>
)}
</div>
)}
</div>
</RbCard>
}
>
<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>
</RbCard>
</div>
)
})}
</div>
)})}
</div>
))}
</Flex>
)}
</InfiniteScroll>

View File

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

View File

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

View File

@@ -2,11 +2,11 @@
* @Author: ZhaoYing
* @Date: 2025-12-30 13:59:36
* @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 { 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 type { ChatVariableModalRef } from './types'
@@ -18,9 +18,31 @@ import UploadFileListModal from '@/views/Conversation/components/UploadFileListM
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import { getFileInfoByUrl } from '@/api/fileStorage'
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 object_placeholder = `# example
# {
# "name": "redbear",
# "age": 2
# }`
const array_object_placeholder = `# example
# [
# {
# "name": "redbear",
# "age": 2
# },
# {
# "name": "redbear",
# "age": 2
# }
# ]
`
interface ChatVariableModalProps {
refresh: (value: ChatVariable, editIndex?: number) => void;
}
@@ -30,7 +52,7 @@ const types = [
'number',
'boolean',
'object',
'file',
// 'file',
'array[file]',
'array[string]',
'array[number]',
@@ -51,37 +73,32 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const [editIndex, setEditIndex] = useState<number | undefined>(undefined);
const type = Form.useWatch('type', form);
const max_size = 50;
const allowed_transfer_methods = Form.useWatch('allowed_transfer_methods', form);
const image_enabled = Form.useWatch('image_enabled', form);
const audio_enabled = Form.useWatch('audio_enabled', form);
const document_enabled = Form.useWatch('document_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 audio_allowed_extensions = Form.useWatch('audio_allowed_extensions', form);
const document_allowed_extensions = Form.useWatch('document_allowed_extensions', form);
const video_allowed_extensions = Form.useWatch('video_allowed_extensions', form);
const max_file_count = Form.useWatch('max_file_count', form);
const hasEnabledFileType = !!(image_enabled || audio_enabled || document_enabled || video_enabled);
const featureConfig = useMemo(() => ({
enabled: hasEnabledFileType,
enabled: true,
allowed_transfer_methods,
max_file_count,
image_enabled, image_max_size_mb, image_allowed_extensions,
audio_enabled, audio_max_size_mb, audio_allowed_extensions,
document_enabled, document_max_size_mb, document_allowed_extensions,
video_enabled, video_max_size_mb, video_allowed_extensions,
image_enabled, image_max_size_mb: max_size, image_allowed_extensions,
audio_enabled, audio_max_size_mb: max_size, audio_allowed_extensions,
document_enabled, document_max_size_mb: max_size, document_allowed_extensions,
video_enabled, video_max_size_mb: max_size, video_allowed_extensions,
}), [
hasEnabledFileType, allowed_transfer_methods, max_file_count,
image_enabled, image_max_size_mb, image_allowed_extensions,
audio_enabled, audio_max_size_mb, audio_allowed_extensions,
document_enabled, document_max_size_mb, document_allowed_extensions,
video_enabled, video_max_size_mb, video_allowed_extensions,
allowed_transfer_methods, max_file_count,
image_enabled, image_allowed_extensions,
audio_enabled, audio_allowed_extensions,
document_enabled, document_allowed_extensions,
video_enabled, video_allowed_extensions, max_size
]);
const handleClose = () => {
@@ -104,6 +121,8 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const list = Array.isArray(defaultVal) ? defaultVal : [defaultVal];
setFileList(list);
}
} else if (variable.type.includes('object') && variable.defaultValue) {
form.setFieldValue('defaultValue', JSON.stringify(variable.defaultValue, null, 2))
}
} else {
form.resetFields();
@@ -113,7 +132,12 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const handleSave = () => {
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();
});
};
@@ -233,7 +257,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
<Select
placeholder={t('common.pleaseSelect')}
onChange={(value) => {
form.setFieldValue('defaultValue', undefined);
form.setFieldValue('defaultValue', value === 'array[string]' ? [] : undefined);
setFileList([]);
if (value === 'file' || value === 'array[file]') form.setFieldsValue(defaultFileUploadValues as any);
}}
@@ -244,7 +268,8 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
/>
</FormItem>
{type === 'file' || type === 'array[file]' ? (
{type?.includes('file')
? (
<>
<UploadFileListModal
ref={uploadFileListModalRef}
@@ -273,67 +298,53 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
</Col>
</Row>
{previewFileList.length > 0 && (
<Flex gap={8} wrap 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>
<FileList wrap="wrap" fileList={previewFileList} onDelete={handleDelete} className="rb:mt-2!" />
)}
</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')}>
{type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
: type === 'boolean'
? <Select
placeholder={t('common.pleaseSelect')}
options={[{ value: true, label: 'true' }, { value: false, label: 'false' }]}
/>
? <RadioGroupBtn size="large" options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} />
: type === 'object' || type === 'array[object]'
? <CodeMirrorEditor
language="json"
placeholder={type === 'object' ? object_placeholder : array_object_placeholder}
variant="outlined"
/>
: <Input placeholder={t('common.enter')} />
}
</Form.Item>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:10:56
* @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
@@ -40,6 +40,7 @@ import ChatToolbar from '@/components/Chat/ChatToolbar'
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import Runtime from './Runtime';
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 }>(({
appId, graphRef, features
@@ -67,8 +68,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
const handleOpen = () => {
setOpen(true)
if (features?.opening_statement?.statement && features?.opening_statement?.statement.trim() !== '') {
setChatList(prev => [...prev, {
if (features?.opening_statement?.enabled && features?.opening_statement?.statement && features?.opening_statement?.statement.trim() !== '') {
setChatList([{
role: 'assistant',
created_at: Date.now(),
content: features?.opening_statement?.statement,
@@ -419,6 +420,26 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
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 (
<RbDrawer
title={<Flex align="center" gap={10}>

View File

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

View File

@@ -21,7 +21,9 @@
line-height: 16px;
}
.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) {
height: 16px;
@@ -45,4 +47,7 @@
}
.collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) {
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
* @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 14:48:00
*/
import { type FC, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -12,7 +12,7 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { type Suggestion } from './plugin/AutocompletePlugin';
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
import Jinjia2CharacterCountPlugin from './plugin/Jinjia2CharacterCountPlugin';
import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin';
import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
@@ -89,7 +89,7 @@ export interface Jinja2EditorProps {
const Jinja2Editor: FC<Jinja2EditorProps> = ({
placeholder = '请输入内容...',
value = '',
value,
onChange,
options = [],
variant = 'borderless',
@@ -174,8 +174,8 @@ const Jinja2Editor: FC<Jinja2EditorProps> = ({
<Jinja2HighlightPlugin />
<LineNumberPlugin />
<Jinja2AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={() => {}} onChange={onChange} waitForInit />
<Jinja2InitialValuePlugin value={value} />
<Jinjia2CharacterCountPlugin setCount={() => {}} />
<Jinja2InitialValuePlugin value={value} onChange={onChange} />
<Jinja2BlurPlugin />
</div>
</LexicalComposer>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @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 { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -57,7 +57,6 @@ const Editor: FC<LexicalEditorProps> =({
language = 'string',
height,
className,
waitForInit = false,
}) => {
console.log('Editor value', value)
const [_count, setCount] = useState(0);
@@ -149,10 +148,10 @@ const Editor: FC<LexicalEditorProps> =({
/>
<HistoryPlugin />
<CommandPlugin />
<AutocompletePlugin options={options} enableJinja2={false} />
<AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={setCount} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={false} />
<BlurPlugin enableJinja2={false} />
<InitialValuePlugin value={value} options={options} />
<BlurPlugin />
</div>
</LexicalComposer>
);

View File

@@ -33,6 +33,18 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
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 (
<span
onClick={handleClick}

View File

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

View File

@@ -52,7 +52,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
const root = $getRoot();
root.clear();
const parts = value.split(/(\{\{[^}]+\}\}|\n)/);
const parts = (value ?? '').split(/(\{\{[^}]+\}\}|\n)/);
let paragraph = $createParagraphNode();
parts.forEach(part => {
@@ -100,15 +100,29 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
}
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') {
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) {
paragraph.append($createVariableNode(suggestion));
} else {

View File

@@ -1,10 +1,10 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 17:10:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:10:59
* @Last Modified by: ZhaoYing
* @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 {
$getSelection, $isRangeSelection, $isTextNode,
@@ -20,8 +20,35 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
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 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 = () => {
if (!popupRef.current) return;
@@ -51,19 +78,16 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/');
setShowSuggestions(shouldShow);
if (!shouldShow) { setSelectedIndex(0); return; }
if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); setChildPanelTop(0); return; }
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
const popupWidth = 280, popupHeight = 200;
const vw = window.innerWidth, vh = window.innerHeight;
let left = Math.min(Math.max(rect.left, 10), vw - popupWidth - 10);
let top = rect.top - 10;
if (top - popupHeight < 10) {
top = Math.min(rect.bottom + 10, vh - popupHeight - 10);
}
setPopupPosition({ top, left });
const popupWidth = 280;
let left = rect.left;
if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10;
if (left < 10) left = 10;
setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
}
});
});
@@ -72,7 +96,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
useEffect(() => {
return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND,
() => { setShowSuggestions(false); return true; },
() => { setShowSuggestions(false); setExpandedParent(null); setChildPanelTop(0); return true; },
COMMAND_PRIORITY_HIGH,
);
}, [editor]);
@@ -94,7 +118,10 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
selection.focus.offset = newOffset;
}
});
document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } }));
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
};
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
@@ -104,7 +131,9 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
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(() => {
if (!showSuggestions) return;
@@ -154,44 +183,99 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
ref={popupRef}
data-autocomplete-popup="true"
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 }}
>
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
<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]">
{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}
</Flex>
{nodeOptions.map((option) => {
const globalIndex = allOptions.indexOf(option);
const hasChildren = !!option.children?.length;
const isExpanded = expandedParent?.key === option.key;
return (
<Flex
key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => !option.disabled && insertMention(option)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
onClick={() => { if (option.disabled || hasChildren) return; insertMention(option); }}
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}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
<span>{option.label}</span>
</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>
);
})}
</div>
))}
</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>
);
};

View File

@@ -6,53 +6,50 @@
*/
import { useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
import { $getRoot, $createParagraphNode, $createTextNode, $isParagraphNode } from 'lexical';
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 prevValueRef = useRef<string>('');
const isUserInputRef = useRef(false);
const internalValueRef = useRef<string | undefined>(undefined);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) return;
if (internalValueRef.current === undefined) return;
editorState.read(() => {
const textContent = $getRoot().getTextContent();
if (textContent !== prevValueRef.current) {
isUserInputRef.current = true;
prevValueRef.current = textContent;
const paragraphs = $getRoot().getChildren()
.filter($isParagraphNode)
.map(p => p.getChildren().map(n => n.getTextContent()).join(''));
const text = paragraphs.join('\n');
if (text !== internalValueRef.current) {
internalValueRef.current = text;
onChangeRef.current?.(text);
}
});
});
}, [editor]);
useEffect(() => {
if (value === prevValueRef.current) return;
if (value === undefined) return;
if (value === internalValueRef.current) return;
if (isUserInputRef.current) {
prevValueRef.current = value;
isUserInputRef.current = false;
return;
}
prevValueRef.current = value;
isUserInputRef.current = false;
queueMicrotask(() => {
editor.update(() => {
const root = $getRoot();
root.clear();
value.split('\n').forEach((line) => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(line));
root.append(paragraph);
});
}, { tag: 'programmatic' });
});
internalValueRef.current = value;
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]);
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);
}}
size={size}
className="rb:w-39! rb:bg-[#F6F6F6]!"
variant="borderless"
className="rb:flex-1!"
variant="filled"
/>
</Form.Item>
<Form.Item
@@ -121,8 +121,8 @@ const AssignmentList: FC<AssignmentListProps> = ({
placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
size={size}
variant="borderless"
className="select"
className="rb:flex-1!"
variant="filled"
/>
: dataType === 'number'
? <InputNumber
@@ -152,8 +152,8 @@ const AssignmentList: FC<AssignmentListProps> = ({
placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
size={size}
variant="borderless"
className="select"
className="rb:flex-1!"
variant="filled"
/>
}
</Form.Item>

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,8 @@ const FilterConditions: FC<FilterConditionsProps> = ({
const keyFieldValue = currentCondition.key;
const keyFieldOption = fileSubVariable.find(option => option.filed === keyFieldValue);
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 (
<Flex
@@ -111,7 +112,7 @@ const FilterConditions: FC<FilterConditionsProps> = ({
fieldNames={{ value: 'filed', label: 'label' }}
onChange={(value) => handleKeyFieldChange(index, value)}
variant="borderless"
className="rb:w-full!"
className="rb:w-full! rb:h-7!"
/>
</Form.Item>
</Col>
@@ -129,15 +130,15 @@ const FilterConditions: FC<FilterConditionsProps> = ({
popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')}
variant="borderless"
className="rb:w-full!"
className="rb:w-full! rb:h-7!"
/>
</Form.Item>
</Col>
{!hideValueField && (
<Col flex="1">
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!">
{variableType?.includes('boolean')
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} />
{innerType === 'boolean'
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: keyFieldValue === 'type'
? <Select
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 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 (
<>
<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')}
options={options.filter(vo => vo.dataType.includes('array') && vo.dataType !== 'array[object]')}
size="small"
onChange={handleChangeInputList}
/>
</Form.Item>
@@ -58,6 +82,7 @@ const ListOperator: FC<ListOperatorProps> = ({ options }) => {
<Select
options={fileSubVariable}
fieldNames={{ value: 'filed', label: 'label' }}
placeholder={t('common.pleaseSelect')}
/>
</Form.Item>
</Col>

View File

@@ -63,6 +63,7 @@ const RadioGroupBtn: FC<RadioCardProps> = ({
allowClear = true,
block = false,
type,
className,
}) => {
/** Listen to value changes and trigger side effects via onValueChange callback */
useEffect(() => {
@@ -86,7 +87,7 @@ const RadioGroupBtn: FC<RadioCardProps> = ({
}
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 */}
{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", {

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 { 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 { getToolMethods, getToolDetail, getTools } from '@/api/tools'
import type { ToolType, ToolItem } from '@/views/ToolManagement/types'
@@ -163,6 +163,25 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
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 (
<>
@@ -202,15 +221,14 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
: parameter.type === 'boolean'
? <Switch size="small" />
: parameter.type === 'integer' || parameter.type === 'number'
? <InputNumber
min={parameter.minimum}
max={parameter.maximum}
step={parameter.type === 'integer' ? 1 : 0.01}
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
size="small"
onChange={(value) => form.setFieldValue(['tool_parameters', parameter.name], value)}
/>
? <Editor
variant="outlined"
type="input"
size="small"
height={28}
options={getNumberOptions}
placeholder={t('common.pleaseEnter')}
/>
: <Editor
variant="outlined"
type="input"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:40:13
* @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 { createPortal } from 'react-dom'
@@ -20,7 +20,7 @@ interface VariableSelectProps {
multiple?: boolean;
size?: 'small' | 'middle' | 'large';
placeholder?: string;
variant?: 'outlined' | 'borderless';
variant?: 'outlined' | 'borderless' | 'filled';
className?: string;
onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void;
}
@@ -190,12 +190,13 @@ const VariableSelect: FC<VariableSelectProps> = ({
{/* Trigger */}
<div
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',
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff]',
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:px-2 rb:transition-colors',
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 === '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:text-[10px]' : '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-[12px]' : 'rb:text-[12px]'),
className
)}
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>
)
) : selectedSuggestion ? (
<span className="rb:flex rb:flex-1 rb:min-w-0">
<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">
<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 rb:overflow-hidden">
{!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>}
<span className="rb:text-[#171719]">
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167] rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
{!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}
</span>
</span>
</span>
</div>
) : (
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span>
)}
@@ -264,7 +266,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
{open && createPortal(
<div
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 }}
>
<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;
return (
<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]">
{nd.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />}
<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-4 rb:bg-cover ${nd.icon}`} />}
{nd.name}
</Flex>
{suggestions.map(s => {
@@ -286,14 +288,15 @@ const VariableSelect: FC<VariableSelectProps> = ({
<Flex
key={s.key}
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"
justify="space-between"
style={{
cursor: s.disabled ? 'not-allowed' : 'pointer',
background: isSelected || isExpanded ? '#f0f8ff' : 'white',
opacity: s.disabled ? 0.5 : 1,
}}
onClick={() => {
if (s.disabled) return;
if (hasChildren) {

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-01-19 17:00:26
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 16:58:40
* @Last Modified time: 2026-04-08 10:12:27
*/
/**
* useVariableList Hook
@@ -26,7 +26,8 @@ export const fileSubVariable = [
{ label: 'url', dataType: 'string', filed: 'url' },
{ label: 'extension', dataType: 'string', filed: 'extension' },
{ 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 === 'list-operator') {
// Determine output type from the first variable in config
const variableValue = config?.variable;
const variableValue = config?.input_list?.defaultValue;
let itemType = 'string';
if (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);
});
}
// Add code node variables
if (type === 'code') {
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
@@ -393,8 +393,18 @@ export const useVariableList = (
// 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' }));
// Process each relevant node
// Process each relevant node: non-list-operator first, then list-operator
const listOperatorIds: string[] = [];
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);
if (node) processNodeVariables(node.getData(), node.getData().id, list, keys);
});

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59
* @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 clsx from 'clsx'
@@ -229,7 +229,6 @@ const Properties: FC<PropertiesProps> = ({
}
return filteredList;
};
if (nodeType === 'llm') {
// For LLM nodes that are children of iteration or loop nodes, include parent variables
const parentLoopNode = selectedNode ? (() => {
@@ -790,8 +789,25 @@ const Properties: FC<PropertiesProps> = ({
return nodeTypeMatch || variableNameMatch;
});
}
if (config.onFilterVariableNames) {
return baseVariableList.filter(variable => Array.isArray(config.onFilterVariableNames) && config.onFilterVariableNames.includes(variable.label));
if (config.onFilterVariableType) {
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
if (config.filterChildNodes && selectedNode) {
@@ -812,7 +828,7 @@ const Properties: FC<PropertiesProps> = ({
}
return baseVariableList;
})()}
onChange={(value, option) => handleChangeVariableList(value, option, key)}
onChange={(value, option) => handleChangeVariableList(value as string, option, key)}
size="small"
/>
: config.type === 'switch'

View File

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

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @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 { useParams } from 'react-router-dom';
@@ -18,6 +18,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import { useUser } from '@/store/user';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'
import type { Suggestion } from '../components/Editor/plugin/AutocompletePlugin';
/**
* Props for useWorkflowGraph hook
@@ -73,6 +74,8 @@ export interface UseWorkflowGraphReturn {
handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
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(() => {
if (graphRef.current) {
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)
}
@@ -722,6 +730,41 @@ export const useWorkflowGraph = ({
// Delete all collected nodes and edges
if (cells.length > 0) {
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;
};
@@ -877,12 +920,13 @@ export const useWorkflowGraph = ({
if (!view) return null
const cell = view.cell
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()
if (parent) {
return parent.getBBox()
}
}
return null
},
},
@@ -984,10 +1028,30 @@ export const useWorkflowGraph = ({
graphRef.current.on('scale', scaleEvent);
// Listen to node move event
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)
// When edge connected, bring connected nodes' ports to front
graphRef.current.on('edge:connected', ({ isNew }) => {
graphRef.current?.getNodes().forEach(node => node.toFront());
graphRef.current.on('edge:connected', ({ isNew, edge }) => {
// 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
if (isNew) {
graphRef.current?.getNodes().forEach(node => {
@@ -1363,9 +1427,49 @@ export const useWorkflowGraph = ({
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 { statement = '' } = value?.opening_statement || {}
featuresRef.current = 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 {
@@ -1389,5 +1493,6 @@ export const useWorkflowGraph = ({
handleAddNotes,
handleSaveFeaturesConfig,
features: featuresRef.current,
getStartNodeVariables,
};
};

View File

@@ -6,10 +6,11 @@ import Properties from './components/Properties';
import CanvasToolbar from './components/CanvasToolbar';
import PortClickHandler from './components/PortClickHandler';
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 type { ChatRef, AddChatVariableRef } from './types'
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 containerRef = useRef<HTMLDivElement>(null);
@@ -35,7 +36,8 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
setChatVariables,
handleAddNotes,
handleSaveFeaturesConfig,
features
features,
getStartNodeVariables,
} = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
const onDragOver = (event: React.DragEvent) => {
@@ -51,6 +53,15 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
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, () => ({
handleSave,
handleRun,
@@ -59,6 +70,7 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
chatVariables,
config,
features: features,
handleFeaturesConfig,
handleSaveFeaturesConfig
}))
return (
@@ -112,6 +124,13 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
variables={chatVariables}
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>
);
});

View File

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

View File

@@ -12,16 +12,7 @@ export default defineConfig({
proxy: {
// 主要API代理支持 /api 和 /api/* 格式
'/api': {
// target: 'http://192.168.110.83:8000', // wxy
// 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 生产地址
target: 'http://localhost:5173',
changeOrigin: true,
// 匹配所有以/api开头的请求包括/api/token
@@ -93,7 +84,7 @@ export default defineConfig({
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除 console
drop_console: false, // 移除 console
drop_debugger: true, // 移除 debugger
},
},