Merge pull request #833 from SuanmoSuanyangTechnology/release/v0.2.10
Release/v0.2.10
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -803,7 +803,6 @@ models:
|
||||
- vision
|
||||
- video
|
||||
- audio
|
||||
- thinking
|
||||
is_omni: true
|
||||
tags:
|
||||
- 大语言模型
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 完成标记
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -513,34 +513,40 @@ def get_dashboard_yesterday_changes(
|
||||
today_data: dict
|
||||
) -> dict:
|
||||
"""
|
||||
计算各指标相比昨天的变化量。
|
||||
|
||||
计算各指标相比昨天的变化百分比。
|
||||
|
||||
- total_app_change / total_knowledge_change:只看活跃记录,
|
||||
百分比 = (截止今日活跃总量 - 截止昨日活跃总量) / 截止昨日活跃总量
|
||||
- total_memory_change / total_api_call_change:
|
||||
百分比 = (今日总量 - 昨日总量) / 昨日总量
|
||||
|
||||
昨日总量为 0 时返回 None。返回值为浮点数,例如 0.5 表示增长 50%。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
workspace_id: 工作空间ID
|
||||
storage_type: 存储类型 'neo4j' | 'rag'
|
||||
today_data: 当前数据,包含 total_memory, total_app, total_knowledge, total_api_call
|
||||
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_memory_change": int | None,
|
||||
"total_app_change": int | None,
|
||||
"total_knowledge_change": int | None,
|
||||
"total_api_call_change": int | None
|
||||
"total_memory_change": float | None,
|
||||
"total_app_change": float | None,
|
||||
"total_knowledge_change": float | None,
|
||||
"total_api_call_change": float | None
|
||||
}
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
from app.models.api_key_model import ApiKey, ApiKeyLog
|
||||
from app.models.knowledge_model import Knowledge
|
||||
from app.models.app_model import App
|
||||
from app.models.appshare_model import AppShare
|
||||
|
||||
business_logger.info(f"计算昨日对比: workspace_id={workspace_id}, storage_type={storage_type}")
|
||||
business_logger.info(f"计算昨日对比百分比: workspace_id={workspace_id}, storage_type={storage_type}")
|
||||
|
||||
now_local = datetime.now()
|
||||
today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday_start = today_start - timedelta(days=1)
|
||||
|
||||
changes = {
|
||||
"total_memory_change": None,
|
||||
@@ -549,134 +555,102 @@ def get_dashboard_yesterday_changes(
|
||||
"total_api_call_change": None,
|
||||
}
|
||||
|
||||
# --- total_api_call_change ---
|
||||
def _calc_percentage(today_val, yesterday_val):
|
||||
"""计算百分比,昨日为0时返回None"""
|
||||
if yesterday_val is None or yesterday_val == 0:
|
||||
return None
|
||||
return round((today_val - yesterday_val) / yesterday_val, 4)
|
||||
|
||||
# --- total_api_call_change: (截止今日累计总数 - 截止昨日累计总数) / 截止昨日累计总数 ---
|
||||
try:
|
||||
# 获取该workspace下所有api_key的id
|
||||
api_key_ids = [
|
||||
row[0] for row in db.query(ApiKey.id).filter(
|
||||
ApiKey.workspace_id == workspace_id
|
||||
).all()
|
||||
]
|
||||
if api_key_ids:
|
||||
# 今日累计
|
||||
today_api_count = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
# 截止今日的累计调用总数
|
||||
total_api_until_now = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
ApiKeyLog.api_key_id.in_(api_key_ids),
|
||||
ApiKeyLog.created_at >= today_start,
|
||||
ApiKeyLog.created_at < now_local
|
||||
).scalar() or 0
|
||||
# 昨日全天
|
||||
yesterday_api_count = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
# 截止昨日的累计调用总数(today_start 即昨日结束)
|
||||
total_api_until_yesterday = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
ApiKeyLog.api_key_id.in_(api_key_ids),
|
||||
ApiKeyLog.created_at >= yesterday_start,
|
||||
ApiKeyLog.created_at < today_start
|
||||
).scalar() or 0
|
||||
changes["total_api_call_change"] = today_api_count - yesterday_api_count
|
||||
changes["total_api_call_change"] = _calc_percentage(total_api_until_now, total_api_until_yesterday)
|
||||
else:
|
||||
# 没有api_key,如果今日也是0则无对比意义
|
||||
changes["total_api_call_change"] = None
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算API调用昨日对比失败: {str(e)}")
|
||||
|
||||
# --- total_knowledge_change ---
|
||||
# --- total_knowledge_change: 只看活跃(status=1)且为顶层知识库(parent_id=workspace_id),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 ---
|
||||
try:
|
||||
# 今天有效总量:当前status=1的知识库总数,排除用户知识库(permission_id='Memory')
|
||||
# 截止今日的活跃知识库总量(当前 status=1,parent_id=workspace_id)
|
||||
today_knowledge = db.query(func.count(Knowledge.id)).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.status == 1,
|
||||
Knowledge.permission_id != "Memory"
|
||||
Knowledge.parent_id == Knowledge.workspace_id
|
||||
).scalar() or 0
|
||||
# 昨日有效总量:昨天之前创建的、当前仍有效的知识库,排除用户知识库
|
||||
# 截止昨日的活跃知识库总量(昨日之前创建的、当前仍 status=1,parent_id=workspace_id)
|
||||
yesterday_knowledge = db.query(func.count(Knowledge.id)).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.status == 1,
|
||||
Knowledge.permission_id != "Memory",
|
||||
Knowledge.parent_id == Knowledge.workspace_id,
|
||||
Knowledge.created_at < today_start
|
||||
).scalar() or 0
|
||||
# 今日软删:今天被软删的知识库(status=2 且 updated_at >= today_start),排除用户知识库
|
||||
today_deleted_knowledge = db.query(func.count(Knowledge.id)).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.status == 2,
|
||||
Knowledge.permission_id != "Memory",
|
||||
Knowledge.updated_at >= today_start
|
||||
).scalar() or 0
|
||||
|
||||
if yesterday_knowledge == 0 and today_knowledge == 0 and today_deleted_knowledge == 0:
|
||||
changes["total_knowledge_change"] = None
|
||||
else:
|
||||
# change = 今天有效总量 - 今日软删 - 昨日有效总量
|
||||
changes["total_knowledge_change"] = today_knowledge - today_deleted_knowledge - yesterday_knowledge
|
||||
changes["total_knowledge_change"] = _calc_percentage(today_knowledge, yesterday_knowledge)
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算知识库昨日对比失败: {str(e)}")
|
||||
|
||||
# --- total_app_change ---
|
||||
# --- total_app_change: 只看活跃(is_active=True),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 ---
|
||||
try:
|
||||
# === 自有app ===
|
||||
# 今天有效总量
|
||||
today_own_apps = db.query(func.count(App.id)).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.is_active == True
|
||||
).scalar() or 0
|
||||
# 昨日有效总量
|
||||
yesterday_own_apps = db.query(func.count(App.id)).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.is_active == True,
|
||||
App.created_at < today_start
|
||||
).scalar() or 0
|
||||
# 今日软删
|
||||
today_deleted_own_apps = db.query(func.count(App.id)).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.is_active == False,
|
||||
App.updated_at >= today_start
|
||||
).scalar() or 0
|
||||
|
||||
# === 被分享app ===
|
||||
# 今天有效总量
|
||||
today_shared_apps = db.query(func.count(AppShare.id)).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
AppShare.is_active == True
|
||||
).scalar() or 0
|
||||
# 昨日有效总量
|
||||
yesterday_shared_apps = db.query(func.count(AppShare.id)).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
AppShare.is_active == True,
|
||||
AppShare.created_at < today_start
|
||||
).scalar() or 0
|
||||
# 今日软删
|
||||
today_deleted_shared_apps = db.query(func.count(AppShare.id)).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
AppShare.is_active == False,
|
||||
AppShare.updated_at >= today_start
|
||||
).scalar() or 0
|
||||
|
||||
today_total_app = today_own_apps + today_shared_apps
|
||||
yesterday_total_app = yesterday_own_apps + yesterday_shared_apps
|
||||
total_deleted = today_deleted_own_apps + today_deleted_shared_apps
|
||||
|
||||
if yesterday_total_app == 0 and today_total_app == 0 and total_deleted == 0:
|
||||
changes["total_app_change"] = None
|
||||
else:
|
||||
# change = 今天有效总量 - 今日软删 - 昨日有效总量
|
||||
changes["total_app_change"] = today_total_app - total_deleted - yesterday_total_app
|
||||
changes["total_app_change"] = _calc_percentage(today_total_app, yesterday_total_app)
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算应用数量昨日对比失败: {str(e)}")
|
||||
|
||||
# --- total_memory_change ---
|
||||
# --- total_memory_change: (今日总量 - 昨日总量) / 昨日总量 ---
|
||||
try:
|
||||
today_memory = today_data.get("total_memory")
|
||||
if today_memory is None:
|
||||
changes["total_memory_change"] = None
|
||||
elif storage_type == "neo4j":
|
||||
# 从 memory_increments 取最近一条 created_at < today_start 的记录
|
||||
last_record = db.query(MemoryIncrement).filter(
|
||||
MemoryIncrement.workspace_id == workspace_id,
|
||||
MemoryIncrement.created_at < today_start
|
||||
).order_by(desc(MemoryIncrement.created_at)).first()
|
||||
if last_record is None:
|
||||
if last_record is None or last_record.total_num == 0:
|
||||
changes["total_memory_change"] = None
|
||||
else:
|
||||
changes["total_memory_change"] = today_memory - last_record.total_num
|
||||
changes["total_memory_change"] = _calc_percentage(today_memory, last_record.total_num)
|
||||
elif storage_type == "rag":
|
||||
# RAG: 查 documents 表中 created_at < today_start 的 chunk_num 之和
|
||||
from app.models.document_model import Document
|
||||
from app.models.end_user_model import EndUser as _EndUser
|
||||
from app.models.app_model import App as _App
|
||||
@@ -691,18 +665,18 @@ def get_dashboard_yesterday_changes(
|
||||
changes["total_memory_change"] = None
|
||||
else:
|
||||
file_names = [f"{uid}.txt" for uid in end_user_ids]
|
||||
yesterday_chunk = db.query(func.sum(Document.chunk_num)).filter(
|
||||
yesterday_chunk = int(db.query(func.sum(Document.chunk_num)).filter(
|
||||
Document.file_name.in_(file_names),
|
||||
Document.created_at < today_start
|
||||
).scalar()
|
||||
if yesterday_chunk is None:
|
||||
).scalar() or 0)
|
||||
if yesterday_chunk == 0:
|
||||
changes["total_memory_change"] = None
|
||||
else:
|
||||
changes["total_memory_change"] = today_memory - int(yesterday_chunk)
|
||||
changes["total_memory_change"] = _calc_percentage(today_memory, yesterday_chunk)
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算记忆总量昨日对比失败: {str(e)}")
|
||||
|
||||
business_logger.info(f"昨日对比计算完成: {changes}")
|
||||
business_logger.info(f"昨日对比百分比计算完成: {changes}")
|
||||
return changes
|
||||
|
||||
|
||||
@@ -1104,15 +1078,11 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict:
|
||||
except Exception as e:
|
||||
business_logger.warning(f"获取知识库数量失败: {e}")
|
||||
|
||||
# total_api_call: 仅统计当天 api_key_log 调用次数
|
||||
# total_api_call: 截止当前的历史累计调用总数
|
||||
try:
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func as _api_func
|
||||
from app.models.api_key_model import ApiKey as _ApiKey, ApiKeyLog as _ApiKeyLog
|
||||
|
||||
_now = datetime.now()
|
||||
_today_start = _now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
_api_key_ids = [
|
||||
row[0] for row in db.query(_ApiKey.id).filter(
|
||||
_ApiKey.workspace_id == workspace_id
|
||||
@@ -1120,9 +1090,7 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict:
|
||||
]
|
||||
if _api_key_ids:
|
||||
total_api_calls = db.query(_api_func.count(_ApiKeyLog.id)).filter(
|
||||
_ApiKeyLog.api_key_id.in_(_api_key_ids),
|
||||
_ApiKeyLog.created_at >= _today_start,
|
||||
_ApiKeyLog.created_at < _now
|
||||
_ApiKeyLog.api_key_id.in_(_api_key_ids)
|
||||
).scalar() or 0
|
||||
else:
|
||||
total_api_calls = 0
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
web/src/assets/images/file/audio_disabled.svg
Normal 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 |
18
web/src/assets/images/file/csv_disabled.svg
Normal 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 |
17
web/src/assets/images/file/excel_disabled.svg
Normal 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 |
17
web/src/assets/images/file/html_disabled.svg
Normal 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 |
14
web/src/assets/images/file/json_disabled.svg
Normal 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 |
19
web/src/assets/images/file/md_disabled.svg
Normal 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 |
16
web/src/assets/images/file/pause.svg
Normal 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 |
20
web/src/assets/images/file/pdf_disabled.svg
Normal 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 |
28
web/src/assets/images/file/play.svg
Normal 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 |
14
web/src/assets/images/file/ppt_disabled.svg
Normal 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 |
14
web/src/assets/images/file/txt_disabled.svg
Normal 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 |
16
web/src/assets/images/file/video_disabled.svg
Normal 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 |
15
web/src/assets/images/file/word_disabled.svg
Normal 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 |
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
|
||||
setVariables: (variables) => {
|
||||
console.log('variables', variables)
|
||||
form.setFieldValue('variables', variables)
|
||||
onVariablesChange?.(variables)
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
168
web/src/components/Chat/FileList.tsx
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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')]", {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -657,6 +657,7 @@ export const zh = {
|
||||
participle: '分词检索',
|
||||
semantic: '语义检索',
|
||||
hybrid: '混合检索',
|
||||
graph: '图谱检索',
|
||||
|
||||
similarity_threshold: '语义相似度阈值',
|
||||
similarity_threshold_desc: '仅返回语义相似度高于此阈值的结果',
|
||||
|
||||
@@ -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 || [])] }
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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!"
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||