From 876c39b1b0a8e4bed9a0f8339222839fb6027118 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 30 Mar 2026 18:37:09 +0800 Subject: [PATCH 001/245] fix(app): 1. Token consumption of the omni model; 2. Token consumption of the cluster includes sub-agents --- api/app/core/agent/langchain_agent.py | 42 +++++++++++++++----- api/app/core/models/base.py | 12 +++++- api/app/core/tools/mcp/client.py | 8 ++-- api/app/services/app_chat_service.py | 6 +-- api/app/services/master_agent_router.py | 11 +++++ api/app/services/multi_agent_orchestrator.py | 34 ++++++++++++++-- 6 files changed, 92 insertions(+), 21 deletions(-) diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index 464a668a..9776cc29 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -254,6 +254,33 @@ class LangChainAgent: return messages + @staticmethod + def _extract_tokens_from_message(msg) -> int: + """从 AIMessage 或类似对象中提取 total_tokens,兼容多种 provider 格式 + + 支持的格式: + - response_metadata.token_usage.total_tokens (OpenAI/ChatOpenAI) + - response_metadata.usage.total_tokens (部分 provider) + - usage_metadata.total_tokens (LangChain 新版) + """ + total = 0 + # 1. response_metadata + response_meta = getattr(msg, "response_metadata", None) + if response_meta and isinstance(response_meta, dict): + # 尝试 token_usage 路径 + token_usage = response_meta.get("token_usage") or response_meta.get("usage", {}) + if isinstance(token_usage, dict): + total = token_usage.get("total_tokens", 0) + # 2. usage_metadata(LangChain 新版 AIMessage 属性) + if not total: + usage_meta = getattr(msg, "usage_metadata", None) + if usage_meta: + if isinstance(usage_meta, dict): + total = usage_meta.get("total_tokens", 0) + else: + total = getattr(usage_meta, "total_tokens", 0) + return total or 0 + def _build_multimodal_content(self, text: str, files: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ 构建多模态消息内容 @@ -412,8 +439,7 @@ class LangChainAgent: else: content = str(msg.content) logger.debug(f"转换为字符串: {content[:100]}...") - response_meta = msg.response_metadata if hasattr(msg, 'response_metadata') else None - total_tokens = response_meta.get("token_usage", {}).get("total_tokens", 0) if response_meta else 0 + total_tokens = self._extract_tokens_from_message(msg) break logger.info(f"最终提取的内容长度: {len(content)}") @@ -458,7 +484,7 @@ class LangChainAgent: user_rag_memory_id: Optional[str] = None, memory_flag: Optional[bool] = True, files: Optional[List[Dict[str, Any]]] = None # 新增:多模态文件 - ) -> AsyncGenerator[str, None]: + ) -> AsyncGenerator[str | int, None]: """执行流式对话 Args: @@ -594,15 +620,13 @@ class LangChainAgent: logger.debug(f"Agent 流式完成,共 {chunk_count} 个事件") # 统计token消耗 + # 统计 token 消耗:优先使用流式过程中捕获的值,回退到最后 event 的 messages output_messages = event.get("data", {}).get("output", {}).get("messages", []) for msg in reversed(output_messages): if isinstance(msg, AIMessage): - response_meta = msg.response_metadata if hasattr(msg, 'response_metadata') else None - total_tokens = response_meta.get("token_usage", {}).get( - "total_tokens", - 0 - ) if response_meta else 0 - yield total_tokens + stream_total_tokens = self._extract_tokens_from_message(msg) + logger.info(f"流式 token 统计: total_tokens={stream_total_tokens}") + yield stream_total_tokens break if memory_flag: await write_long_term(storage_type, end_user_id, message_chat, full_content, user_rag_memory_id, diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 80117f27..a4dbc092 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -58,7 +58,7 @@ class RedBearModelFactory: write=60.0, pool=10.0, ) - return { + params = { "model": config.model_name, "base_url": config.base_url, "api_key": config.api_key, @@ -66,6 +66,10 @@ class RedBearModelFactory: "max_retries": config.max_retries, **config.extra_params } + # 流式模式下启用 stream_usage 以获取 token 统计 + if config.extra_params.get("streaming"): + params["stream_usage"] = True + return params if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK, ModelProvider.OLLAMA, ModelProvider.VOLCANO]: # 使用 httpx.Timeout 对象来设置详细的超时配置 @@ -78,7 +82,7 @@ class RedBearModelFactory: write=60.0, # 写入超时:60秒 pool=10.0, # 连接池超时:10秒 ) - return { + params = { "model": config.model_name, "base_url": config.base_url, "api_key": config.api_key, @@ -86,6 +90,10 @@ class RedBearModelFactory: "max_retries": config.max_retries, **config.extra_params } + # 流式模式下启用 stream_usage 以获取 token 统计 + if config.extra_params.get("streaming"): + params["stream_usage"] = True + return params elif provider == ModelProvider.DASHSCOPE: # DashScope (通义千问) 使用自己的参数格式 # 注意: DashScopeEmbeddings 不支持 timeout 和 base_url 参数 diff --git a/api/app/core/tools/mcp/client.py b/api/app/core/tools/mcp/client.py index 6df6df51..3539d33a 100644 --- a/api/app/core/tools/mcp/client.py +++ b/api/app/core/tools/mcp/client.py @@ -99,7 +99,7 @@ class SimpleMCPClient: # 建立 SSE 连接 response = await self._session.get(self.server_url) - if response.status != 200: + if not (200 <= response.status < 300): error_text = await response.text() raise MCPConnectionError(f"SSE 连接失败 {response.status}: {error_text}") @@ -190,7 +190,7 @@ class SimpleMCPClient: try: async with self._session.post(self._endpoint_url, json=request) as response: - if response.status != 200: + if not (200 <= response.status < 300): error_text = await response.text() raise MCPConnectionError(f"请求失败 {response.status}: {error_text}") @@ -205,7 +205,7 @@ class SimpleMCPClient: raise MCPConnectionError("endpoint URL 未初始化") async with self._session.post(self._endpoint_url, json=notification) as response: - if response.status != 200: + if not (200 <= response.status < 300): logger.warning(f"通知发送失败: {response.status}") async def _initialize_modelscope_session(self): @@ -223,7 +223,7 @@ class SimpleMCPClient: try: async with self._session.post(self.server_url, json=init_request) as response: - if response.status != 200: + if not (200 <= response.status < 300): error_text = await response.text() raise MCPConnectionError(f"初始化失败 {response.status}: {error_text}") diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 90474428..b5f9f194 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -631,13 +631,13 @@ class AppChatService: storage_type=storage_type, user_rag_memory_id=user_rag_memory_id ): - if "sub_usage" in event: + # 拦截 sub_usage 事件,累加 token + if "event: sub_usage" in event: if "data:" in event: try: data_line = event.split("data: ", 1)[1].strip() data = json.loads(data_line) - if "total_tokens" in data: - total_tokens += data["total_tokens"] + total_tokens += data.get("total_tokens", 0) except: pass else: diff --git a/api/app/services/master_agent_router.py b/api/app/services/master_agent_router.py index b0f43b51..954d3b2b 100644 --- a/api/app/services/master_agent_router.py +++ b/api/app/services/master_agent_router.py @@ -403,6 +403,17 @@ class MasterAgentRouter: response = await llm.ainvoke(prompt) ModelApiKeyService.record_api_key_usage(self.db, api_key_config.id) + # 提取 token 消耗 + self._last_routing_tokens = 0 + if hasattr(response, 'usage_metadata') and response.usage_metadata: + um = response.usage_metadata + self._last_routing_tokens = um.get("total_tokens", 0) if isinstance(um, dict) else getattr(um, "total_tokens", 0) + elif hasattr(response, 'response_metadata') and response.response_metadata: + token_usage = response.response_metadata.get("token_usage") or response.response_metadata.get("usage", {}) + if isinstance(token_usage, dict): + self._last_routing_tokens = token_usage.get("total_tokens", 0) + logger.info(f"Master Agent 路由 token 消耗: {self._last_routing_tokens}") + # 提取响应内容 if hasattr(response, 'content'): return response.content diff --git a/api/app/services/multi_agent_orchestrator.py b/api/app/services/multi_agent_orchestrator.py index 60a3b5b8..1330caad 100644 --- a/api/app/services/multi_agent_orchestrator.py +++ b/api/app/services/multi_agent_orchestrator.py @@ -287,6 +287,11 @@ class MultiAgentOrchestrator: sub_conversation_id = None total_tokens = 0 + # 累加 Master Agent 路由决策消耗的 token + total_tokens += task_analysis.get("routing_tokens", 0) + # 累加 Master Agent 整合消耗的 token + total_tokens += getattr(self, '_last_merge_tokens', 0) + if isinstance(results, dict): sub_conversation_id = results.get("conversation_id") or results.get("result", {}).get("conversation_id") # 提取 token 信息 @@ -358,12 +363,16 @@ class MultiAgentOrchestrator: variables=variables ) + # 获取路由决策消耗的 token + routing_tokens = getattr(self.router, '_last_routing_tokens', 0) + logger.info( "Master Agent 分析完成", extra={ "selected_agent": routing_decision.get("selected_agent_id"), "confidence": routing_decision.get("confidence"), - "strategy": routing_decision.get("strategy") + "strategy": routing_decision.get("strategy"), + "routing_tokens": routing_tokens } ) @@ -372,7 +381,8 @@ class MultiAgentOrchestrator: "variables": variables or {}, "sub_agents": self.config.sub_agents, "initial_context": variables or {}, - "routing_decision": routing_decision + "routing_decision": routing_decision, + "routing_tokens": routing_tokens } async def _execute_sequential( @@ -1032,6 +1042,11 @@ class MultiAgentOrchestrator: # 5. 流式执行子 Agent sub_conversation_id = None + # Master Agent 路由决策消耗的 token,通过 sub_usage 事件发送给上层 + routing_tokens = task_analysis.get("routing_tokens", 0) + if routing_tokens > 0: + yield self._format_sse_event("sub_usage", {"total_tokens": routing_tokens}) + async for event in self._execute_sub_agent_stream( agent_data["config"], message, @@ -1054,6 +1069,7 @@ class MultiAgentOrchestrator: except: pass + # 直接透传所有事件(包括 sub_usage),累加统一由上层处理 yield event # 6. 如果有会话 ID,发送一个包含它的事件 @@ -2612,6 +2628,17 @@ class MultiAgentOrchestrator: ModelApiKeyService.record_api_key_usage(self.db, api_key_config.id) + # 提取整合消耗的 token + merge_tokens = 0 + if hasattr(response, 'usage_metadata') and response.usage_metadata: + um = response.usage_metadata + merge_tokens = um.get("total_tokens", 0) if isinstance(um, dict) else getattr(um, "total_tokens", 0) + elif hasattr(response, 'response_metadata') and response.response_metadata: + token_usage = response.response_metadata.get("token_usage") or response.response_metadata.get("usage", {}) + if isinstance(token_usage, dict): + merge_tokens = token_usage.get("total_tokens", 0) + self._last_merge_tokens = merge_tokens + # 提取响应内容 if hasattr(response, 'content'): merged_response = response.content @@ -2621,7 +2648,8 @@ class MultiAgentOrchestrator: logger.info( "Master Agent 整合完成", extra={ - "merged_length": len(merged_response) + "merged_length": len(merged_response), + "merge_tokens": merge_tokens } ) From 8b997b422a8ad3ebf63bdc9683d4b9abf55b4af7 Mon Sep 17 00:00:00 2001 From: wxy Date: Wed, 1 Apr 2026 11:04:27 +0800 Subject: [PATCH 002/245] feat: enhance homepage version management with database persistence --- api/app/controllers/home_page_controller.py | 39 ++++++++- api/app/repositories/home_page_repository.py | 90 ++++++++++++++------ 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/api/app/controllers/home_page_controller.py b/api/app/controllers/home_page_controller.py index de4a78a3..685157e7 100644 --- a/api/app/controllers/home_page_controller.py +++ b/api/app/controllers/home_page_controller.py @@ -3,9 +3,10 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.response_utils import success -from app.db import get_db +from app.db import get_db, SessionLocal from app.dependencies import get_current_user from app.models.user_model import User +from app.repositories.home_page_repository import HomePageRepository from app.schemas.response_schema import ApiResponse from app.services.home_page_service import HomePageService @@ -31,9 +32,39 @@ def get_workspace_list( @router.get("/version", response_model=ApiResponse) def get_system_version(): - """获取系统版本号+说明""" - current_version = settings.SYSTEM_VERSION - version_info = HomePageService.load_version_introduction(current_version) + """获取系统版本号 + 说明""" + current_version = None + version_info = None + + # 1️⃣ 优先从数据库获取最新已发布的版本 + try: + db = SessionLocal() + try: + print(f"[DEBUG] 开始从数据库获取最新版本...") + current_version, version_info = HomePageRepository.get_latest_version_introduction(db) + if current_version: + print(f"[DEBUG] 数据库获取成功:version={current_version}") + else: + print(f"[DEBUG] 数据库获取失败:current_version=None") + finally: + db.close() + except Exception as e: + print(f"[DEBUG] 数据库查询异常:{e}") + pass + + # 2️⃣ 降级:使用环境变量中的版本号 + if not current_version: + print(f"[DEBUG] 使用环境变量版本:{settings.SYSTEM_VERSION}") + current_version = settings.SYSTEM_VERSION + version_info = HomePageService.load_version_introduction(current_version) + + # 3️⃣ 如果数据库和 JSON 都没有,返回基本信息 + if not version_info: + version_info = { + "introduction": {"codeName": "", "releaseDate": "", "upgradePosition": "", "coreUpgrades": []}, + "introduction_en": {"codeName": "", "releaseDate": "", "upgradePosition": "", "coreUpgrades": []} + } + return success( data={ "version": current_version, diff --git a/api/app/repositories/home_page_repository.py b/api/app/repositories/home_page_repository.py index 6d74bcaf..a15b9634 100644 --- a/api/app/repositories/home_page_repository.py +++ b/api/app/repositories/home_page_repository.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, time from sqlalchemy.orm import Session from sqlalchemy import func from uuid import UUID @@ -192,10 +192,65 @@ class HomePageRepository: return workspaces, app_count_dict, user_count_dict + @staticmethod + def get_latest_version_introduction(db: Session) -> tuple[Optional[str], Optional[Dict[str, Any]]]: + """ + 从数据库获取最新已发布的版本说明 + 使用反射方式读取表结构,不依赖 premium 模型类 + + Args: + db: 数据库会话 + + Returns: + (版本号,版本说明字典) 的元组 + 如果数据库中没有已发布的版本,返回 (None, None) + """ + try: + from sqlalchemy import Table, MetaData + + metadata = MetaData() + + version_notes = Table('version_notes', metadata, autoload_with=db.bind) + + # 获取最新已发布的版本(按发布时间倒序,日期相同时按版本号倒序) + query = db.query(version_notes).filter( + version_notes.c.is_published == True + ).order_by( + version_notes.c.release_date.desc(), + version_notes.c.version.desc() + ) + + note = query.first() + + if not note: + return None, None + + version_info = { + "introduction": { + "codeName": note.code_name or "", + "releaseDate": int(datetime.combine(note.release_date, time()).timestamp() * 1000) if note.release_date else 0, + "upgradePosition": note.upgrade_position or "", + "coreUpgrades": note.core_upgrades or [] + }, + "introduction_en": { + "codeName": note.code_name_en or note.code_name or "", + "releaseDate": int(datetime.combine(note.release_date, time()).timestamp() * 1000) if note.release_date else 0, + "upgradePosition": note.upgrade_position_en or note.upgrade_position or "", + "coreUpgrades": note.core_upgrades_en or [] + } + } + + return note.version, version_info + + except Exception as e: + import traceback + traceback.print_exc() + return None, None + @staticmethod def get_version_introduction(db: Session, version: str) -> Optional[Dict[str, Any]]: """ - 从数据库获取版本说明(优先读取已发布的版本) + 从数据库获取指定版本说明(优先读取已发布的版本) 使用反射方式读取表结构,不依赖 premium 模型类 Args: @@ -208,10 +263,10 @@ class HomePageRepository: """ try: from sqlalchemy import Table, MetaData + from datetime import datetime, time metadata = MetaData() version_notes = Table('version_notes', metadata, autoload_with=db.engine) - version_note_items = Table('version_note_items', metadata, autoload_with=db.engine) note = db.query(version_notes).filter( version_notes.c.version == version, @@ -221,31 +276,18 @@ class HomePageRepository: if not note: return None - items = db.query(version_note_items).filter( - version_note_items.c.note_id == note.id - ).order_by(version_note_items.c.sort_order).all() - - core_upgrades = [] - for item in items: - title = item.title - content = item.content - if content: - core_upgrades.append(f"{title}
{content}") - else: - core_upgrades.append(title) - return { "introduction": { - "codeName": "", - "releaseDate": note.release_date.isoformat() if note.release_date else "", - "upgradePosition": "", - "coreUpgrades": core_upgrades + "codeName": note.code_name or "", + "releaseDate": int(datetime.combine(note.release_date, time()).timestamp() * 1000) if note.release_date else 0, + "upgradePosition": note.upgrade_position or "", + "coreUpgrades": note.core_upgrades or [] }, "introduction_en": { - "codeName": "", - "releaseDate": note.release_date.isoformat() if note.release_date else "", - "upgradePosition": "", - "coreUpgrades": core_upgrades + "codeName": note.code_name_en or note.code_name or "", + "releaseDate": int(datetime.combine(note.release_date, time()).timestamp() * 1000) if note.release_date else 0, + "upgradePosition": note.upgrade_position_en or note.upgrade_position or "", + "coreUpgrades": note.core_upgrades_en or [] } } except Exception: From 8f609ba29c636f1456b835118ba821fe23531831 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 1 Apr 2026 11:15:06 +0800 Subject: [PATCH 003/245] fix(redis_lock): refactor RedisFairLock to use ZSET for queue management and fix loop shutdown - Replace list-based queue with sorted set for better dead client cleanup - Add zombie cleanup buffer to handle expired queue entries - Fix potential None loop reference in graceful shutdown - Add task start time to write_message_task result - Update lock acquisition script to use ZSET operations - Remove unused queue cleanup scripts - Ensure proper lock release and renewal failure handling --- api/app/tasks.py | 6 ++- api/app/utils/redis_lock.py | 96 ++++++++++++++++++++----------------- 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/api/app/tasks.py b/api/app/tasks.py index 72421a5f..fa2fa55d 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1176,6 +1176,7 @@ def write_message_task( redis_client = get_sync_redis_client() lock = None + loop = None if redis_client is not None: lock = RedisFairLock( key=f"memory_write:{end_user_id}", @@ -1196,6 +1197,7 @@ def write_message_task( } try: + task_start_time = int(time.time()) loop = set_asyncio_event_loop() result = loop.run_until_complete(_run()) @@ -1219,6 +1221,7 @@ def write_message_task( return { "status": "SUCCESS", "result": result, + "start_at": task_start_time, "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, @@ -1252,7 +1255,8 @@ def write_message_task( logger.warning(f"[CELERY WRITE] 释放锁失败: {e}") # Gracefully shutdown the event loop to prevent # 'RuntimeError: Event loop is closed' from httpx.AsyncClient.__del__ - _shutdown_loop_gracefully(loop) + if loop: + _shutdown_loop_gracefully(loop) # unused task diff --git a/api/app/utils/redis_lock.py b/api/app/utils/redis_lock.py index a86ba46e..f517cbb5 100644 --- a/api/app/utils/redis_lock.py +++ b/api/app/utils/redis_lock.py @@ -1,14 +1,15 @@ -import redis -import uuid -import time import threading +import time +import uuid + +import redis UNLOCK_SCRIPT = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) -else - return 0 end + +return 0 """ RENEW_SCRIPT = """ @@ -19,38 +20,44 @@ else end """ -CLEANUP_DEAD_HEAD_SCRIPT = """ +ACQUIRE_SCRIPT = """ local queue_key = KEYS[1] local lock_key = KEYS[2] -local first = redis.call("lindex", queue_key, 0) -if not first then - return 0 +local client_id = ARGV[1] +local expire = tonumber(ARGV[2]) +local time_out = tonumber(ARGV[3]) + +local now = tonumber(redis.call("time")[1]) + +if redis.call("zscore", queue_key, client_id) == false then + redis.call("zadd", queue_key, now, client_id) end -if redis.call("exists", lock_key) == 1 then - return 0 +local expired = redis.call("zrangebyscore", queue_key, 0, now - time_out) + +for _, v in ipairs(expired) do + redis.call("zrem", queue_key, v) end -redis.call("lpop", queue_key) -return 1 -""" +local first = redis.call("zrange", queue_key, 0, 0)[1] +if first == client_id then -SAFE_RELEASE_QUEUE_SCRIPT = """ -local queue_key = KEYS[1] -local value = ARGV[1] + if redis.call("set", lock_key, client_id, "NX", "EX", expire) then + redis.call("zrem", queue_key, client_id) + return 1 + end -local first = redis.call("lindex", queue_key, 0) -if first == value then - redis.call("lpop", queue_key) - return 1 + if redis.call("get", lock_key) == client_id then + redis.call("expire", lock_key, expire) + return 1 + end end return 0 """ def _ensure_str(val): - """统一将 Redis 返回值转为 str,兼容 decode_responses=True/False""" if val is None: return None if isinstance(val, bytes): @@ -59,18 +66,21 @@ def _ensure_str(val): class RedisFairLock: + # ZOMBIE CLEAN BUFFER + CLEANUP_BUFFER = 30 + def __init__( self, key: str, redis_client: redis.StrictRedis, expire: int = 30, - retry_interval: float = 0.05, + retry_interval: float = 1, timeout: float = 600, auto_renewal: bool = True ): self.key = key - self.queue_key = f"{key}:queue" - self.value = str(uuid.uuid4()) + self.queue_key = f"{key}:zset" + self.value = f"{uuid.uuid4().hex}:{int(time.time())}" self.expire = expire self.retry_interval = retry_interval self.timeout = timeout @@ -83,25 +93,25 @@ class RedisFairLock: def acquire(self): start = time.time() - self.redis.rpush(self.queue_key, self.value) - while True: - first = _ensure_str(self.redis.lindex(self.queue_key, 0)) + ok = self.redis.eval( + ACQUIRE_SCRIPT, + 2, + self.queue_key, + self.key, + self.value, + str(self.expire), + str(self.timeout + self.CLEANUP_BUFFER) + ) - if first == self.value: - ok = self.redis.set(self.key, self.value, nx=True, ex=self.expire) - if ok: - self._locked = True - - if self.auto_renewal: - self._start_renewal() - return True - - if first: - self.redis.eval(CLEANUP_DEAD_HEAD_SCRIPT, 2, self.queue_key, self.key) + if ok == 1: + self._locked = True + if self.auto_renewal: + self._start_renewal() + return True if time.time() - start > self.timeout: - self.redis.lrem(self.queue_key, 0, self.value) + self.redis.zrem(self.queue_key, self.value) return False time.sleep(self.retry_interval) @@ -112,13 +122,15 @@ class RedisFairLock: if self._stop_renew.is_set(): break - self.redis.eval( + success = self.redis.eval( RENEW_SCRIPT, 1, self.key, self.value, str(self.expire) ) + if not success: + break def _start_renewal(self): self._stop_renew = threading.Event() @@ -139,8 +151,6 @@ class RedisFairLock: self.redis.eval(UNLOCK_SCRIPT, 1, self.key, self.value) - self.redis.eval(SAFE_RELEASE_QUEUE_SCRIPT, 1, self.queue_key, self.value) - self._locked = False def __enter__(self): From 70c3c7dd7444fd7b3dea86e255a645f50ee0a802 Mon Sep 17 00:00:00 2001 From: wxy Date: Wed, 1 Apr 2026 11:20:52 +0800 Subject: [PATCH 004/245] feat: enhance homepage version management with database persistence --- api/app/repositories/home_page_repository.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/api/app/repositories/home_page_repository.py b/api/app/repositories/home_page_repository.py index a15b9634..d4eaddeb 100644 --- a/api/app/repositories/home_page_repository.py +++ b/api/app/repositories/home_page_repository.py @@ -1,6 +1,6 @@ from datetime import datetime, time from sqlalchemy.orm import Session -from sqlalchemy import func +from sqlalchemy import func, Table, MetaData from uuid import UUID from typing import Dict, Optional, Any @@ -206,8 +206,6 @@ class HomePageRepository: 如果数据库中没有已发布的版本,返回 (None, None) """ try: - from sqlalchemy import Table, MetaData - metadata = MetaData() version_notes = Table('version_notes', metadata, autoload_with=db.bind) @@ -262,9 +260,6 @@ class HomePageRepository: 如果数据库中没有该版本,返回 None """ try: - from sqlalchemy import Table, MetaData - from datetime import datetime, time - metadata = MetaData() version_notes = Table('version_notes', metadata, autoload_with=db.engine) From d3cd66fc6e78c603b9ef991dd64781f2c543e454 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 1 Apr 2026 12:03:56 +0800 Subject: [PATCH 005/245] feat(web): use bg replace img --- web/src/assets/images/common/return.svg | 17 ----- web/src/components/ButtonCheckbox/index.tsx | 4 +- web/src/components/ModelSelect/index.tsx | 4 +- web/src/components/RadioGroupCard/index.tsx | 2 +- web/src/components/RbModal/index.tsx | 2 +- web/src/components/Table/index.tsx | 2 +- web/src/views/ApplicationConfig/Agent.tsx | 2 +- .../components/ConfigHeader.tsx | 23 +++--- .../views/Home/components/QuickOperation.tsx | 17 ++--- .../views/Home/components/RecentActivity.tsx | 14 ++-- .../views/Index/components/QuickActions.tsx | 41 ++-------- web/src/views/Index/index.tsx | 2 +- .../views/Ontology/components/PageHeader.tsx | 2 +- web/src/views/UserMemoryDetail/Rag.tsx | 12 ++- .../components/PageHeader.tsx | 65 ---------------- .../UserMemoryDetail/pages/GraphDetail.tsx | 2 +- .../views/UserMemoryDetail/pages/index.tsx | 2 +- .../Workflow/components/Chat/Runtime.tsx | 2 +- .../components/Editor/nodes/VariableNode.tsx | 6 +- .../Editor/plugin/AutocompletePlugin.tsx | 6 +- .../views/Workflow/components/NodeLibrary.tsx | 4 +- .../Workflow/components/Nodes/AddNode.tsx | 2 +- .../components/Nodes/ConditionNode.tsx | 2 +- .../Workflow/components/Nodes/LoopNode.tsx | 2 +- .../Workflow/components/Nodes/NormalNode.tsx | 2 +- .../Workflow/components/PortClickHandler.tsx | 54 +++++--------- .../components/Properties/VariableSelect.tsx | 12 +-- .../Workflow/components/Properties/index.tsx | 2 +- web/src/views/Workflow/constant.ts | 74 +++++++------------ web/src/views/Workflow/index.tsx | 2 +- 30 files changed, 104 insertions(+), 279 deletions(-) delete mode 100644 web/src/assets/images/common/return.svg delete mode 100644 web/src/views/UserMemoryDetail/components/PageHeader.tsx diff --git a/web/src/assets/images/common/return.svg b/web/src/assets/images/common/return.svg deleted file mode 100644 index cb8166c0..00000000 --- a/web/src/assets/images/common/return.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - 退出 - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/src/components/ButtonCheckbox/index.tsx b/web/src/components/ButtonCheckbox/index.tsx index 8c52701b..0804a1b3 100644 --- a/web/src/components/ButtonCheckbox/index.tsx +++ b/web/src/components/ButtonCheckbox/index.tsx @@ -74,9 +74,9 @@ const ButtonCheckbox: FC = ({ onClick={handleChange} > {/* Display unchecked icon when not checked */} - {icon && !checked && } + {icon && !checked && {icon}} {/* Display checked icon when checked */} - {checkedIcon && checked && } + {checkedIcon && checked && {checkedIcon}} {children} ); diff --git a/web/src/components/ModelSelect/index.tsx b/web/src/components/ModelSelect/index.tsx index 3a6e42b6..8f9152fb 100644 --- a/web/src/components/ModelSelect/index.tsx +++ b/web/src/components/ModelSelect/index.tsx @@ -54,7 +54,7 @@ const ModelSelect: FC = ({ const logo = getListLogoUrl(item.provider, item.logo as string); return ( - {logo && } + {logo && {logo}}
{item.name}
); @@ -75,7 +75,7 @@ const ModelSelect: FC = ({ return ( - {logo && } + {logo && {logo}} {data.name as string} {data.capability?.length > 0 && ( diff --git a/web/src/components/RadioGroupCard/index.tsx b/web/src/components/RadioGroupCard/index.tsx index 49020b38..e346d7af 100644 --- a/web/src/components/RadioGroupCard/index.tsx +++ b/web/src/components/RadioGroupCard/index.tsx @@ -106,7 +106,7 @@ const RadioGroupCard: FC = ({ {/* Use custom render or default card layout */} {itemRender ? itemRender(option) : ( <> - {option.icon && {option.icon}}
diff --git a/web/src/components/RbModal/index.tsx b/web/src/components/RbModal/index.tsx index 199bfab5..84a57d8d 100644 --- a/web/src/components/RbModal/index.tsx +++ b/web/src/components/RbModal/index.tsx @@ -44,7 +44,7 @@ const RbModal: FC = ({ {...props} > {/* Scrollable content container */} -
+
{children}
diff --git a/web/src/components/Table/index.tsx b/web/src/components/Table/index.tsx index 62c9421a..08089499 100644 --- a/web/src/components/Table/index.tsx +++ b/web/src/components/Table/index.tsx @@ -91,7 +91,7 @@ const RbTable = forwardRef(, Q = Record {extra} diff --git a/web/src/views/UserMemoryDetail/Rag.tsx b/web/src/views/UserMemoryDetail/Rag.tsx index a11d4295..ff9069c7 100644 --- a/web/src/views/UserMemoryDetail/Rag.tsx +++ b/web/src/views/UserMemoryDetail/Rag.tsx @@ -16,8 +16,6 @@ import { Row, Col, Skeleton, Spin, Flex, Tooltip } from 'antd' import { LoadingOutlined } from '@ant-design/icons'; import { useParams } from 'react-router-dom' -import aboutUs from '@/assets/images/userMemory/aboutUs.svg' -import memoryInsight from '@/assets/images/userMemory/memoryInsight.svg' import RbCard from '@/components/RbCard/Card' import type { Data } from './types' import { @@ -34,12 +32,12 @@ import ConversationMemory from './components/ConversationMemory' */ interface TitleProps { title: string - icon: string + iconClassName: string } /** Collapsible section title */ -const Title: FC = ({ title, icon }) => ( +const Title: FC = ({ title, iconClassName }) => ( - +
{title} ) @@ -143,7 +141,7 @@ const Rag: FC = () => { <> <div className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-2.5 rb:px-3 rb:mb-4"> {loading.summary @@ -160,7 +158,7 @@ const Rag: FC = () => { <> <Title title={t('userMemory.memoryInsight')} - icon={memoryInsight} + iconClassName="rb:bg-[url('@/assets/images/userMemory/memoryInsight.svg')]" /> <div className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-2.5 rb:px-3"> {loading.insight diff --git a/web/src/views/UserMemoryDetail/components/PageHeader.tsx b/web/src/views/UserMemoryDetail/components/PageHeader.tsx deleted file mode 100644 index 861bf0f5..00000000 --- a/web/src/views/UserMemoryDetail/components/PageHeader.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * @Author: ZhaoYing - * @Date: 2026-02-03 18:32:30 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 18:32:30 - */ -/** - * Page Header Component - * Header with navigation and operation buttons - */ - -import { type FC, type ReactNode } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Layout, Button } from 'antd'; -import { useTranslation } from 'react-i18next'; - -import logoutIcon from '@/assets/images/logout_hover.svg' - -const { Header } = Layout; - -/** - * Component props - */ -interface ConfigHeaderProps { - name?: string; - operation?: ReactNode; - source?: 'detail' | 'node'; - extra?: ReactNode; -} -const PageHeader: FC<ConfigHeaderProps> = ({ - name, - operation, - source = 'detail', - extra -}) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - - /** Navigate back */ - const goBack = () => { - if (source === 'detail') { - navigate('/user-memory', { replace: true }) - } else { - navigate(-1) - } - } - return ( - <Header className="rb:w-full rb:h-16 rb:flex rb:justify-between rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8"> - <div className="rb:h-8 rb:flex rb:items-center rb:font-medium"> - {t('userMemory.memoryWindow', { name: name })} - {operation} - </div> - - <div className="rb:flex rb:items-center rb:gap-3"> - <Button type="primary" ghost className="rb:h-6! rb:px-2! rb:leading-5.5!" onClick={goBack}> - <img src={logoutIcon} className="rb:w-4 rb:h-4" /> - {t('common.return')} - </Button> - {extra} - </div> - </Header> - ); -}; - -export default PageHeader; \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx index 19f49ff0..d2417e1c 100644 --- a/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx @@ -104,7 +104,7 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => { <Space size={12}> <Button className="rb:px-2! rb:gap-0.5!" - icon={<div className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/return.svg')]"></div>} + icon={<div className="rb:bg-[url('@/assets/images/workflow/return.svg')] rb:size-4 rb:bg-cover"></div>} onClick={() => navigate(-1)} > {t('common.return')} diff --git a/web/src/views/UserMemoryDetail/pages/index.tsx b/web/src/views/UserMemoryDetail/pages/index.tsx index f8ce00c3..8cc62ec0 100644 --- a/web/src/views/UserMemoryDetail/pages/index.tsx +++ b/web/src/views/UserMemoryDetail/pages/index.tsx @@ -106,7 +106,7 @@ const Detail: FC = () => { } <Button className="rb:px-2! rb:gap-0.5!" - icon={<div className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/return.svg')]"></div>} + icon={<div className="rb:bg-[url('@/assets/images/workflow/return.svg')] rb:size-4 rb:bg-cover"></div>} onClick={handleGoBack} > {t('common.return')} diff --git a/web/src/views/Workflow/components/Chat/Runtime.tsx b/web/src/views/Workflow/components/Chat/Runtime.tsx index 142b7e1d..7da550e0 100644 --- a/web/src/views/Workflow/components/Chat/Runtime.tsx +++ b/web/src/views/Workflow/components/Chat/Runtime.tsx @@ -137,7 +137,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ 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 && <img src={vo.icon} className="rb:size-4" />} + {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> diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index 6462e1ae..1a1a09c4 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -45,11 +45,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ {data.isContext ? ( <span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span> ) : data.group !== 'CONVERSATION' ? ( - <img - src={data.nodeData?.icon} - style={{ width: '12px', height: '12px', marginRight: '4px' }} - alt="" - /> + <div className={`rb:size-4 rb:mr-1 rb:bg-cover ${data.nodeData?.icon}`} /> ) : null} {!data.isContext && data.group !== 'CONVERSATION' && ( <> diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 863e5160..c78eac38 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -288,11 +288,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> 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 && <img - src={nodeIcon} - className="rb:size-3" - alt="" - />} + {nodeIcon && <div className={`rb:size-3 rb:bg-cover ${nodeIcon}`} />} {nodeName} </Flex> {nodeOptions.map((option) => { diff --git a/web/src/views/Workflow/components/NodeLibrary.tsx b/web/src/views/Workflow/components/NodeLibrary.tsx index a7b06fd1..e6190adb 100644 --- a/web/src/views/Workflow/components/NodeLibrary.tsx +++ b/web/src/views/Workflow/components/NodeLibrary.tsx @@ -49,7 +49,7 @@ const NodeLibrary: FC<{ collapsed: boolean; handleToggle: () => void }> = ({ col e.dataTransfer.setData('application/json', JSON.stringify(node)); }} > - <img src={node.icon} className="rb:size-6 rb:cursor-pointer" /> + <div className={`rb:size-6 rb:cursor-pointer rb:bg-cover ${node.icon}`} /> </div> </Tooltip> )) @@ -77,7 +77,7 @@ const NodeLibrary: FC<{ collapsed: boolean; handleToggle: () => void }> = ({ col e.dataTransfer.setData('application/json', JSON.stringify(node)); }} > - <img src={node.icon} className="rb:size-6" /> + <div className={`rb:size-6 rb:bg-cover ${node.icon}`} /> <span className="rb:font-medium rb:text-[12px] rb:leading-4">{t(`workflow.${node.type}`)}</span> </Flex> ))} diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index dd0ab23d..5f1e7e65 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -151,7 +151,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { e.currentTarget.style.background = 'white'; }} > - <img src={nodeType.icon} className="rb:w-4 rb:h-4" /> + <div className={`rb:size-4 rb:bg-cover ${nodeType.icon}`} /> <span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span> </div> ))} diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 516b5125..79e8352c 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -52,7 +52,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { })}> <NodeTools node={node} /> <Flex align="center" gap={8} className="rb:flex-1"> - <img src={data.icon} className="rb:size-6" /> + <div className={`rb:size-6 rb:bg-cover ${data.icon}`} /> <div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div> </Flex> diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index 29c683cc..4a803246 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -126,7 +126,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { })}> <NodeTools node={node} /> <Flex align="center" gap={8} className="rb:flex-1"> - <img src={data.icon} className="rb:size-6" /> + <div className={`rb:size-6 rb:bg-cover ${data.icon}`} /> <div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div> </Flex> <div className="rb:mt-3 rb:min-h-[calc(100%-36px)] rb:w-full rb:bg-[radial-gradient(circle,#939AB1_1px,#F0F3F8_1px)] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:rounded-[10px] rb:bg-size-[12px_12px]"></div> diff --git a/web/src/views/Workflow/components/Nodes/NormalNode.tsx b/web/src/views/Workflow/components/Nodes/NormalNode.tsx index 12e89cca..f947d004 100644 --- a/web/src/views/Workflow/components/Nodes/NormalNode.tsx +++ b/web/src/views/Workflow/components/Nodes/NormalNode.tsx @@ -16,7 +16,7 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => { })}> <NodeTools node={node} /> <Flex align="center" gap={8} className="rb:flex-1"> - <img src={data.icon} className="rb:size-6" /> + <div className={`rb:size-6 rb:bg-cover ${data.icon}`} /> <div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div> </Flex> diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index 13ad6b98..31693722 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -5,7 +5,7 @@ * @Last Modified time: 2026-03-30 15:14:02 */ import { useEffect, useState } from 'react'; -import { Popover } from 'antd'; +import { Flex, Popover } from 'antd'; import { useTranslation } from 'react-i18next'; import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../constant'; @@ -286,21 +286,16 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => { }; const content = ( - <div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: `${nodeWidth}px` }}> - {nodeLibrary.map((category, categoryIndex) => { + <Flex vertical gap={16} className="rb:max-h-[300px] rb:overflow-y-auto rb:p-3" style={{ minWidth: `${nodeWidth}px` }}> + {nodeLibrary.map((category) => { const sourceNodeData = sourceNode?.getData(); const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop'); const isChildOfIteration = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'iteration'); let filteredNodes; - if (isChildOfLoop) { - // Use same filtering as AddNode for child nodes of loop, but allow break - filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); - } else if (isChildOfIteration) { - // Filter out loop and iteration nodes for children of iteration nodes, but allow break + if (isChildOfLoop || isChildOfIteration) { filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); } else { - // Original filtering for non-loop child nodes filteredNodes = category.nodes.filter(nodeType => nodeType.type !== 'start' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break' ); @@ -310,36 +305,27 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => { return ( <div key={category.category}> - {categoryIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />} - <div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}> + <div className="rb:font-semibold rb:mb-2 rb:text-[12px] rb:leading-4.5 rb:pl-1"> {t(`workflow.${category.category}`)} </div> - {filteredNodes.map((nodeType) => ( - <div - key={nodeType.type} - style={{ - padding: '8px 12px', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - gap: '8px', - }} - onClick={() => handleNodeSelect(nodeType)} - onMouseEnter={(e) => { - e.currentTarget.style.background = '#f0f8ff'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = 'white'; - }} - > - <img src={nodeType.icon} className="rb:w-4 rb:h-4" /> - <span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span> - </div> - ))} + <Flex gap={6} vertical> + {filteredNodes.map((nodeType) => ( + <Flex + key={nodeType.type} + align="center" + gap={8} + className="rb:rounded-xl rb:p-2! rb:border rb:border-[#EBEBEB] rb:cursor-pointer rb:hover:border rb:hover:border-[#171719]!" + onClick={() => handleNodeSelect(nodeType)} + > + <div className={`rb:size-6 rb:bg-cover ${nodeType.icon}`} /> + <span className="rb:font-medium rb:text-[12px] rb:leading-4">{t(`workflow.${nodeType.type}`)}</span> + </Flex> + ))} + </Flex> </div> ); })} - </div> + </Flex> ); if (!tempElement) return null; diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index 9170d065..51101736 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -73,11 +73,7 @@ const VariableSelect: FC<VariableSelectProps> = ({ > {filterOption.nodeData?.icon && filterOption.nodeData?.name && ( <> - <img - src={filterOption.nodeData.icon} - style={{ width: '12px', height: '12px', marginRight: '4px' }} - alt="" - /> + <div className={`rb:size-3 rb:mr-1 rb:bg-cover ${filterOption.nodeData.icon}`} /> {filterOption.nodeData.name} <span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span> </> @@ -111,11 +107,7 @@ const VariableSelect: FC<VariableSelectProps> = ({ */ const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({ label: <Flex align="center" gap={4}> - {suggestions[0].nodeData.icon && <img - src={suggestions[0].nodeData.icon} - className="rb:size-3" - alt="" - />} + {suggestions[0].nodeData.icon && <div className={`rb:size-3 ${suggestions[0].nodeData.icon}`} />} {suggestions[0].nodeData.name} </Flex>, options: suggestions.map(s => ({ diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 66b59075..e38331db 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -474,7 +474,7 @@ const Properties: FC<PropertiesProps> = ({ label: t(`workflow.${category.category}`), options: category.nodes.filter(item => !['cycle-start', 'break'].includes(item.type)).map(node => ({ label: <div className="rb:flex rb:items-center rb:gap-2 rb:flex-1"> - <img src={node.icon} className="rb:size-3.5" /> + <div className={`rb:size-3.5 rb:bg-cover ${node.icon}`} /> <div className="rb:wrap-break-word rb:line-clamp-1">{t(`workflow.${node.type}`)}</div> </div>, value: node.type diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index 92773191..2de35fbb 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -13,28 +13,6 @@ import NoteNode from './components/Nodes/NoteNode'; import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; -// Import workflow icons -import startIcon from '@/assets/images/workflow/start.svg'; -import endIcon from '@/assets/images/workflow/end.svg'; -import llmIcon from '@/assets/images/workflow/llm.svg'; -import ragIcon from '@/assets/images/workflow/rag.svg'; -import parameterExtractionIcon from '@/assets/images/workflow/parameter_extraction.svg'; -import conditionIcon from '@/assets/images/workflow/condition.svg'; -import iterationIcon from '@/assets/images/workflow/iteration.svg'; -import loopIcon from '@/assets/images/workflow/loop.svg'; -import aggregatorIcon from '@/assets/images/workflow/aggregator.svg'; -import httpRequestIcon from '@/assets/images/workflow/http_request.svg'; -import toolsIcon from '@/assets/images/workflow/tools.svg'; -import codeExecutionIcon from '@/assets/images/workflow/code_execution.svg'; -import templateRenderingIcon from '@/assets/images/workflow/template_rendering.svg'; -import questionClassifierIcon from '@/assets/images/workflow/question-classifier.svg' -import breakIcon from '@/assets/images/workflow/break.svg' -import assignerIcon from '@/assets/images/workflow/assigner.svg' -import memoryReadIcon from '@/assets/images/workflow/memory-read.svg' -import memoryWriteIcon from '@/assets/images/workflow/memory-write.svg' -import unknownIcon from '@/assets/images/workflow/unknown.svg' -import documentExtractorIcon from '@/assets/images/workflow/document-extractor.svg' - import { memoryConfigListUrl } from '@/api/memory' import type { NodeLibrary } from './types' @@ -46,7 +24,7 @@ export const nodeLibrary: NodeLibrary[] = [ { category: "coreNode", nodes: [ - { type: "start", icon: startIcon, + { type: "start", icon: 'rb:bg-[url("@/assets/images/workflow/start.svg")]', config: { variables: { type: 'define', @@ -87,7 +65,7 @@ export const nodeLibrary: NodeLibrary[] = [ } }, { - type: "end", icon: endIcon, + type: "end", icon: 'rb:bg-[url("@/assets/images/workflow/end.svg")]', config: { output: { type: 'editor' @@ -100,7 +78,7 @@ export const nodeLibrary: NodeLibrary[] = [ { category: "aiAndCognitiveProcessing", nodes: [ - { type: "llm", icon: llmIcon, + { type: "llm", icon: 'rb:bg-[url("@/assets/images/workflow/llm.svg")]', config: { model_id: { type: 'define', @@ -154,7 +132,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "knowledge-retrieval", icon: ragIcon, + { type: "knowledge-retrieval", icon: 'rb:bg-[url("@/assets/images/workflow/rag.svg")]', config: { query: { type: 'variableList', @@ -164,7 +142,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "parameter-extractor", icon: parameterExtractionIcon, + { type: "parameter-extractor", icon: 'rb:bg-[url("@/assets/images/workflow/parameter_extraction.svg")]', config: { model_id: { type: 'modelSelect', @@ -191,7 +169,7 @@ export const nodeLibrary: NodeLibrary[] = [ { category: "cognitiveUpgrading", nodes: [ - { type: "memory-read", icon: memoryReadIcon, + { type: "memory-read", icon: 'rb:bg-[url("@/assets/images/workflow/memory-read.svg")]', config: { message: { type: 'editor', @@ -214,7 +192,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "memory-write", icon: memoryWriteIcon, + { type: "memory-write", icon: 'rb:bg-[url("@/assets/images/workflow/memory-write.svg")]', config: { message: { type: 'editor', @@ -240,7 +218,7 @@ export const nodeLibrary: NodeLibrary[] = [ { category: "flowControl", nodes: [ - { type: "if-else", icon: conditionIcon, + { type: "if-else", icon: 'rb:bg-[url("@/assets/images/workflow/condition.svg")]', config: { cases: { type: 'caseList', @@ -253,7 +231,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "question-classifier", icon: questionClassifierIcon, + { type: "question-classifier", icon: 'rb:bg-[url("@/assets/images/workflow/question-classifier.svg")]', config: { model_id: { type: 'modelSelect', @@ -277,7 +255,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "iteration", icon: iterationIcon, + { type: "iteration", icon: 'rb:bg-[url("@/assets/images/workflow/iteration.svg")]', config: { input: { type: 'variableList', @@ -310,7 +288,7 @@ export const nodeLibrary: NodeLibrary[] = [ } }, }, - { type: "loop", icon: loopIcon, + { type: "loop", icon: 'rb:bg-[url("@/assets/images/workflow/loop.svg")]', config: { cycle_vars: { type: 'cycleVarsList', @@ -333,9 +311,10 @@ export const nodeLibrary: NodeLibrary[] = [ }, } }, - { type: "cycle-start", icon: startIcon }, - { type: "break", icon: breakIcon }, - { type: "var-aggregator", icon: aggregatorIcon, + { type: "cycle-start", icon: 'rb:bg-[url("@/assets/images/workflow/start.svg")]'}, + { type: "break", icon: 'rb:bg-[url("@/assets/images/workflow/break.svg")]'}, + { + type: "var-aggregator", icon: 'rb:bg-[url("@/assets/images/workflow/aggregator.svg")]', config: { group: { type: 'switch', @@ -350,7 +329,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "assigner", icon: assignerIcon, + { type: "assigner", icon: 'rb:bg-[url("@/assets/images/workflow/assigner.svg")]', config: { assignments: { type: 'assignmentList', @@ -363,7 +342,7 @@ export const nodeLibrary: NodeLibrary[] = [ { category: "externalInteraction", nodes: [ - { type: "http-request", icon: httpRequestIcon, + { type: "http-request", icon: 'rb:bg-[url("@/assets/images/workflow/http_request.svg")]', config: { method: { type: 'select', @@ -423,7 +402,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "tool", icon: toolsIcon, + { type: "tool", icon: 'rb:bg-[url("@/assets/images/workflow/tools.svg")]', config: { tool_id: { type: 'cascader' @@ -433,7 +412,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { type: "code", icon: codeExecutionIcon, + { type: "code", icon: 'rb:bg-[url("@/assets/images/workflow/code_execution.svg")]', config: { input_variables: { type: 'inputList', @@ -459,7 +438,7 @@ export const nodeLibrary: NodeLibrary[] = [ }, } }, - { type: "jinja-render", icon: templateRenderingIcon, + { type: "jinja-render", icon: 'rb:bg-[url("@/assets/images/workflow/template_rendering.svg")]', config: { mapping: { type: 'mappingList', @@ -474,7 +453,7 @@ export const nodeLibrary: NodeLibrary[] = [ }, } }, - { type: "document-extractor", icon: documentExtractorIcon, + { type: "document-extractor", icon: 'rb:bg-[url("@/assets/images/workflow/document-extractor.svg")]', config: { file_selector: { type: 'variableList', @@ -527,7 +506,8 @@ export const THEME_MAP: Record<string, { outer: string; title: string; bg: strin } export const notesConfig = { - type: "notes", icon: templateRenderingIcon, + type: "notes", + icon: 'rb:bg-[url("@/assets/images/workflow/unknown.svg")]', config: { text: { type: 'define', @@ -555,11 +535,11 @@ export const notesConfig = { } export const unknownNode = { type: 'unknown', - icon: unknownIcon + icon: 'rb:bg-[url("@/assets/images/workflow/unknown.svg")]' } export const noteNode = { type: 'notes', - icon: unknownIcon + icon: 'rb:bg-[url("@/assets/images/workflow/unknown.svg")]' } export const nodeWidth = 240; @@ -702,7 +682,7 @@ const defaultPortGroup = { body: { width: 1, height: 8, - x: -1, + x: 0.75, magnet: true, stroke: port_color, strokeWidth: edge_width, @@ -738,7 +718,7 @@ const leftPortGroup = { body: { width: 1, height: 8, - x: -1, + x: -1.75, y: -4, magnet: true, stroke: port_color, diff --git a/web/src/views/Workflow/index.tsx b/web/src/views/Workflow/index.tsx index b698e857..34ef91a7 100644 --- a/web/src/views/Workflow/index.tsx +++ b/web/src/views/Workflow/index.tsx @@ -68,7 +68,7 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC {/* 右侧画布区域 */} <div - className={clsx(`rb:fixed rb:top-18.5 rb:bottom-2.5 rb:left-0 rb:right-0 rb:transition-all`)} + className={clsx(`rb:fixed rb:top-16 rb:bottom-0 rb:left-0 rb:right-0 rb:transition-all`)} onDrop={onDrop} onDragOver={onDragOver} > From e77a1a92fdabe8d8c3f76dbc104a6746cddcac86 Mon Sep 17 00:00:00 2001 From: zhaoying <zhaoyingyz@126.com> Date: Wed, 1 Apr 2026 13:33:16 +0800 Subject: [PATCH 006/245] feat(web): skill toolList add is_active --- web/src/components/Layout/BasicAuthLayout.tsx | 5 +- .../Skills/components/ToolList/ToolList.tsx | 63 ++++--- .../views/Skills/components/ToolList/types.ts | 1 + web/src/views/Skills/pages/SkillConfig.tsx | 177 ++++++++++-------- 4 files changed, 130 insertions(+), 116 deletions(-) diff --git a/web/src/components/Layout/BasicAuthLayout.tsx b/web/src/components/Layout/BasicAuthLayout.tsx index 2f40ac37..094493c5 100644 --- a/web/src/components/Layout/BasicAuthLayout.tsx +++ b/web/src/components/Layout/BasicAuthLayout.tsx @@ -19,6 +19,7 @@ import { Outlet } from 'react-router-dom'; import { useEffect, type FC } from 'react'; +import { Layout } from 'antd'; import { useUser } from '@/store/user'; @@ -35,10 +36,10 @@ const BasicAuthLayout: FC = () => { }, [getUserInfo]); return ( - <div className="rb:relative rb:min-h-screen rb:w-screen"> + <Layout className="rb:min-h-screen!"> {/* Render child routes without additional UI */} <Outlet /> - </div> + </Layout> ) }; diff --git a/web/src/views/Skills/components/ToolList/ToolList.tsx b/web/src/views/Skills/components/ToolList/ToolList.tsx index 785e0a54..0df4da58 100644 --- a/web/src/views/Skills/components/ToolList/ToolList.tsx +++ b/web/src/views/Skills/components/ToolList/ToolList.tsx @@ -12,7 +12,7 @@ import { type FC, useRef, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Space, Button, List } from 'antd' +import { Space, Button, Flex } from 'antd' import Card from '@/views/ApplicationConfig/components/Card' import type { @@ -22,6 +22,7 @@ import type { import Empty from '@/components/Empty' import ToolModal from './ToolModal' import { getToolMethods, getToolDetail } from '@/api/tools' +import Tag from '@/components/Tag' /** * Tool List Component Props @@ -61,6 +62,7 @@ const ToolList: FC<ToolListProps> = ({value, onChange}) => { const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation) return { ...item, + is_active: (toolDetail as any).is_active, label: mcpFilterItem?.description, method_id: mcpFilterItem?.method_id, value: mcpFilterItem?.name, @@ -74,6 +76,7 @@ const ToolList: FC<ToolListProps> = ({value, onChange}) => { const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation) return { ...item, + is_active: (toolDetail as any).is_active, label: builtinFilterItem?.description, method_id: builtinFilterItem?.method_id, value: builtinFilterItem?.name, @@ -84,6 +87,7 @@ const ToolList: FC<ToolListProps> = ({value, onChange}) => { // Single method: Use first method return { ...item, + is_active: (toolDetail as any).is_active, label: (methods as any[])[0]?.description, method_id: (methods as any[])[0]?.method_id, value: (methods as any[])[0]?.name, @@ -96,6 +100,7 @@ const ToolList: FC<ToolListProps> = ({value, onChange}) => { const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation) return { ...item, + is_active: (toolDetail as any).is_active, label: customFilterItem?.name, method_id: customFilterItem?.method_id, value: customFilterItem?.name, @@ -129,7 +134,10 @@ const ToolList: FC<ToolListProps> = ({value, onChange}) => { * @param tool - Tool to add */ const updateTools = (tool: ToolOption) => { - const list = [...toolList, tool] + const list = [...toolList, { + ...tool, + is_active: true, + }] setToolList(list) onChange && onChange(list) } @@ -146,42 +154,35 @@ const ToolList: FC<ToolListProps> = ({value, onChange}) => { } return ( - <Card + <Card title={t('application.toolConfiguration')} extra={ - <Button style={{ padding: '0 8px', height: '24px' }} onClick={handleAddTool}> - + {t('application.addTool')} - </Button> + <Button className="rb:h-6! rb:py-0! rb:px-2! rb:rounded-md! rb:text-[#21233" onClick={handleAddTool}>+ {t('application.addTool')}</Button> } > - {/* Show empty state or tool list */} {toolList.length === 0 - ? <Empty size={88} /> - : - <List - grid={{ gutter: 12, column: 1 }} - dataSource={toolList} - renderItem={(item, index) => ( - <List.Item> - {/* Tool card with delete button */} - <div key={index} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg"> - {/* Tool label/description */} - <div className="rb:font-medium rb:leading-4"> - {item.label} - </div> - <Space size={12}> - {/* Delete button with hover effect */} - <div - className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]" - onClick={() => handleDeleteTool(index)} - ></div> - </Space> + ? <div className="rb-border rb:rounded-xl rb:pt-4 rb:pb-6"><Empty size={88} /></div> + : <Flex vertical gap={12}> + {toolList.map((item, index) => ( + <Flex key={index} align="center" justify="space-between" className="rb:py-2.5! rb:pl-4! rb:pr-3! rb-border rb:rounded-lg"> + <div> + <div className="rb:font-medium rb:leading-4"> + {item.label} </div> - </List.Item> - )} - /> + <Tag color={item.is_active ? 'success' : 'error'} className="rb:mt-1"> + {item.is_active ? t('common.enable') : t('common.deleted')} + </Tag> + </div> + <Space size={12}> + <div + className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]" + onClick={() => handleDeleteTool(index)} + ></div> + </Space> + </Flex> + ))} + </Flex> } - {/* Tool selection modal */} <ToolModal ref={toolModalRef} refresh={updateTools} diff --git a/web/src/views/Skills/components/ToolList/types.ts b/web/src/views/Skills/components/ToolList/types.ts index b0380f6a..4c7d908d 100644 --- a/web/src/views/Skills/components/ToolList/types.ts +++ b/web/src/views/Skills/components/ToolList/types.ts @@ -32,6 +32,7 @@ export interface ToolOption { tool_id?: string; /** Whether tool is enabled */ enabled?: boolean; + is_active?: boolean; } /** diff --git a/web/src/views/Skills/pages/SkillConfig.tsx b/web/src/views/Skills/pages/SkillConfig.tsx index f9f76dea..84c82378 100644 --- a/web/src/views/Skills/pages/SkillConfig.tsx +++ b/web/src/views/Skills/pages/SkillConfig.tsx @@ -7,17 +7,16 @@ import { type FC, useEffect, useRef, useState } from "react"; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import { Form, Input, Button, Space, Select, App } from 'antd' +import { Form, Input, Button, Space, Select, App, Flex } from 'antd' import Card from '@/views/ApplicationConfig/components/Card' -import aiPrompt from '@/assets/images/application/aiPrompt.png' import AiPromptModal from '@/views/ApplicationConfig/components/AiPromptModal' import ToolList from '../components/ToolList/ToolList' import type { AiPromptModalRef } from '@/views/ApplicationConfig/types' -import exitIcon from '@/assets/images/knowledgeBase/exit.png'; import type { SkillFormData } from '../types' import { getSkillDetail, createSkill, updateSkill } from '@/api/skill' import { stringRegExp } from '@/utils/validator'; +import PageHeader from '@/components/Layout/PageHeader' /** * Skill Configuration Page Component @@ -43,6 +42,7 @@ const SkillConfig: FC = () => { const { message } = App.useApp() const [loading, setLoading] = useState(false) const [form] = Form.useForm<SkillFormData>(); + const [data, setData] = useState<SkillFormData | null>(null) /** * Effect: Load skill data if editing existing skill @@ -70,6 +70,7 @@ const SkillConfig: FC = () => { getSkillDetail(id) .then(res => { form.setFieldsValue(res as SkillFormData) + setData(res as SkillFormData) }) .finally(() => { setLoading(false) @@ -131,93 +132,103 @@ const SkillConfig: FC = () => { } return ( - <div className="rb:w-250 rb:mt-5 rb:pb-5 rb:mx-auto"> - {/* Back button */} - <div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}> - <img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' /> - <span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span> - </div> - - <Form form={form} layout="vertical"> - <Space size={16} direction="vertical" className="rb:w-full"> - {/* Manifest Section: Basic skill information */} - <Card title={t('skills.mainfest')}> - <Form.Item - name="name" - label={t('skills.name')} - rules={[ - { required: true, message: t('common.inputPlaceholder', { title: t('skills.name') }) }, - { max: 50 }, - { pattern: stringRegExp, message: t('common.nameInvalid') }, - ]} + <Flex vertical className="rb:h-screen!"> + <PageHeader + title={data?.name} + extra={ + <Flex gap={12} align="center"> + {/* Save button */} + <Button type="primary" className="rb:px-2! rb:gap-0.5!" disabled={loading} onClick={handleSave}>{t('skills.save')}</Button> + <Button + className="rb:px-2! rb:gap-0.5!" + icon={<div className="rb:bg-[url('@/assets/images/workflow/return.svg')] rb:size-4 rb:bg-cover"></div>} + onClick={handleBack} > - <Input placeholder={t('common.pleaseEnter')} /> - </Form.Item> - <Form.Item - name="description" - label={t('skills.description')} - rules={[{ max: 500 }]} - > - <Input.TextArea placeholder={t('skills.descriptionPlaceholder')} /> - </Form.Item> - <Form.Item - name={['config', 'keywords']} - label={t('skills.keywords')} - rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('skills.keywords') }) }]} - > - <Select - mode="tags" - placeholder={t('common.pleaseEnter')} - /> - </Form.Item> - </Card> + {t('common.return')} + </Button> + </Flex> + } + /> + <div className="rb:w-250 rb:my-3 rb:mx-auto rb:flex-1 rb:overflow-y-auto"> + <Form form={form} layout="vertical"> + <Space size={16} direction="vertical" className="rb:w-full"> + {/* Manifest Section: Basic skill information */} + <Card title={t('skills.mainfest')}> + <Form.Item + name="name" + label={t('skills.name')} + rules={[ + { required: true, message: t('common.inputPlaceholder', { title: t('skills.name') }) }, + { max: 50 }, + { pattern: stringRegExp, message: t('common.nameInvalid') }, + ]} + > + <Input placeholder={t('common.pleaseEnter')} /> + </Form.Item> + <Form.Item + name="description" + label={t('skills.description')} + rules={[{ max: 500 }]} + > + <Input.TextArea placeholder={t('skills.descriptionPlaceholder')} /> + </Form.Item> + <Form.Item + name={['config', 'keywords']} + label={t('skills.keywords')} + rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('skills.keywords') }) }]} + > + <Select + mode="tags" + placeholder={t('common.pleaseEnter')} + /> + </Form.Item> + </Card> - {/* Prompt Configuration Section: AI instructions */} - <Card title={t('skills.promptConfiguration')} - extra={ - <Button style={{ padding: '0 8px', height: '24px' }} onClick={handlePrompt}> - <img src={aiPrompt} className="rb:size-5" /> - {t('skills.aiPrompt')} - </Button> - } - > + {/* Prompt Configuration Section: AI instructions */} + <Card title={t('skills.promptConfiguration')} + extra={ + <Button style={{ padding: '0 8px', height: '24px' }} onClick={handlePrompt}> + <div className="rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/application/aiPrompt.png')] rb:mr-1!" /> + {t('skills.aiPrompt')} + </Button> + } + > + <Form.Item + name="prompt" + className="rb:mb-0!" + > + <Input.TextArea + placeholder={t('skills.promptPlaceholder')} + styles={{ + textarea: { + minHeight: '200px', + borderRadius: '8px' + }, + }} + /> + </Form.Item> + </Card> + + {/* Tool Configuration Section */} <Form.Item - name="prompt" + name="tools" + rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('skills.tools') }) }]} className="rb:mb-0!" > - <Input.TextArea - placeholder={t('skills.promptPlaceholder')} - styles={{ - textarea: { - minHeight: '200px', - borderRadius: '8px' - }, - }} - /> + <ToolList /> </Form.Item> - </Card> - {/* Tool Configuration Section */} - <Form.Item - name="tools" - rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('skills.tools') }) }]} - className="rb:mb-0!" - > - <ToolList /> - </Form.Item> - - {/* Save button */} - <Button type="primary" block disabled={loading} onClick={handleSave}>{t('skills.save')}</Button> - </Space> - </Form> - - {/* AI Prompt Generation Modal */} - <AiPromptModal - ref={aiPromptModalRef} - refresh={updatePrompt} - source="skills" - /> - </div> + </Space> + </Form> + + {/* AI Prompt Generation Modal */} + <AiPromptModal + ref={aiPromptModalRef} + refresh={updatePrompt} + source="skills" + /> + </div> + </Flex> ) } From 264183cec287868449cca060531ab96358f41b17 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 1 Apr 2026 15:47:43 +0800 Subject: [PATCH 007/245] feat(models): support reasoning_content streaming --- api/app/controllers/ontology_controller.py | 1 + .../controllers/public_share_controller.py | 6 +- .../controllers/service/app_api_controller.py | 5 + api/app/core/agent/langchain_agent.py | 50 +++- api/app/core/memory/src/search.py | 3 +- api/app/core/models/base.py | 69 +++++- .../core/models/scripts/bedrock_models.yaml | 17 ++ .../core/models/scripts/dashscope_models.yaml | 225 +++++++++++++----- .../core/models/scripts/openai_models.yaml | 36 ++- .../core/models/scripts/volcano_models.yaml | 9 + api/app/core/models/volcano_chat.py | 38 +++ api/app/core/workflow/nodes/llm/node.py | 3 +- .../nodes/parameter_extractor/node.py | 4 +- .../nodes/question_classifier/node.py | 4 +- api/app/models/models_model.py | 2 +- api/app/schemas/app_schema.py | 3 + api/app/schemas/conversation_schema.py | 1 + api/app/services/app_chat_service.py | 20 +- api/app/services/conversation_service.py | 4 +- api/app/services/draft_run_service.py | 41 +++- api/app/services/llm_router.py | 1 + api/app/services/master_agent_router.py | 1 + api/app/services/memory_perceptual_service.py | 3 +- api/app/services/model_parameter_merger.py | 10 +- api/app/services/model_service.py | 32 ++- api/app/services/multi_agent_orchestrator.py | 2 + api/app/services/prompt_optimizer_service.py | 3 +- api/app/services/shared_chat_service.py | 11 +- 28 files changed, 495 insertions(+), 109 deletions(-) create mode 100644 api/app/core/models/volcano_chat.py diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index 3d2a1bdb..fe6b3598 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -163,6 +163,7 @@ def _get_ontology_service( api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, + support_thinking="thinking" in (api_key_config.capability or []), max_retries=3, timeout=60.0 ) diff --git a/api/app/controllers/public_share_controller.py b/api/app/controllers/public_share_controller.py index c10ad14b..ddd31071 100644 --- a/api/app/controllers/public_share_controller.py +++ b/api/app/controllers/public_share_controller.py @@ -453,6 +453,9 @@ async def chat( # 流式返回 agent_config = agent_config_4_app_release(release) + if not (agent_config.model_parameters.get("deep_thinking", False) and payload.thinking): + agent_config.model_parameters["deep_thinking"] = False + if payload.stream: async def event_generator(): async for event in app_chat_service.agnet_chat_stream( @@ -634,7 +637,8 @@ async def config_query( "app_type": release.app.type, "variables": release.config.get("variables"), "memory": release.config.get("memory", {}).get("enabled"), - "features": release.config.get("features") + "features": release.config.get("features"), + "model_parameters": release.config.get("model_parameters") } elif release.app.type == AppType.MULTI_AGENT: content = { diff --git a/api/app/controllers/service/app_api_controller.py b/api/app/controllers/service/app_api_controller.py index d4573464..93caa200 100644 --- a/api/app/controllers/service/app_api_controller.py +++ b/api/app/controllers/service/app_api_controller.py @@ -144,6 +144,11 @@ async def chat( # print(app.current_release.default_model_config_id) agent_config = agent_config_4_app_release(app.current_release) # print(agent_config.default_model_config_id) + + # thinking 开关:仅当 agent 配置了 deep_thinking 且请求 thinking=True 时才启用 + if not (agent_config.model_parameters.get("deep_thinking", False) and payload.thinking): + agent_config.model_parameters["deep_thinking"] = False + # 流式返回 if payload.stream: async def event_generator(): diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index 3bb2252f..044e7cc9 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -37,7 +37,10 @@ class LangChainAgent: tools: Optional[Sequence[BaseTool]] = None, streaming: bool = False, max_iterations: Optional[int] = None, # 最大迭代次数(None 表示自动计算) - max_tool_consecutive_calls: int = 3 # 单个工具最大连续调用次数 + max_tool_consecutive_calls: int = 3, # 单个工具最大连续调用次数 + deep_thinking: bool = False, # 是否启用深度思考模式 + thinking_budget_tokens: Optional[int] = None, # 深度思考 token 预算 + capability: Optional[List[str]] = None # 模型能力列表,用于校验是否支持深度思考 ): """初始化 LangChain Agent @@ -60,6 +63,7 @@ class LangChainAgent: self.streaming = streaming self.is_omni = is_omni self.max_tool_consecutive_calls = max_tool_consecutive_calls + self.deep_thinking = deep_thinking and ("thinking" in (capability or [])) # 工具调用计数器:记录每个工具的连续调用次数 self.tool_call_counter: Dict[str, int] = {} @@ -82,6 +86,13 @@ class LangChainAgent: f"auto_calculated={max_iterations is None}" ) + # 根据 capability 校验是否真正支持深度思考 + actual_deep_thinking = self.deep_thinking + if deep_thinking and not actual_deep_thinking: + logger.warning( + f"模型 {model_name} 不支持深度思考(capability 中无 'thinking'),已自动关闭 deep_thinking" + ) + # 创建 RedBearLLM(支持多提供商) model_config = RedBearModelConfig( model_name=model_name, @@ -89,10 +100,13 @@ class LangChainAgent: api_key=api_key, base_url=api_base, is_omni=is_omni, + deep_thinking=actual_deep_thinking, + thinking_budget_tokens=thinking_budget_tokens if actual_deep_thinking else None, + support_thinking="thinking" in (capability or []), extra_params={ "temperature": temperature, "max_tokens": max_tokens, - "streaming": streaming # 使用参数控制流式 + "streaming": streaming } ) @@ -310,6 +324,17 @@ class LangChainAgent: return content_parts + @staticmethod + def _extract_reasoning_content(msg) -> str: + """从 AIMessage 中提取深度思考内容(reasoning_content) + + 所有 provider 统一通过 additional_kwargs.reasoning_content 传递: + - DeepSeek-R1 / QwQ: 原生字段 + - Volcano (Doubao-thinking): 由 VolcanoChatOpenAI 从 delta.reasoning_content 注入 + """ + additional = getattr(msg, "additional_kwargs", None) or {} + return additional.get("reasoning_content") or additional.get("reasoning", "") + async def chat( self, message: str, @@ -375,6 +400,7 @@ class LangChainAgent: logger.debug(f"输出消息数量: {len(output_messages)}") total_tokens = 0 + reasoning_content = "" for msg in reversed(output_messages): if isinstance(msg, AIMessage): logger.debug(f"找到 AI 消息,content 类型: {type(msg.content)}") @@ -410,6 +436,7 @@ class LangChainAgent: content = str(msg.content) logger.debug(f"转换为字符串: {content[:100]}...") total_tokens = self._extract_tokens_from_message(msg) + reasoning_content = self._extract_reasoning_content(msg) if self.deep_thinking else "" break logger.info(f"最终提取的内容长度: {len(content)}") @@ -425,6 +452,8 @@ class LangChainAgent: "total_tokens": total_tokens } } + if reasoning_content: + response["reasoning_content"] = reasoning_content logger.debug( "Agent 调用完成", @@ -457,6 +486,8 @@ class LangChainAgent: Yields: str: 消息内容块 + int: token 统计 + Dict: 深度思考内容 {"type": "reasoning", "content": "..."} """ logger.info("=" * 80) logger.info(" chat_stream 方法开始执行") @@ -477,6 +508,7 @@ class LangChainAgent: # 统一使用 agent 的 astream_events 实现流式输出 logger.debug("使用 Agent astream_events 实现流式输出") full_content = '' + full_reasoning = '' try: last_event = {} async for event in self.agent.astream_events( @@ -493,6 +525,13 @@ class LangChainAgent: # LLM 流式输出 chunk = event.get("data", {}).get("chunk") if chunk and hasattr(chunk, "content"): + # 提取深度思考内容(仅在启用深度思考时) + if self.deep_thinking: + reasoning_chunk = self._extract_reasoning_content(chunk) + if reasoning_chunk: + full_reasoning += reasoning_chunk + yield {"type": "reasoning", "content": reasoning_chunk} + # 处理多模态响应:content 可能是字符串或列表 chunk_content = chunk.content if isinstance(chunk_content, str) and chunk_content: @@ -523,6 +562,13 @@ class LangChainAgent: chunk = event.get("data", {}).get("chunk") if chunk: if hasattr(chunk, "content"): + # 提取深度思考内容(仅在启用深度思考时) + if self.deep_thinking: + reasoning_chunk = self._extract_reasoning_content(chunk) + if reasoning_chunk: + full_reasoning += reasoning_chunk + yield {"type": "reasoning", "content": reasoning_chunk} + chunk_content = chunk.content if isinstance(chunk_content, str) and chunk_content: full_content += chunk_content diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index e4f0d4d0..a3c40dcd 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -758,8 +758,7 @@ async def run_hybrid_search( model_name=embedder_config_dict["model_name"], provider=embedder_config_dict["provider"], api_key=embedder_config_dict["api_key"], - base_url=embedder_config_dict["base_url"], - type="llm" + base_url=embedder_config_dict["base_url"] ) config_load_time = time.time() - config_load_start logger.info(f"[PERF] Config loading took {config_load_time:.4f}s") diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index a4dbc092..c7d8cfed 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.models.models_model import ModelProvider, ModelType +from app.core.models.volcano_chat import VolcanoChatOpenAI T = TypeVar("T") @@ -25,6 +26,9 @@ class RedBearModelConfig(BaseModel): api_key: str base_url: Optional[str] = None is_omni: bool = False # 是否为 Omni 模型 + deep_thinking: bool = False # 是否启用深度思考模式 + thinking_budget_tokens: Optional[int] = None # 深度思考 token 预算 + support_thinking: bool = False # 模型是否支持 enable_thinking 参数(capability 含 thinking) # 请求超时时间(秒)- 默认120秒以支持复杂的LLM调用,可通过环境变量 LLM_TIMEOUT 配置 timeout: float = Field(default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "120.0"))) # 最大重试次数 - 默认2次以避免过长等待,可通过环境变量 LLM_MAX_RETRIES 配置 @@ -44,7 +48,7 @@ class RedBearModelFactory: # 打印供应商信息用于调试 from app.core.logging_config import get_business_logger logger = get_business_logger() - logger.debug(f"获取模型参数 - Provider: {provider}, Model: {config.model_name}, is_omni: {config.is_omni}") + logger.debug(f"获取模型参数 - Provider: {provider}, Model: {config.model_name}, is_omni: {config.is_omni}, deep_thinking: {config.deep_thinking}") # dashscope 的 omni 模型使用 OpenAI 兼容模式 if provider == ModelProvider.DASHSCOPE and config.is_omni: @@ -58,7 +62,7 @@ class RedBearModelFactory: write=60.0, pool=10.0, ) - params = { + params: Dict[str, Any] = { "model": config.model_name, "base_url": config.base_url, "api_key": config.api_key, @@ -67,8 +71,19 @@ class RedBearModelFactory: **config.extra_params } # 流式模式下启用 stream_usage 以获取 token 统计 - if config.extra_params.get("streaming"): + is_streaming = bool(config.extra_params.get("streaming")) + if is_streaming: params["stream_usage"] = True + # 只有支持 thinking 的模型才传 enable_thinking + if config.support_thinking: + 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 + else: + model_kwargs["enable_thinking"] = False + params["model_kwargs"] = model_kwargs return params if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK, ModelProvider.OLLAMA, ModelProvider.VOLCANO]: @@ -82,7 +97,7 @@ class RedBearModelFactory: write=60.0, # 写入超时:60秒 pool=10.0, # 连接池超时:10秒 ) - params = { + params: Dict[str, Any] = { "model": config.model_name, "base_url": config.base_url, "api_key": config.api_key, @@ -93,17 +108,44 @@ class RedBearModelFactory: # 流式模式下启用 stream_usage 以获取 token 统计 if config.extra_params.get("streaming"): params["stream_usage"] = True + # 深度思考模式 + is_streaming = bool(config.extra_params.get("streaming")) + if is_streaming: + if provider == ModelProvider.VOLCANO: + # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 + thinking_config: Dict[str, Any] = { + "type": "enabled" if config.deep_thinking else "disabled" + } + if config.deep_thinking and config.thinking_budget_tokens: + thinking_config["budget_tokens"] = config.thinking_budget_tokens + params["extra_body"] = {"thinking": thinking_config} + else: + # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 + model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) + model_kwargs["enable_thinking"] = config.deep_thinking + if config.deep_thinking and config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens + params["model_kwargs"] = model_kwargs return params elif provider == ModelProvider.DASHSCOPE: - # DashScope (通义千问) 使用自己的参数格式 - # 注意: DashScopeEmbeddings 不支持 timeout 和 base_url 参数 - # 只支持: model, dashscope_api_key, max_retries, client - return { + params = { "model": config.model_name, "dashscope_api_key": config.api_key, "max_retries": config.max_retries, **config.extra_params } + # 只有支持 thinking 的模型才传 enable_thinking + if config.support_thinking: + is_streaming = bool(config.extra_params.get("streaming")) + 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 + else: + model_kwargs["enable_thinking"] = False + params["model_kwargs"] = model_kwargs + return params elif provider == ModelProvider.BEDROCK: # Bedrock 使用 AWS 凭证 # api_key 格式: "access_key_id:secret_access_key" 或只是 access_key_id @@ -142,6 +184,13 @@ class RedBearModelFactory: elif "region_name" not in params: params["region_name"] = "us-east-1" # 默认区域 + # 深度思考模式:Claude 3.7 Sonnet 等支持思考的模型 + # 通过 additional_model_request_fields 传递 thinking 块,关闭时不传(Bedrock 无 disabled 选项) + if config.deep_thinking: + budget = config.thinking_budget_tokens or 10000 + params["additional_model_request_fields"] = { + "thinking": {"type": "enabled", "budget_tokens": budget} + } return params else: raise BusinessException(f"不支持的提供商: {provider}", code=BizCode.PROVIDER_NOT_SUPPORTED) @@ -168,7 +217,9 @@ def get_provider_llm_class(config: RedBearModelConfig, type: ModelType = ModelTy # dashscope 的 omni 模型使用 OpenAI 兼容模式 if provider == ModelProvider.DASHSCOPE and config.is_omni: return ChatOpenAI - if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK, ModelProvider.VOLCANO]: + if provider == ModelProvider.VOLCANO: + return VolcanoChatOpenAI + if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK]: if type == ModelType.LLM: return OpenAI elif type == ModelType.CHAT: diff --git a/api/app/core/models/scripts/bedrock_models.yaml b/api/app/core/models/scripts/bedrock_models.yaml index 2c0ab757..5b3a2f64 100644 --- a/api/app/core/models/scripts/bedrock_models.yaml +++ b/api/app/core/models/scripts/bedrock_models.yaml @@ -11,6 +11,7 @@ models: tags: - 大语言模型 logo: bedrock + - name: amazon nova type: llm provider: bedrock @@ -27,6 +28,7 @@ models: - stream-tool-call - vision logo: bedrock + - name: anthropic claude type: llm provider: bedrock @@ -35,6 +37,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -44,6 +47,7 @@ models: - stream-tool-call - document logo: bedrock + - name: cohere type: llm provider: bedrock @@ -58,6 +62,7 @@ models: - tool-call - stream-tool-call logo: bedrock + - name: deepseek type: llm provider: bedrock @@ -66,6 +71,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -74,6 +80,7 @@ models: - tool-call - stream-tool-call logo: bedrock + - name: meta type: llm provider: bedrock @@ -87,6 +94,7 @@ models: - agent-thought - tool-call logo: bedrock + - name: mistral type: llm provider: bedrock @@ -100,6 +108,7 @@ models: - agent-thought - tool-call logo: bedrock + - name: openai type: llm provider: bedrock @@ -114,6 +123,7 @@ models: - tool-call - stream-tool-call logo: bedrock + - name: qwen type: llm provider: bedrock @@ -128,6 +138,7 @@ models: - tool-call - stream-tool-call logo: bedrock + - name: amazon.rerank-v1:0 type: rerank provider: bedrock @@ -139,6 +150,7 @@ models: tags: - 重排序模型 logo: bedrock + - name: cohere.rerank-v3-5:0 type: rerank provider: bedrock @@ -150,6 +162,7 @@ models: tags: - 重排序模型 logo: bedrock + - name: amazon.nova-2-multimodal-embeddings-v1:0 type: embedding provider: bedrock @@ -163,6 +176,7 @@ models: - 文本嵌入模型 - vision logo: bedrock + - name: amazon.titan-embed-text-v1 type: embedding provider: bedrock @@ -174,6 +188,7 @@ models: tags: - 文本嵌入模型 logo: bedrock + - name: amazon.titan-embed-text-v2:0 type: embedding provider: bedrock @@ -185,6 +200,7 @@ models: tags: - 文本嵌入模型 logo: bedrock + - name: cohere.embed-english-v3 type: embedding provider: bedrock @@ -196,6 +212,7 @@ models: tags: - 文本嵌入模型 logo: bedrock + - name: cohere.embed-multilingual-v3 type: embedding provider: bedrock diff --git a/api/app/core/models/scripts/dashscope_models.yaml b/api/app/core/models/scripts/dashscope_models.yaml index 89a16966..d1b604e0 100644 --- a/api/app/core/models/scripts/dashscope_models.yaml +++ b/api/app/core/models/scripts/dashscope_models.yaml @@ -6,36 +6,42 @@ models: description: DeepSeek-R1-Distill-Qwen-14B大语言模型,支持智能体思考,32000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - agent-thought logo: dashscope + - name: deepseek-r1-distill-qwen-32b type: llm provider: dashscope description: DeepSeek-R1-Distill-Qwen-32B大语言模型,支持智能体思考,32000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - agent-thought logo: dashscope + - name: deepseek-r1 type: llm provider: dashscope description: DeepSeek-R1大语言模型,支持智能体思考,131072超大上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - agent-thought logo: dashscope + - name: deepseek-v3.1 type: llm provider: dashscope @@ -48,6 +54,7 @@ models: - 大语言模型 - agent-thought logo: dashscope + - name: deepseek-v3.2-exp type: llm provider: dashscope @@ -60,6 +67,7 @@ models: - 大语言模型 - agent-thought logo: dashscope + - name: deepseek-v3.2 type: llm provider: dashscope @@ -72,6 +80,7 @@ models: - 大语言模型 - agent-thought logo: dashscope + - name: deepseek-v3 type: llm provider: dashscope @@ -84,6 +93,7 @@ models: - 大语言模型 - agent-thought logo: dashscope + - name: farui-plus type: llm provider: dashscope @@ -98,6 +108,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: glm-4.7 type: llm provider: dashscope @@ -112,6 +123,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qvq-max-latest type: llm provider: dashscope @@ -119,7 +131,8 @@ models: is_deprecated: false is_official: true capability: - - vision + - vision + - thinking is_omni: false tags: - 大语言模型 @@ -127,6 +140,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qvq-max type: llm provider: dashscope @@ -134,7 +148,8 @@ models: is_deprecated: false is_official: true capability: - - vision + - vision + - thinking is_omni: false tags: - 大语言模型 @@ -142,6 +157,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-coder-turbo-0919 type: llm provider: dashscope @@ -155,13 +171,15 @@ models: - 代码模型 - agent-thought logo: dashscope + - name: qwen-max-latest type: llm provider: dashscope description: qwen-max-latest大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式,支持联网搜索 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -169,6 +187,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-max-longcontext type: llm provider: dashscope @@ -183,13 +202,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-max type: llm provider: dashscope description: qwen-max大语言模型,支持多工具调用、智能体思考、流式工具调用,32768上下文窗口,对话模式,支持联网搜索 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -197,6 +218,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-mt-plus type: llm provider: dashscope @@ -210,6 +232,7 @@ models: - 翻译模型 - agent-thought logo: dashscope + - name: qwen-mt-turbo type: llm provider: dashscope @@ -223,6 +246,7 @@ models: - 翻译模型 - agent-thought logo: dashscope + - name: qwen-plus-0112 type: llm provider: dashscope @@ -237,6 +261,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-0125 type: llm provider: dashscope @@ -251,6 +276,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-0723 type: llm provider: dashscope @@ -265,6 +291,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-0806 type: llm provider: dashscope @@ -279,6 +306,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-0919 type: llm provider: dashscope @@ -293,6 +321,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-1125 type: llm provider: dashscope @@ -307,6 +336,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-1127 type: llm provider: dashscope @@ -321,6 +351,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-plus-1220 type: llm provider: dashscope @@ -335,6 +366,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen-vl-max type: chat provider: dashscope @@ -342,8 +374,8 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video is_omni: false tags: - 大语言模型 @@ -352,6 +384,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen-vl-plus-0809 type: chat provider: dashscope @@ -359,8 +392,8 @@ models: is_deprecated: true is_official: true capability: - - vision - - video + - vision + - video is_omni: false tags: - 大语言模型 @@ -369,6 +402,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen-vl-plus-2025-01-02 type: chat provider: dashscope @@ -376,8 +410,8 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video is_omni: false tags: - 大语言模型 @@ -386,6 +420,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen-vl-plus-2025-01-25 type: chat provider: dashscope @@ -393,8 +428,8 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video is_omni: false tags: - 大语言模型 @@ -403,6 +438,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen-vl-plus-latest type: chat provider: dashscope @@ -410,8 +446,8 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video is_omni: false tags: - 大语言模型 @@ -420,6 +456,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen-vl-plus type: chat provider: dashscope @@ -427,8 +464,8 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video is_omni: false tags: - 大语言模型 @@ -437,6 +474,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen2.5-0.5b-instruct type: llm provider: dashscope @@ -451,13 +489,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-14b type: llm provider: dashscope description: qwen3-14b大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -465,13 +505,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-235b-a22b-instruct-2507 type: llm provider: dashscope description: qwen3-235b-a22b-instruct-2507大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -479,13 +521,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-235b-a22b-thinking-2507 type: llm provider: dashscope description: qwen3-235b-a22b-thinking-2507大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -493,13 +537,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-235b-a22b type: llm provider: dashscope description: qwen3-235b-a22b大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -507,13 +553,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-30b-a3b-instruct-2507 type: llm provider: dashscope description: qwen3-30b-a3b-instruct-2507大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -521,13 +569,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-30b-a3b type: llm provider: dashscope description: qwen3-30b-a3b大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -535,13 +585,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-32b type: llm provider: dashscope description: qwen3-32b大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -549,13 +601,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-4b type: llm provider: dashscope description: qwen3-4b大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -563,13 +617,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-8b type: llm provider: dashscope description: qwen3-8b大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -577,65 +633,75 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-coder-30b-a3b-instruct type: llm provider: dashscope description: qwen3-coder-30b-a3b-instruct大语言模型,支持智能体思考,262144上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - 代码模型 - agent-thought logo: dashscope + - name: qwen3-coder-480b-a35b-instruct type: llm provider: dashscope description: qwen3-coder-480b-a35b-instruct大语言模型,支持智能体思考,262144上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - 代码模型 - agent-thought logo: dashscope + - name: qwen3-coder-plus-2025-09-23 type: llm provider: dashscope description: qwen3-coder-plus-2025-09-23大语言模型,支持智能体思考,1000000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - 代码模型 - agent-thought logo: dashscope + - name: qwen3-coder-plus type: llm provider: dashscope description: qwen3-coder-plus大语言模型,支持智能体思考,1000000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - 代码模型 - agent-thought logo: dashscope + - name: qwen3-max-2025-09-23 type: llm provider: dashscope description: qwen3-max-2025-09-23大语言模型,支持多工具调用、智能体思考、流式工具调用,262144上下文窗口,对话模式,支持联网搜索 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -644,13 +710,15 @@ models: - stream-tool-call - 联网搜索 logo: dashscope + - name: qwen3-max-2026-01-23 type: llm provider: dashscope description: qwen3-max-2026-01-23大语言模型,支持多工具调用、智能体思考、流式工具调用,262144上下文窗口,对话模式,支持联网搜索 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -659,13 +727,15 @@ models: - stream-tool-call - 联网搜索 logo: dashscope + - name: qwen3-max-preview type: llm provider: dashscope description: qwen3-max-preview大语言模型,支持多工具调用、智能体思考、流式工具调用,262144上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -673,13 +743,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-max type: llm provider: dashscope description: qwen3-max大语言模型,支持多工具调用、智能体思考、流式工具调用,262144上下文窗口,对话模式,支持联网搜索 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -688,13 +760,15 @@ models: - stream-tool-call - 联网搜索 logo: dashscope + - name: qwen3-next-80b-a3b-instruct type: llm provider: dashscope description: qwen3-next-80b-a3b-instruct大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -702,13 +776,15 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-next-80b-a3b-thinking type: llm provider: dashscope description: qwen3-next-80b-a3b-thinking大语言模型,支持多工具调用、智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -716,6 +792,7 @@ models: - agent-thought - stream-tool-call logo: dashscope + - name: qwen3-omni-flash-2025-12-01 type: llm provider: dashscope @@ -723,9 +800,10 @@ models: is_deprecated: false is_official: true capability: - - vision - - video - - audio + - vision + - video + - audio + - thinking is_omni: true tags: - 大语言模型 @@ -735,6 +813,7 @@ models: - video - audio logo: dashscope + - name: qwen3-vl-235b-a22b-instruct type: chat provider: dashscope @@ -742,8 +821,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -754,6 +834,7 @@ models: - vision - video logo: dashscope + - name: qwen3-vl-235b-a22b-thinking type: chat provider: dashscope @@ -761,8 +842,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -773,6 +855,7 @@ models: - vision - video logo: dashscope + - name: qwen3-vl-30b-a3b-instruct type: chat provider: dashscope @@ -780,8 +863,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -792,6 +876,7 @@ models: - vision - video logo: dashscope + - name: qwen3-vl-30b-a3b-thinking type: chat provider: dashscope @@ -799,8 +884,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -811,6 +897,7 @@ models: - vision - video logo: dashscope + - name: qwen3-vl-flash type: chat provider: dashscope @@ -818,8 +905,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -830,6 +918,7 @@ models: - vision - video logo: dashscope + - name: qwen3-vl-plus-2025-09-23 type: chat provider: dashscope @@ -837,8 +926,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -847,6 +937,7 @@ models: - agent-thought - video logo: dashscope + - name: qwen3-vl-plus type: chat provider: dashscope @@ -854,8 +945,9 @@ models: is_deprecated: false is_official: true capability: - - vision - - video + - vision + - video + - thinking is_omni: false tags: - 大语言模型 @@ -864,45 +956,52 @@ models: - agent-thought - video logo: dashscope + - name: qwq-32b type: llm provider: dashscope description: qwq-32b大语言模型,支持智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - agent-thought - stream-tool-call logo: dashscope + - name: qwq-plus-0305 type: llm provider: dashscope description: qwq-plus-0305大语言模型,支持智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - agent-thought - stream-tool-call logo: dashscope + - name: qwq-plus type: llm provider: dashscope description: qwq-plus大语言模型,支持智能体思考、流式工具调用,131072上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 - agent-thought - stream-tool-call logo: dashscope + - name: gte-rerank-v2 type: rerank provider: dashscope @@ -914,6 +1013,7 @@ models: tags: - 重排序模型 logo: dashscope + - name: gte-rerank type: rerank provider: dashscope @@ -925,6 +1025,7 @@ models: tags: - 重排序模型 logo: dashscope + - name: multimodal-embedding-v1 type: embedding provider: dashscope @@ -932,13 +1033,14 @@ models: is_deprecated: false is_official: true capability: - - vision + - vision is_omni: false tags: - 嵌入模型 - 多模态模型 - vision logo: dashscope + - name: text-embedding-v1 type: embedding provider: dashscope @@ -951,6 +1053,7 @@ models: - 嵌入模型 - 文本嵌入 logo: dashscope + - name: text-embedding-v2 type: embedding provider: dashscope @@ -963,6 +1066,7 @@ models: - 嵌入模型 - 文本嵌入 logo: dashscope + - name: text-embedding-v3 type: embedding provider: dashscope @@ -975,6 +1079,7 @@ models: - 嵌入模型 - 文本嵌入 logo: dashscope + - name: text-embedding-v4 type: embedding provider: dashscope @@ -986,4 +1091,4 @@ models: tags: - 嵌入模型 - 文本嵌入 - logo: dashscope \ No newline at end of file + logo: dashscope diff --git a/api/app/core/models/scripts/openai_models.yaml b/api/app/core/models/scripts/openai_models.yaml index 7f6d3a51..08b81008 100644 --- a/api/app/core/models/scripts/openai_models.yaml +++ b/api/app/core/models/scripts/openai_models.yaml @@ -20,6 +20,7 @@ models: - audio - video logo: openai + - name: gpt-3.5-turbo-0125 type: llm provider: openai @@ -34,6 +35,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-3.5-turbo-1106 type: llm provider: openai @@ -48,6 +50,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-3.5-turbo-16k type: llm provider: openai @@ -62,6 +65,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-3.5-turbo-instruct type: llm provider: openai @@ -73,6 +77,7 @@ models: tags: - 大语言模型 logo: openai + - name: gpt-3.5-turbo type: llm provider: openai @@ -87,6 +92,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-4-0125-preview type: llm provider: openai @@ -101,6 +107,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-4-1106-preview type: llm provider: openai @@ -115,6 +122,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-4-turbo-2024-04-09 type: llm provider: openai @@ -131,6 +139,7 @@ models: - stream-tool-call - vision logo: openai + - name: gpt-4-turbo-preview type: llm provider: openai @@ -145,6 +154,7 @@ models: - agent-thought - stream-tool-call logo: openai + - name: gpt-4-turbo type: llm provider: openai @@ -161,6 +171,7 @@ models: - stream-tool-call - vision logo: openai + - name: o1-preview type: llm provider: openai @@ -173,6 +184,7 @@ models: - 大语言模型 - agent-thought logo: openai + - name: o1 type: llm provider: openai @@ -181,6 +193,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -190,6 +203,7 @@ models: - vision - structured-output logo: openai + - name: o3-2025-04-16 type: llm provider: openai @@ -198,6 +212,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -207,13 +222,15 @@ models: - stream-tool-call - structured-output logo: openai + - name: o3-mini-2025-01-31 type: llm provider: openai description: o3-mini-2025-01-31大语言模型,支持智能体思考、工具调用、流式工具调用、结构化输出,200000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -222,13 +239,15 @@ models: - stream-tool-call - structured-output logo: openai + - name: o3-mini type: llm provider: openai description: o3-mini大语言模型,支持智能体思考、工具调用、流式工具调用、结构化输出,200000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - thinking is_omni: false tags: - 大语言模型 @@ -237,6 +256,7 @@ models: - stream-tool-call - structured-output logo: openai + - name: o3-pro-2025-06-10 type: llm provider: openai @@ -245,6 +265,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -253,6 +274,7 @@ models: - vision - structured-output logo: openai + - name: o3-pro type: llm provider: openai @@ -261,6 +283,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -269,6 +292,7 @@ models: - vision - structured-output logo: openai + - name: o3 type: llm provider: openai @@ -277,6 +301,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -286,6 +311,7 @@ models: - stream-tool-call - structured-output logo: openai + - name: o4-mini-2025-04-16 type: llm provider: openai @@ -294,6 +320,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -303,6 +330,7 @@ models: - stream-tool-call - structured-output logo: openai + - name: o4-mini type: llm provider: openai @@ -311,6 +339,7 @@ models: is_official: true capability: - vision + - thinking is_omni: false tags: - 大语言模型 @@ -320,6 +349,7 @@ models: - stream-tool-call - structured-output logo: openai + - name: text-embedding-3-large type: embedding provider: openai @@ -331,6 +361,7 @@ models: tags: - 文本向量模型 logo: openai + - name: text-embedding-3-small type: embedding provider: openai @@ -342,6 +373,7 @@ models: tags: - 文本向量模型 logo: openai + - name: text-embedding-ada-002 type: embedding provider: openai diff --git a/api/app/core/models/scripts/volcano_models.yaml b/api/app/core/models/scripts/volcano_models.yaml index 24609f5a..c86d41ac 100644 --- a/api/app/core/models/scripts/volcano_models.yaml +++ b/api/app/core/models/scripts/volcano_models.yaml @@ -10,6 +10,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -24,6 +25,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -38,6 +40,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -52,6 +55,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -82,6 +86,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -96,6 +101,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -110,6 +116,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -124,6 +131,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 @@ -139,6 +147,7 @@ models: capability: - vision - video + - thinking is_omni: false tags: - 大语言模型 diff --git a/api/app/core/models/volcano_chat.py b/api/app/core/models/volcano_chat.py new file mode 100644 index 00000000..d86484a5 --- /dev/null +++ b/api/app/core/models/volcano_chat.py @@ -0,0 +1,38 @@ +""" +火山引擎 ChatOpenAI 扩展 + +ChatOpenAI 在解析流式 SSE 时只取 delta.content,会丢弃 delta.reasoning_content。 +此类仅重写 _convert_chunk_to_generation_chunk,将 reasoning_content 补入 additional_kwargs。 +""" +from __future__ import annotations + +from typing import Any, Optional + +from langchain_core.outputs import ChatGenerationChunk +from langchain_openai import ChatOpenAI + + +class VolcanoChatOpenAI(ChatOpenAI): + """火山引擎 Chat 模型,支持深度思考内容(reasoning_content)的流式透传。""" + + def _convert_chunk_to_generation_chunk( + self, + chunk: dict, + default_chunk_class: type, + base_generation_info: Optional[dict], + ) -> Optional[ChatGenerationChunk]: + gen_chunk = super()._convert_chunk_to_generation_chunk( + chunk, default_chunk_class, base_generation_info + ) + if gen_chunk is None: + return None + + # 从原始 chunk 中提取 reasoning_content + choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices", []) + if choices: + delta = choices[0].get("delta") or {} + reasoning: Any = delta.get("reasoning_content") + if reasoning: + gen_chunk.message.additional_kwargs["reasoning_content"] = reasoning + + return gen_chunk diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index a691001f..e3c68420 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -135,7 +135,8 @@ class LLMNode(BaseNode): api_key=model_info.api_key, base_url=model_info.api_base, extra_params=extra_params, - is_omni=model_info.is_omni + is_omni=model_info.is_omni, + support_thinking="thinking" in (model_info.capability or []), ), type=model_info.model_type ) diff --git a/api/app/core/workflow/nodes/parameter_extractor/node.py b/api/app/core/workflow/nodes/parameter_extractor/node.py index 3dc5fcc3..d7a2a501 100644 --- a/api/app/core/workflow/nodes/parameter_extractor/node.py +++ b/api/app/core/workflow/nodes/parameter_extractor/node.py @@ -109,6 +109,7 @@ class ParameterExtractorNode(BaseNode): api_key = api_config.api_key api_base = api_config.api_base is_omni = api_config.is_omni + capability = api_config.capability model_type = config.type llm = RedBearLLM( @@ -117,7 +118,8 @@ class ParameterExtractorNode(BaseNode): provider=provider, api_key=api_key, base_url=api_base, - is_omni=is_omni + is_omni=is_omni, + support_thinking="thinking" in (capability or []), ), type=ModelType(model_type) ) diff --git a/api/app/core/workflow/nodes/question_classifier/node.py b/api/app/core/workflow/nodes/question_classifier/node.py index 31fadaf6..ddae1ced 100644 --- a/api/app/core/workflow/nodes/question_classifier/node.py +++ b/api/app/core/workflow/nodes/question_classifier/node.py @@ -62,6 +62,7 @@ class QuestionClassifierNode(BaseNode): api_key = api_config.api_key base_url = api_config.api_base is_omni = api_config.is_omni + capability = api_config.capability model_type = config.type return RedBearLLM( @@ -70,7 +71,8 @@ class QuestionClassifierNode(BaseNode): provider=provider, api_key=api_key, base_url=base_url, - is_omni=is_omni + is_omni=is_omni, + support_thinking="thinking" in (capability or []), ), type=ModelType(model_type) ) diff --git a/api/app/models/models_model.py b/api/app/models/models_model.py index 69bedc3d..fab85ea6 100644 --- a/api/app/models/models_model.py +++ b/api/app/models/models_model.py @@ -81,7 +81,7 @@ class ModelConfig(BaseModel): # 模型配置参数 capability = Column(ARRAY(String), default=list, nullable=False, server_default=text("'{}'::varchar[]"), - comment="模型能力列表(如['vision', 'audio', 'video'])") + comment="模型能力列表(如['vision', 'audio', 'video', 'thinking'])") is_omni = Column(Boolean, default=False, nullable=False, server_default="false", comment="是否为Omni模型(使用特殊API调用)") config = Column(JSON, comment="模型配置参数") # - temperature : 控制生成文本的随机性。值越高,输出越随机、越有创造性;值越低,输出越确定、越保守。 diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index f1e9132f..4ca3e7de 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -241,6 +241,8 @@ class ModelParameters(BaseModel): presence_penalty: float = Field(default=0.0, ge=-2.0, le=2.0, description="存在惩罚") n: int = Field(default=1, ge=1, le=10, description="生成的回复数量") stop: Optional[List[str]] = Field(default=None, description="停止序列") + deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)") + thinking_budget_tokens: Optional[int] = Field(default=None, ge=1024, le=131072, description="深度思考 token 预算(仅部分模型支持)") class VariableDefinition(BaseModel): @@ -612,6 +614,7 @@ class AppChatRequest(BaseModel): user_id: Optional[str] = Field(default=None, description="用户ID(用于会话管理)") variables: Optional[Dict[str, Any]] = Field(default=None, description="自定义变量参数值") stream: bool = Field(default=False, description="是否流式返回") + thinking: bool = Field(default=False, description="是否启用深度思考(需Agent配置支持)") files: List[FileInput] = Field(default_factory=list, description="附件列表(支持多文件)") diff --git a/api/app/schemas/conversation_schema.py b/api/app/schemas/conversation_schema.py index 98715612..b2f565ef 100644 --- a/api/app/schemas/conversation_schema.py +++ b/api/app/schemas/conversation_schema.py @@ -31,6 +31,7 @@ class ChatRequest(BaseModel): stream: bool = Field(default=False, description="是否流式返回") 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="附件列表(支持多文件)") diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index a3ba860c..53ac577a 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -117,7 +117,9 @@ class AppChatService: max_tokens=model_parameters.get("max_tokens", 2000), system_prompt=system_prompt, tools=tools, - + deep_thinking=model_parameters.get("deep_thinking", False), + thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + capability=api_key_obj.capability or [], ) model_info = ModelInfo( @@ -205,7 +207,8 @@ class AppChatService: "model": api_key_obj.model_name, "usage": result.get("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}), "audio_url": None, - "citations": filtered_citations + "citations": filtered_citations, + "reasoning_content": result.get("reasoning_content") } if files: for f in files: @@ -258,6 +261,7 @@ class AppChatService: "conversation_id": conversation_id, "message_id": str(message_id), "message": result["content"], + "reasoning_content": result.get("reasoning_content"), "usage": result.get("usage", { "prompt_tokens": 0, "completion_tokens": 0, @@ -354,7 +358,10 @@ class AppChatService: max_tokens=model_parameters.get("max_tokens", 2000), system_prompt=system_prompt, tools=tools, - streaming=True + streaming=True, + deep_thinking=model_parameters.get("deep_thinking", False), + thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + capability=api_key_obj.capability or [], ) model_info = ModelInfo( @@ -403,6 +410,7 @@ class AppChatService: # 流式调用 Agent(支持多模态),同时并行启动 TTS full_content = "" + full_reasoning = "" total_tokens = 0 text_queue: asyncio.Queue = asyncio.Queue() @@ -426,6 +434,9 @@ class AppChatService: ): if isinstance(chunk, int): total_tokens = chunk + elif isinstance(chunk, dict) and chunk.get("type") == "reasoning": + full_reasoning += chunk['content'] + yield f"event: reasoning\ndata: {json.dumps({'content': chunk['content']}, ensure_ascii=False)}\n\n" else: full_content += chunk yield f"event: message\ndata: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n" @@ -472,7 +483,8 @@ class AppChatService: "model": api_key_obj.model_name, "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": total_tokens}, "audio_url": None, - "citations": filtered_citations + "citations": filtered_citations, + "reasoning_content": full_reasoning or None } if files: diff --git a/api/app/services/conversation_service.py b/api/app/services/conversation_service.py index bd7f7496..6e9f3544 100644 --- a/api/app/services/conversation_service.py +++ b/api/app/services/conversation_service.py @@ -534,6 +534,7 @@ class ConversationService: api_key = api_config.api_key api_base = api_config.api_base is_omni = api_config.is_omni + capability = api_config.capability model_type = config.type llm = RedBearLLM( @@ -542,7 +543,8 @@ class ConversationService: provider=provider, api_key=api_key, base_url=api_base, - is_omni=is_omni + is_omni=is_omni, + support_thinking="thinking" in (capability or []), ), type=ModelType(model_type) ) diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 4b503f2b..978dfdab 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -458,7 +458,7 @@ class AgentRunService: statement = opening["statement"] suggested_questions = opening["suggested_questions"] - + # 如果有变量,进行替换(仅支持 {{var_name}} 格式) if variables: for var_name, var_value in variables.items(): @@ -595,6 +595,9 @@ class AgentRunService: max_tokens=effective_params.get("max_tokens", 2000), system_prompt=system_prompt, tools=tools, + deep_thinking=effective_params.get("deep_thinking", False), + thinking_budget_tokens=effective_params.get("thinking_budget_tokens"), + capability=api_key_config.get("capability", []), ) # 5. 处理会话ID(创建或验证),新会话时写入开场白 @@ -689,7 +692,8 @@ class AgentRunService: "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 - }) + }), + "reasoning_content": result.get("reasoning_content") }, files=files, processed_files=processed_files, @@ -701,6 +705,7 @@ class AgentRunService: response = { "message": result["content"], + "reasoning_content": result.get("reasoning_content"), "conversation_id": conversation_id, "usage": result.get("usage", { "prompt_tokens": 0, @@ -838,7 +843,10 @@ class AgentRunService: max_tokens=effective_params.get("max_tokens", 2000), system_prompt=system_prompt, tools=tools, - streaming=True + streaming=True, + deep_thinking=effective_params.get("deep_thinking", False), + thinking_budget_tokens=effective_params.get("thinking_budget_tokens"), + capability=api_key_config.get("capability", []), ) # 5. 处理会话ID(创建或验证),新会话时写入开场白 @@ -898,6 +906,7 @@ class AgentRunService: # 9. 流式调用 Agent(支持多模态),同时并行启动 TTS full_content = "" + full_reasoning = "" total_tokens = 0 # 启动流式 TTS(文本边输出边合成) @@ -916,6 +925,9 @@ class AgentRunService: ): if isinstance(chunk, int): total_tokens = chunk + elif isinstance(chunk, dict) and chunk.get("type") == "reasoning": + full_reasoning += chunk["content"] + yield self._format_sse_event("reasoning", {"content": chunk["content"]}) else: full_content += chunk yield self._format_sse_event("message", {"content": chunk}) @@ -944,7 +956,8 @@ class AgentRunService: app_id=agent_config.app_id, user_id=user_id, meta_data={ - "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": total_tokens} + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": total_tokens}, + "reasoning_content": full_reasoning or None }, files=files, processed_files=processed_files, @@ -1665,7 +1678,7 @@ class AgentRunService: """从 text_queue 取文本按句子切分后喂给 synthesizer""" import re buf = "" - sentence_end = re.compile(r'[\u3002\uff01\uff1f\.!?\n]') + sentence_end = re.compile(r'[\u3002\uff01\uff1f.!?\n]') while True: chunk = await text_queue.get() if chunk is None: @@ -1894,6 +1907,7 @@ class AgentRunService: "conversation_id": result['conversation_id'], "parameters_used": model_info["parameters"], "message": result.get("message"), + "reasoning_content": result.get("reasoning_content"), "usage": usage, "elapsed_time": elapsed, "tokens_per_second": ( @@ -2012,7 +2026,7 @@ class AgentRunService: # 需要从 ModelApiKey 获取实际的模型名称,或者在 ModelConfig 中添加 model 字段 return None - def _with_parameters(self, agent_config: AgentConfig, parameters: Dict[str, Any]) -> AgentConfig: + def _with_parameters(self, agent_config: AgentConfig, parameters: Dict[str, Any]) -> tuple[AgentConfig, Any]: """创建一个带有覆盖参数的 agent_config(浅拷贝,只修改 model_parameters) Args: @@ -2110,6 +2124,7 @@ class AgentRunService: start_time = time.time() full_content = "" + full_reasoning = "" returned_conversation_id = model_conversation_id audio_url = None audio_status = None @@ -2168,6 +2183,18 @@ class AgentRunService: "content": chunk })) + # 转发深度思考事件(带模型标识) + if event_type == "reasoning" and event_data: + reasoning_chunk = event_data.get("content", "") + full_reasoning += reasoning_chunk + await event_queue.put(self._format_sse_event("model_reasoning", { + "model_index": idx, + "model_config_id": model_config_id, + "label": model_label, + "conversation_id": returned_conversation_id, + "content": event_data.get("content", "") + })) + # 从 end 事件中提取 features 输出字段 if event_type == "end" and event_data: audio_url = event_data.get("audio_url") @@ -2199,6 +2226,7 @@ class AgentRunService: "conversation_id": returned_conversation_id, "parameters_used": model_info["parameters"], "message": full_content, + "reasoning_content": full_reasoning or None, "elapsed_time": elapsed, "audio_url": audio_url, "audio_status": audio_status, @@ -2351,6 +2379,7 @@ class AgentRunService: "label": r["label"], "conversation_id": r.get("conversation_id"), "message": r.get("message"), + "reasoning_content": r.get("reasoning_content"), "elapsed_time": r.get("elapsed_time", 0), "audio_url": r.get("audio_url"), "audio_status": r.get("audio_status"), diff --git a/api/app/services/llm_router.py b/api/app/services/llm_router.py index 02895d6b..7087415e 100644 --- a/api/app/services/llm_router.py +++ b/api/app/services/llm_router.py @@ -415,6 +415,7 @@ class LLMRouter: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, + support_thinking="thinking" in (api_key_config.capability or []), temperature=0.3, max_tokens=500 ) diff --git a/api/app/services/master_agent_router.py b/api/app/services/master_agent_router.py index 954d3b2b..206443bd 100644 --- a/api/app/services/master_agent_router.py +++ b/api/app/services/master_agent_router.py @@ -393,6 +393,7 @@ class MasterAgentRouter: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, + support_thinking="thinking" in (api_key_config.capability or []), extra_params = extra_params ) diff --git a/api/app/services/memory_perceptual_service.py b/api/app/services/memory_perceptual_service.py index 7cf94a1a..7d6d1092 100644 --- a/api/app/services/memory_perceptual_service.py +++ b/api/app/services/memory_perceptual_service.py @@ -232,7 +232,8 @@ class MemoryPerceptualService: provider=model_config.provider, api_key=model_config.api_key, base_url=model_config.api_base, - is_omni=model_config.is_omni + is_omni=model_config.is_omni, + support_thinking="thinking" in (model_config.capability or []), ) ) return llm, model_config diff --git a/api/app/services/model_parameter_merger.py b/api/app/services/model_parameter_merger.py index 262e3d49..4be83851 100644 --- a/api/app/services/model_parameter_merger.py +++ b/api/app/services/model_parameter_merger.py @@ -45,12 +45,20 @@ class ModelParameterMerger: "frequency_penalty": 0.0, "presence_penalty": 0.0, "n": 1, - "stop": None + "stop": None, + "deep_thinking": False, + "thinking_budget_tokens": None } # 合并参数:默认值 -> 模型配置 -> Agent 配置 merged = default_params.copy() + # Pydantic 对象转为 dict + if model_config_params and hasattr(model_config_params, 'model_dump'): + model_config_params = model_config_params.model_dump() + if agent_config_params and hasattr(agent_config_params, 'model_dump'): + agent_config_params = agent_config_params.model_dump() + # 应用模型配置参数 if model_config_params: for key in default_params: diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index c9266667..4cbb3509 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -85,15 +85,16 @@ class ModelConfigService: @staticmethod async def validate_model_config( - db: Session, - *, - model_name: str, - provider: str, - api_key: str, - api_base: Optional[str] = None, - model_type: str = "llm", - test_message: str = "Hello", - is_omni: bool = False + db: Session, + *, + model_name: str, + provider: str, + api_key: str, + api_base: Optional[str] = None, + model_type: str = "llm", + test_message: str = "Hello", + is_omni: bool = False, + capability: Optional[list] = None ) -> Dict[str, Any]: """验证模型配置是否有效 @@ -124,6 +125,7 @@ class ModelConfigService: api_key=api_key, base_url=api_base, is_omni=is_omni, + support_thinking="thinking" in (capability or []), temperature=0.7, max_tokens=100 ) @@ -320,7 +322,8 @@ class ModelConfigService: api_base=api_key_data.api_base, model_type=model_data.type, test_message="Hello", - is_omni=model_data.is_omni + is_omni=model_data.is_omni, + capability=model_data.capability ) if not validation_result["valid"]: raise BusinessException( @@ -590,7 +593,8 @@ class ModelApiKeyService: api_base=data.api_base, model_type=model_config.type, test_message="Hello", - is_omni=data.is_omni + is_omni=data.is_omni, + capability=model_config.capability ) if not validation_result["valid"]: # 记录验证失败的模型,但不抛出异常 @@ -675,7 +679,8 @@ class ModelApiKeyService: api_base=api_key_data.api_base, model_type=model_config.type, test_message="Hello", - is_omni=api_key_data.is_omni + is_omni=api_key_data.is_omni, + capability=model_config.capability ) if not validation_result["valid"]: raise BusinessException( @@ -707,7 +712,8 @@ class ModelApiKeyService: api_base=api_key_data.api_base or existing_api_key.api_base, model_type=model_config.type, test_message="Hello", - is_omni=model_config.is_omni + is_omni=model_config.is_omni, + capability=model_config.capability ) if not validation_result["valid"]: raise BusinessException( diff --git a/api/app/services/multi_agent_orchestrator.py b/api/app/services/multi_agent_orchestrator.py index 1330caad..216aeb6e 100644 --- a/api/app/services/multi_agent_orchestrator.py +++ b/api/app/services/multi_agent_orchestrator.py @@ -2616,6 +2616,7 @@ class MultiAgentOrchestrator: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, + support_thinking="thinking" in (api_key_config.capability or []), temperature=0.7, # 整合任务使用中等温度 max_tokens=2000 ) @@ -2794,6 +2795,7 @@ class MultiAgentOrchestrator: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, + support_thinking="thinking" in (api_key_config.capability or []), temperature=0.7, max_tokens=2000, extra_params={"streaming": True} # 启用流式输出 diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index 184220a8..fde8c4f9 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -185,7 +185,8 @@ class PromptOptimizerService: provider=api_config.provider, api_key=api_config.api_key, base_url=api_config.api_base, - is_omni=api_config.is_omni + is_omni=api_config.is_omni, + support_thinking="thinking" in (api_config.capability or []), ), type=ModelType(model_config.type)) try: prompt_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompt') diff --git a/api/app/services/shared_chat_service.py b/api/app/services/shared_chat_service.py index c74604a5..b1e40a2d 100644 --- a/api/app/services/shared_chat_service.py +++ b/api/app/services/shared_chat_service.py @@ -248,7 +248,9 @@ class SharedChatService: max_tokens=model_parameters.get("max_tokens", 2000), system_prompt=system_prompt, tools=tools, - + deep_thinking=model_parameters.get("deep_thinking", False), + thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + capability=api_key_obj.capability or [], ) # 加载历史消息 @@ -450,7 +452,10 @@ class SharedChatService: max_tokens=model_parameters.get("max_tokens", 2000), system_prompt=system_prompt, tools=tools, - streaming=True + streaming=True, + deep_thinking=model_parameters.get("deep_thinking", False), + thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + capability=api_key_obj.capability or [], ) # 加载历史消息 @@ -479,6 +484,8 @@ class SharedChatService: ): if isinstance(chunk, int): total_tokens = chunk + elif isinstance(chunk, dict) and chunk.get("type") == "reasoning": + yield f"event: reasoning\ndata: {json.dumps({'content': chunk['content']}, ensure_ascii=False)}\n\n" else: full_content += chunk # 发送消息块事件 From 386ed2b9145cb464b0854122e14a0f7e9c190b0a Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 1 Apr 2026 15:57:02 +0800 Subject: [PATCH 008/245] feat(models): support reasoning_content streaming --- api/app/controllers/service/end_user_api_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/controllers/service/end_user_api_controller.py b/api/app/controllers/service/end_user_api_controller.py index 9d410bd2..0ae80a20 100644 --- a/api/app/controllers/service/end_user_api_controller.py +++ b/api/app/controllers/service/end_user_api_controller.py @@ -42,6 +42,7 @@ async def create_end_user( payload = CreateEndUserRequest(**body) workspace_id = api_key_auth.workspace_id + # sourcery skip: sql-injection logger.info(f"Create end user request - other_id: {payload.other_id}, workspace_id: {workspace_id}") # Resolve memory_config_id: explicit > workspace default From 258c19f9e08648cd89d8e00a74516fd99aa22530 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 1 Apr 2026 16:02:27 +0800 Subject: [PATCH 009/245] fix(app service)Sourcery mistook the log f-string for SQL.: --- api/app/controllers/service/end_user_api_controller.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/app/controllers/service/end_user_api_controller.py b/api/app/controllers/service/end_user_api_controller.py index 0ae80a20..df9996c2 100644 --- a/api/app/controllers/service/end_user_api_controller.py +++ b/api/app/controllers/service/end_user_api_controller.py @@ -42,8 +42,7 @@ async def create_end_user( payload = CreateEndUserRequest(**body) workspace_id = api_key_auth.workspace_id - # sourcery skip: sql-injection - logger.info(f"Create end user request - other_id: {payload.other_id}, workspace_id: {workspace_id}") + logger.info("Create end user request - other_id: %s, workspace_id: %s", payload.other_id, workspace_id) # Resolve memory_config_id: explicit > workspace default memory_config_id = None From ad4ddea97759b87ee11fd67fa525cb8d504d43e2 Mon Sep 17 00:00:00 2001 From: zhaoying <zhaoyingyz@126.com> Date: Wed, 1 Apr 2026 16:43:45 +0800 Subject: [PATCH 010/245] feat(web): ui upgrade --- .../assets/images/conversation/compress.svg | 18 ++ web/src/assets/images/conversation/expand.svg | 15 ++ web/src/components/BtnTabs/index.tsx | 8 +- web/src/components/Chat/AudioPlayer.tsx | 152 +++++++++++++ web/src/components/Chat/ChatContent.tsx | 180 +++++++++------- web/src/components/Chat/VideoPlayer.tsx | 62 ++++++ web/src/components/Markdown/index.tsx | 11 +- web/src/styles/antdThemeConfig.ts | 3 + web/src/styles/index.css | 11 + web/src/views/OrderHistory/index.tsx | 50 +++-- web/src/views/SelfReflectionEngine/index.tsx | 199 ++++++++++-------- .../UserMemoryDetail/pages/ExplicitDetail.tsx | 98 +++++++-- .../UserMemoryDetail/pages/WorkingDetail.tsx | 2 +- 13 files changed, 590 insertions(+), 219 deletions(-) create mode 100644 web/src/assets/images/conversation/compress.svg create mode 100644 web/src/assets/images/conversation/expand.svg create mode 100644 web/src/components/Chat/AudioPlayer.tsx create mode 100644 web/src/components/Chat/VideoPlayer.tsx diff --git a/web/src/assets/images/conversation/compress.svg b/web/src/assets/images/conversation/compress.svg new file mode 100644 index 00000000..640d80ba --- /dev/null +++ b/web/src/assets/images/conversation/compress.svg @@ -0,0 +1,18 @@ +<?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>编组 35 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/expand.svg b/web/src/assets/images/conversation/expand.svg new file mode 100644 index 00000000..8cc87d99 --- /dev/null +++ b/web/src/assets/images/conversation/expand.svg @@ -0,0 +1,15 @@ + + + 编组 36 + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/BtnTabs/index.tsx b/web/src/components/BtnTabs/index.tsx index 772a4c8d..8a6e670b 100644 --- a/web/src/components/BtnTabs/index.tsx +++ b/web/src/components/BtnTabs/index.tsx @@ -24,10 +24,11 @@ interface BtnTabsProps { onChange: (key: string) => void; /** Optional extra class name for the container */ className?: string; + variant?: 'outline' | 'borderless' } /** Button-style tab switcher — renders tabs as pill-shaped buttons with active highlight */ -const BtnTabs: FC = ({ items, activeKey, onChange, className }) => { +const BtnTabs: FC = ({ items, activeKey, onChange, className, variant = 'borderless' }) => { return ( {items.map((tab) => ( @@ -35,8 +36,9 @@ const BtnTabs: FC = ({ items, activeKey, onChange, className }) => key={tab.key} onClick={() => onChange(tab.key)} className={clsx('rb:px-2 rb:py-1 rb:rounded-[13px] rb:text-[12px] rb:leading-4.5 rb:cursor-pointer', { - 'rb:bg-[#F6F6F6]': activeKey !== tab.key, - 'rb:bg-[#171719] rb:text-white': activeKey === tab.key, + 'rb:bg-[#F6F6F6]': activeKey !== tab.key && variant === 'borderless', + 'rb-border rb:bg-white': activeKey !== tab.key && variant === 'outline', + 'rb:bg-[#171719] rb:text-white rb:border-[#171719]': activeKey === tab.key, })} > {tab.label} diff --git a/web/src/components/Chat/AudioPlayer.tsx b/web/src/components/Chat/AudioPlayer.tsx new file mode 100644 index 00000000..766c8deb --- /dev/null +++ b/web/src/components/Chat/AudioPlayer.tsx @@ -0,0 +1,152 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-03-16 15:00:07 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-27 15:23:14 + */ +import { type FC, useRef, useState, useEffect } from 'react' +import { Flex, Dropdown, type MenuProps, Slider } from 'antd' +import clsx from 'clsx' +import { useTranslation } from 'react-i18next' + +/** Available playback speed options. */ +const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] + +/** Format seconds into "MM:SS" display string. */ +const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}` + +/** + * Props for the AudioPlayer component. + * @property src - Audio file URL to play. + * @property fileName - Display name shown beside the file icon. + * @property fileSize - Human-readable file size string (e.g. "3.2 MB"). + */ +interface AudioPlayerProps { + src: string + fileName?: string + fileSize?: string +} + +/** + * AudioPlayer – A compact inline audio player with playback controls. + * + * Displays file metadata (name & size), a play/pause toggle, a seekable + * progress slider, elapsed/total time, and a dropdown menu for downloading + * the file or changing playback speed. + * + * @example + * + */ +const AudioPlayer: FC = ({ src, fileName, fileSize }) => { + const { t } = useTranslation() + const audioRef = useRef(null) + const [playing, setPlaying] = useState(false) + const [current, setCurrent] = useState(0) + const [duration, setDuration] = useState(0) + const [speed, setSpeed] = useState(1) + + /* Bind native audio events to sync React state; re-binds when src changes. */ + useEffect(() => { + const audio = audioRef.current + if (!audio) return + const onTime = () => setCurrent(audio.currentTime) + const onMeta = () => setDuration(audio.duration) + const onEnd = () => setPlaying(false) + audio.addEventListener('timeupdate', onTime) + audio.addEventListener('loadedmetadata', onMeta) + audio.addEventListener('ended', onEnd) + return () => { + audio.removeEventListener('timeupdate', onTime) + audio.removeEventListener('loadedmetadata', onMeta) + audio.removeEventListener('ended', onEnd) + } + }, [src]) + + /** Toggle between play and pause. */ + const togglePlay = () => { + const audio = audioRef.current + if (!audio) return + if (playing) { audio.pause(); setPlaying(false) } + else { audio.play(); setPlaying(true) } + } + + /** Seek to a specific position (in seconds) on the audio timeline. */ + const handleSeek = (val: number) => { + if (audioRef.current) audioRef.current.currentTime = val + setCurrent(val) + } + + /** Update playback speed on both React state and the native audio element. */ + const setPlaybackSpeed = (s: number) => { + setSpeed(s) + if (audioRef.current) audioRef.current.playbackRate = s + } + + /** Open the audio source URL in a new tab to trigger download. */ + const handleDownload = () => window.open(src, '_blank') + + /** Dropdown menu items: download and playback speed sub-menu. */ + const mainMenu: MenuProps = { + items: [ + { + key: 'download', + icon:
, + label: t('common.download'), + onClick: handleDownload, + }, + { + key: 'speed', + icon:
, + label: t('perceptualDetail.playbackSpeed'), + children: SPEEDS.map(s => ({ + key: String(s), + label: {s === 1 ? 'normal' : s}, + onClick: () => setPlaybackSpeed(s), + })), + }, + ], + } + + return ( +
+