diff --git a/assets/migrate/0012_add_quote_id_to_room_messages.sql b/assets/migrate/0012_add_quote_id_to_room_messages.sql new file mode 100644 index 00000000..f0f6a457 --- /dev/null +++ b/assets/migrate/0012_add_quote_id_to_room_messages.sql @@ -0,0 +1,2 @@ +ALTER TABLE room_messages ADD COLUMN quote_id INTEGER; +CREATE INDEX IF NOT EXISTS ix_room_messages_quote_id ON room_messages (quote_id); diff --git a/frontend b/frontend index 71411dca..048df4bd 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 71411dcabfc77030f935156c43ed0954d6385890 +Subproject commit 048df4bd3722632d3a04c668d0bb4629c22b600b diff --git a/src/controller/roomController.py b/src/controller/roomController.py index 54845203..365b5ca7 100644 --- a/src/controller/roomController.py +++ b/src/controller/roomController.py @@ -38,6 +38,7 @@ class UpdateAgentsRequest(BaseModel): class SendMessageRequest(BaseModel): content: str | None = None insert_immediately: bool = False + quote_id: int | None = None class RoomApiResponse(BaseModel): @@ -229,7 +230,7 @@ async def post(self, room_id_str: str) -> None: error_code="immediate_insert_driver_not_supported", ) - await room.add_message(room.OPERATOR_MEMBER_ID, content, insert_immediately=request.insert_immediately) + await room.add_message(room.OPERATOR_MEMBER_ID, content, insert_immediately=request.insert_immediately, quote_id=request.quote_id) if room.get_current_turn_agent_id() == room.OPERATOR_MEMBER_ID: await room.handle_finish_request(room.OPERATOR_MEMBER_ID) self.return_success() diff --git a/src/dal/db/gtRoomMessageManager.py b/src/dal/db/gtRoomMessageManager.py index 45253af5..24b0bcb5 100644 --- a/src/dal/db/gtRoomMessageManager.py +++ b/src/dal/db/gtRoomMessageManager.py @@ -13,6 +13,7 @@ async def append_room_message( send_time: datetime, insert_immediately: bool = False, seq: int | None = None, + quote_id: int | None = None, ) -> GtRoomMessage: return await GtRoomMessage.aio_create( room_id=room_id, @@ -21,6 +22,7 @@ async def append_room_message( send_time=send_time, insert_immediately=insert_immediately, seq=seq, + quote_id=quote_id, ) diff --git a/src/model/dbModel/gtRoomMessage.py b/src/model/dbModel/gtRoomMessage.py index 3d44d100..db987543 100644 --- a/src/model/dbModel/gtRoomMessage.py +++ b/src/model/dbModel/gtRoomMessage.py @@ -15,9 +15,22 @@ class GtRoomMessage(DbModelBase): insert_immediately: bool = peewee.BooleanField(null=False, default=False) # V20: 消息在房间内的显示顺序。immediately 消息在注入前为 NULL,注入时由 agentTurnRunner 赋值。 seq: int | None = peewee.IntegerField(null=True, default=None) + # V21: 引用消息 ID,指向同房间内的一条历史消息。NULL 表示无引用。 + quote_id: int | None = peewee.IntegerField(null=True, default=None, index=True) # 非数据库字段,不持久化;由业务代码在创建或恢复消息时手动赋值 sender_display_name: str = "" + # 非数据库字段:引用消息的摘要信息,发送消息时由业务代码填充 + quote_sender_name: str = "" + quote_content_preview: str = "" + + def to_json(self) -> dict: + """序列化,包含非持久化字段(sender_display_name / quote_sender_name / quote_content_preview)。""" + data = super().to_json() + data["sender_display_name"] = self.sender_display_name + data["quote_sender_name"] = self.quote_sender_name + data["quote_content_preview"] = self.quote_content_preview + return data class Meta: table_name = "room_messages" diff --git a/src/route.py b/src/route.py index 8203b97a..48a05e06 100644 --- a/src/route.py +++ b/src/route.py @@ -3,7 +3,7 @@ import tornado.web -from controller import roleTemplateController, agentController, roomController, wsController, teamController, deptController, configController, activityController, settingController, systemController, initController, superviseController +from controller import roleTemplateController, agentController, roomController, wsController, teamController, deptController, configController, activityController, settingController, systemController, initController, superviseController, workspaceController import sys as _sys if getattr(_sys, "frozen", False): @@ -124,6 +124,10 @@ def set_extra_headers(self, path: str) -> None: (r"/agents/(\d+)/activities.json", activityController.AgentActivitiesHandler), (r"/teams/(\d+)/activities.json", activityController.TeamActivitiesHandler), + # Workspace 文件访问 + (r"/workspace/files/list.json", workspaceController.WorkspaceListHandler), + (r"/workspace/files/(.*)", workspaceController.WorkspaceFileHandler), + # 前端静态文件(必须放最后,SPA fallback) (r"/(.*)", _SPAHandler, {"path": _FRONTEND_DIST, "default_filename": "index.html"}), diff --git a/src/service/agentService/prompts.py b/src/service/agentService/prompts.py index 8b733045..abe1dddc 100644 --- a/src/service/agentService/prompts.py +++ b/src/service/agentService/prompts.py @@ -48,8 +48,8 @@ ## 四、 协作风格与待命 -1. **沟通风格**:自然融入对话。请用简短的 1-2 句话回复,保持对话紧凑。 -2. **协作闭环**:任务完成后,应主动在相关房间发送简短同步消息,说明完成情况、关键结果或当前阻塞。长期任务应分阶段同步。 +1. **沟通风格**:自然融入对话。回复应简洁且包含实质内容——给出具体结论、方案或发现,避免仅回复空泛确认(如单独的"收到""好的")。如需详细说明,可适当展开。 +2. **协作闭环**:任务完成后,应主动在相关房间同步进展,内容须包含关键结果、具体发现或当前阻塞点。长期任务应分阶段同步,每次同步应说明已完成的步骤和下一步计划。 3. **待命约束**:未收到明确任务时,**不要**捏造进展。如本轮无话可说,直接调用 `finish_action`。若上一轮已说明待命且无新任务,直接跳过无需重复。 4. **节制交流**:新房间刚创建时,简单问候即可。自我介绍后若无明确任务,请停止发言并跳过轮次。 ''' diff --git a/src/service/funcToolService/core.py b/src/service/funcToolService/core.py index d64856d3..afd81c2e 100644 --- a/src/service/funcToolService/core.py +++ b/src/service/funcToolService/core.py @@ -1,7 +1,8 @@ import inspect import json import logging -from typing import Any, Iterable, Optional +import re +from typing import Any, Callable, Iterable, Optional, Union, get_args, get_origin, get_type_hints from constants import ToolCategory from util import llmApiUtil @@ -34,6 +35,222 @@ logger = logging.getLogger(__name__) +# ─── LLM 工具参数幻觉防御 ───────────────────────────────── +# 部分 LLM(尤其是 DeepSeek)在生成 tool_calls 时会出现以下幻觉模式: +# 1. {"arguments": {"param": "value"}} — 将参数包装在 arguments 键下 +# 2. 生成乱码 token(如 str: + """移除 LLM 生成 JSON 中已知的乱码 token。""" + cleaned = raw + for token in _GARBLED_TOKENS: + cleaned = cleaned.replace(token, "") + return cleaned + + +def _unwrap_arguments_wrapper(args: dict, function_name: str) -> tuple[dict, bool]: + """检测并解包 {"arguments": {...}} 包装模式。 + + 部分 LLM 会将工具参数嵌套在 "arguments" 键下,例如: + {"arguments": {"confirm_no_need_talk": true}} + 而非正确的顶层格式: + {"confirm_no_need_talk": true} + + Returns: + (unwrapped_args, was_unwrapped) + """ + if "arguments" not in args: + return args, False + + func_tool = get_func_tool(function_name) + if func_tool is None: + return args, False + + sig = inspect.signature(func_tool.callable) + valid_param_names = { + p.name for p in sig.parameters.values() + if not p.name.startswith("_") + } + + # 如果 "arguments" 是函数的合法参数名,不解包 + if "arguments" in valid_param_names: + return args, False + + other_keys = {k for k in args if k != "arguments"} + + # 如果除了 arguments 外还有其他合法参数名,可能是正常调用,不解包 + if other_keys and other_keys.intersection(valid_param_names): + return args, False + + inner = args["arguments"] + if isinstance(inner, dict): + logger.warning( + "检测到 arguments 包装模式(dict),已自动解包: tool=%s, 原始 keys=%s, 解包后 keys=%s", + function_name, list(args.keys()), list(inner.keys()), + ) + return inner, True + elif isinstance(inner, str): + try: + inner_parsed = json.loads(inner) + if isinstance(inner_parsed, dict): + logger.warning( + "检测到 arguments 包装模式(string),已自动解包: tool=%s, 原始 keys=%s, 解包后 keys=%s", + function_name, list(args.keys()), list(inner_parsed.keys()), + ) + return inner_parsed, True + except json.JSONDecodeError: + pass + + return args, False + + +def _format_type_hint(hint: Any) -> str: + """将 Python 类型提示格式化为可读字符串。""" + origin = get_origin(hint) + if origin is Union or (hasattr(hint, "__class__") and hint.__class__.__name__ == "_UnionGenericAlias"): + args = get_args(hint) + non_none = [a for a in args if a is not type(None)] + if len(non_none) == 1: + return f"{_format_type_hint(non_none[0])} 或 null" + if origin is list: + args = get_args(hint) + if args: + return f"list[{_format_type_hint(args[0])}]" + return "list" + if origin is dict: + return "dict" + if hint is str: + return "string" + if hint is int: + return "integer" + if hint is float: + return "number" + if hint is bool: + return "boolean" + if hasattr(hint, "__name__"): + return hint.__name__ + return str(hint) + + +def _levenshtein_distance(a: str, b: str) -> int: + """计算两个字符串的 Levenshtein 编辑距离。""" + if len(a) < len(b): + return _levenshtein_distance(b, a) + if len(b) == 0: + return len(a) + prev_row = list(range(len(b) + 1)) + for i, ca in enumerate(a): + curr_row = [i + 1] + for j, cb in enumerate(b): + curr_row.append(min( + prev_row[j + 1] + 1, + curr_row[j] + 1, + prev_row[j] + (0 if ca == cb else 1), + )) + prev_row = curr_row + return prev_row[-1] + + +def _find_close_match(wrong_key: str, valid_keys: set[str]) -> str | None: + """在有效参数名中找到最接近的匹配。""" + if not valid_keys: + return None + best_key = None + best_dist = float("inf") + threshold = max(1, len(wrong_key) // 3) + for valid_key in valid_keys: + dist = _levenshtein_distance(wrong_key, valid_key) + if dist < best_dist and dist <= threshold: + best_dist = dist + best_key = valid_key + return best_key + + +def _extract_unexpected_kwargs(error_str: str) -> list[str]: + """从 TypeError 消息中提取多余的参数名。 + + 匹配模式: + - "func() got an unexpected keyword argument 'foo'" + - "func() got unexpected keyword arguments 'foo', 'bar'" + """ + match = re.search(r"unexpected keyword arguments?\s+(.+)", error_str) + if match: + return re.findall(r"'([^']+)'", match.group(1)) + return [] + + +def _extract_missing_args(error_str: str) -> list[str]: + """从 TypeError 消息中提取缺失的必填参数名。 + + 匹配模式: + - "func() missing 1 required positional argument: 'foo'" + - "func() missing 2 required positional arguments: 'foo' and 'bar'" + """ + match = re.search(r"missing \d+ required positional arguments?:\s+(.+)", error_str) + if match: + return re.findall(r"'([^']+)'", match.group(1)) + return [] + + +def _build_schema_error_message( + function_name: str, + func: Callable[..., Any], + unexpected_keys: list[str] | None = None, + missing_keys: list[str] | None = None, + raw_error: str | None = None, +) -> str: + """构造包含完整参数列表的 schema 感知错误消息,帮助 LLM 自纠正。""" + try: + type_hints = get_type_hints(func) + except Exception: + type_hints = {} + + sig = inspect.signature(func) + + lines = [f"函数 {function_name} 参数错误"] + if raw_error: + lines.append(f"原因: {raw_error}") + + lines.append("") + lines.append("期望的参数列表:") + for param_name, param in sig.parameters.items(): + if param_name.startswith("_"): + continue + param_type = type_hints.get(param_name) + type_name = _format_type_hint(param_type) if param_type else "any" + is_required = param.default == inspect.Parameter.empty + req_label = "必填" if is_required else "可选" + default_info = "" if is_required else f", 默认值={param.default!r}" + lines.append(f" - {param_name} ({type_name}, {req_label}{default_info})") + + if unexpected_keys: + lines.append("") + lines.append(f"多余的参数: {', '.join(unexpected_keys)}") + valid_names = { + p.name for p in sig.parameters.values() + if not p.name.startswith("_") + } + for wrong_key in unexpected_keys: + suggestion = _find_close_match(wrong_key, valid_names) + if suggestion: + lines.append(f" 提示: 参数 \"{wrong_key}\" 是否应为 \"{suggestion}\"?") + + if missing_keys: + lines.append("") + lines.append(f"缺少的必填参数: {', '.join(missing_keys)}") + + return "\n".join(lines) + def build_tools(func_tools: Iterable[FuncTool]) -> list[llmApiUtil.OpenAITool]: """遍历 FuncTool 定义,构建并返回工具列表。""" @@ -100,7 +317,6 @@ def get_tools_by_names( ]) - async def run_tool_call( function_args: str, context: Optional[ToolCallContext] = None, @@ -111,14 +327,65 @@ async def run_tool_call( logger.error("函数执行失败: tool_name 为空") return {"success": False, "message": "函数执行失败: tool_name 为空"} + # ── 阶段 1:JSON 解析 ── + # 先尝试直接解析;失败后清理已知乱码 token 再试;仍失败则返回明确错误。 + raw_args = function_args.strip() try: - args: dict = json.loads(function_args) + args: dict = json.loads(raw_args) except json.JSONDecodeError: - logger.warning(f"工具参数 JSON 解析失败,已忽略参数: tool={function_name}, args={function_args!r}") - args = {} + cleaned = _clean_garbled_json(raw_args) + if cleaned != raw_args: + try: + args = json.loads(cleaned) + logger.warning( + "工具参数 JSON 解析成功(清理乱码 token 后): tool=%s, original_len=%d, cleaned_len=%d", + function_name, len(raw_args), len(cleaned), + ) + except json.JSONDecodeError as e2: + logger.warning( + "工具参数 JSON 解析失败: tool=%s, args=%r, error=%s", + function_name, raw_args[:200], e2, + ) + return { + "success": False, + "message": ( + f"函数 {function_name} 的参数 JSON 格式错误,无法解析。" + f"请检查参数格式是否正确。错误详情: {e2}" + ), + } + else: + logger.warning( + "工具参数 JSON 解析失败: tool=%s, args=%r", + function_name, raw_args[:200], + ) + return { + "success": False, + "message": ( + f"函数 {function_name} 的参数 JSON 格式错误,无法解析。" + f"请检查参数格式是否正确。原始参数: {raw_args[:200]!r}" + ), + } + + # 确保 args 是 dict(LLM 可能生成 list 或其他类型) + if not isinstance(args, dict): + logger.warning( + "工具参数不是 JSON 对象: tool=%s, type=%s", function_name, type(args).__name__, + ) + return { + "success": False, + "message": ( + f"函数 {function_name} 的参数必须是 JSON 对象(键值对)," + f"实际传入的是 {type(args).__name__} 类型。" + f"请使用 {{\"key\": \"value\"}} 格式传递参数。" + ), + } + + # ── 阶段 2:arguments 包装解包 ── + # 部分 LLM 会将参数嵌套在 "arguments" 键下,检测并自动解包。 + args, was_unwrapped = _unwrap_arguments_wrapper(args, function_name) caller = context.agent_id if context is not None else "unknown" - logger.info(f"use_tool: caller_id={caller}, tool={function_name}, args={args}") + logger.info("use_tool: caller_id=%s, tool=%s, args=%s", caller, function_name, args) try: func_tool = get_func_tool(function_name) @@ -141,20 +408,38 @@ async def run_tool_call( if not isinstance(result, dict): result = {"success": True, "result": result} - logger.info(f"函数执行结果: {result}") + logger.info("函数执行结果: %s", result) return result - except Exception as e: - if isinstance(e, TypeError): - error = f"Invalid arguments for function {function_name}: {e}" + except TypeError as e: + # ── 阶段 3:Schema 感知的错误消息 ── + # 不再只返回 Python 原始 TypeError,而是构造包含完整参数列表的消息, + # 帮助 LLM 理解正确的参数格式并自纠正。 + error_str = str(e) + unexpected_keys = _extract_unexpected_kwargs(error_str) + missing_keys = _extract_missing_args(error_str) + + if func is not None: + schema_msg = _build_schema_error_message( + function_name=function_name, + func=func, + unexpected_keys=unexpected_keys, + missing_keys=missing_keys, + raw_error=error_str, + ) else: - error = str(e) + schema_msg = f"Invalid arguments for function {function_name}: {error_str}" + + logger.error("函数执行失败: %s", e) + return {"success": False, "message": f"函数执行失败: {schema_msg}"} - logger.error(f"函数执行失败: {e}") + except Exception as e: + error = str(e) + logger.error("函数执行失败: %s", e) return {"success": False, "message": f"函数执行失败: {error}"} def shutdown() -> None: """清空工具列表,程序退出前调用。""" global _func_tools - _func_tools = {} + _func_tools = {} \ No newline at end of file diff --git a/src/service/llmService/llmRequestRules.py b/src/service/llmService/llmRequestRules.py index 757943a4..8a0818d9 100644 --- a/src/service/llmService/llmRequestRules.py +++ b/src/service/llmService/llmRequestRules.py @@ -15,26 +15,55 @@ def apply(self, request: llmApiUtil.OpenAIRequest) -> llmApiUtil.OpenAIRequest: raise NotImplementedError +# 与 client.py 中的 _THINKING_MODE_MODEL_PREFIXES 保持同步 +_THINKING_MODE_MODEL_PREFIXES = ( + "deepseek-r1", + "deepseek-reasoner", + "deepseek-v4", + "deepseek-pro", +) + + +def _is_thinking_mode_model(model: str) -> bool: + """判断模型是否为 thinking mode 模型(需要 reasoning_content 字段)。""" + model_lower = model.lower() + return any(model_lower.startswith(prefix) for prefix in _THINKING_MODE_MODEL_PREFIXES) + + +def _is_thinking_enabled(request: llmApiUtil.OpenAIRequest) -> bool: + """判断当前请求是否开启了思考模式。 + + 触发方式(优先级从高到低): + 1. provider_params 中 thinking.type == "enabled" → 开启 + 2. provider_params 中 thinking.type == "disabled" → 显式关闭,不触发 + 3. provider_params 中设置了 reasoning_effort → 开启 + 4. 模型名称隐式启用(如 deepseek-v4-pro)→ 开启 + """ + thinking = (request.provider_params or {}).get("thinking") or {} + if isinstance(thinking, dict): + thinking_type = thinking.get("type") + if thinking_type == "enabled": + return True + if thinking_type == "disabled": + return False + reasoning_effort = (request.provider_params or {}).get("reasoning_effort") + if reasoning_effort not in (None, ""): + return True + if _is_thinking_mode_model(request.model): + return True + return False + + class StripRequiredToolChoiceForReasoningRule(LlmRequestRule): """开启思考模式时,不能强制使用工具,否则 deepseek-v4-pro 等模型会报错。""" def check_match(self, request: llmApiUtil.OpenAIRequest) -> bool: - reasoning_effort = (request.provider_params or {}).get("reasoning_effort") - return ( - request.tool_choice == "required" - and reasoning_effort not in (None, "") - ) + return request.tool_choice == "required" and _is_thinking_enabled(request) def apply(self, request: llmApiUtil.OpenAIRequest) -> llmApiUtil.OpenAIRequest: return request.model_copy(update={"tool_choice": None}) -def _is_thinking_enabled(request: llmApiUtil.OpenAIRequest) -> bool: - """判断当前请求是否开启了思考模式(thinking.type == "enabled")。""" - thinking = (request.provider_params or {}).get("thinking") or {} - return isinstance(thinking, dict) and thinking.get("type") == "enabled" - - class FillMissingReasoningContentRule(LlmRequestRule): """开启思考模式时,历史中由非思考模型生成的 assistant tool_call 消息缺少 reasoning_content 字段,DeepSeek 等模型会报 400 错误。 diff --git a/src/service/roomService/chatRoom.py b/src/service/roomService/chatRoom.py index 375a03cb..6de0a590 100644 --- a/src/service/roomService/chatRoom.py +++ b/src/service/roomService/chatRoom.py @@ -163,14 +163,15 @@ def has_pending_immediate_messages(self, agent_id: int) -> bool: return self._store.has_pending_immediate_messages(agent_id) async def add_message(self, sender_id: int, content: str, send_time: datetime | None = None, *, - insert_immediately: bool = False) -> None: - await self._append_message(sender_id, content, send_time=send_time, insert_immediately=insert_immediately) + insert_immediately: bool = False, quote_id: int | None = None) -> None: + await self._append_message(sender_id, content, send_time=send_time, insert_immediately=insert_immediately, quote_id=quote_id) async def _append_message( self, sender_id: int, content: str, send_time: datetime | None = None, *, update_turn_state: bool = True, insert_immediately: bool = False, + quote_id: int | None = None, ) -> None: assertUtil.assertTrue( self.can_post_message(sender_id), @@ -198,9 +199,16 @@ async def _append_message( message = GtRoomMessage(room_id=self.room_id, sender_id=sender_id, content=content, send_time=send_time or datetime.now(), - insert_immediately=insert_immediately) + insert_immediately=insert_immediately, quote_id=quote_id) message.sender_display_name = agent.display_name + # 填充引用消息摘要(非持久化字段,用于 WS 推送) + if quote_id is not None: + quote_msg = self._store.get_message_by_id(quote_id) + if quote_msg is not None: + message.quote_sender_name = quote_msg.sender_display_name + message.quote_content_preview = (quote_msg.content or "")[:100] + if insert_immediately or is_queued: self._store.append_pending(message) else: @@ -214,6 +222,7 @@ async def _append_message( room_id=self.room_id, sender_id=sender_id, content=content, send_time=message.send_time, insert_immediately=insert_immediately, seq=message.seq, + quote_id=quote_id, ) message.id = db_msg.id messageBus.publish(MessageBusTopic.ROOM_MSG_ADDED, gt_room=self.gt_room, gt_message=message) diff --git a/src/service/roomService/core.py b/src/service/roomService/core.py index b4331e1e..4afd7913 100644 --- a/src/service/roomService/core.py +++ b/src/service/roomService/core.py @@ -106,6 +106,14 @@ async def _restore_room_runtime_state(room: ChatRoom) -> None: row.sender_display_name = agent.display_name restored_messages.append(row) + # 填充引用消息摘要(quote_sender_name / quote_content_preview) + msg_by_id = {m.id: m for m in restored_messages if m.id is not None} + for row in restored_messages: + if row.quote_id is not None and row.quote_id in msg_by_id: + quoted = msg_by_id[row.quote_id] + row.quote_sender_name = quoted.sender_display_name + row.quote_content_preview = (quoted.content or "")[:100] + if restored_messages is not None or agent_read_index is not None: room.inject_runtime_state( messages=restored_messages, @@ -150,8 +158,20 @@ async def get_room_messages_from_db( before_id: int | None = None, limit: int | None = None, ) -> tuple[list[GtRoomMessage], bool]: - """从数据库加载房间消息,固定走持久层。""" - return await gtRoomMessageManager.get_room_messages(room_id, before_id=before_id, limit=limit) + """从数据库加载房间消息,固定走持久层。同时填充 sender_display_name 和引用摘要。""" + gt_messages, has_more = await gtRoomMessageManager.get_room_messages(room_id, before_id=before_id, limit=limit) + # 填充 sender_display_name + for msg in gt_messages: + agent = await gtAgentManager.get_agent_by_id(msg.sender_id) + msg.sender_display_name = agent.display_name if agent else str(msg.sender_id) + # 填充引用消息摘要 + msg_by_id = {m.id: m for m in gt_messages if m.id is not None} + for msg in gt_messages: + if msg.quote_id is not None and msg.quote_id in msg_by_id: + quoted = msg_by_id[msg.quote_id] + msg.quote_sender_name = quoted.sender_display_name + msg.quote_content_preview = (quoted.content or "")[:100] + return gt_messages, has_more def get_all_rooms() -> List[ChatRoom]: diff --git a/src/service/roomService/messageStore.py b/src/service/roomService/messageStore.py index b9d65831..b87aa341 100644 --- a/src/service/roomService/messageStore.py +++ b/src/service/roomService/messageStore.py @@ -51,6 +51,7 @@ async def append_and_assign_seq(self, msg: GtRoomMessage, *, room_id=self._gt_room.id, sender_id=msg.sender_id, content=msg.content, send_time=msg.send_time, insert_immediately=False, seq=msg.seq, + quote_id=msg.quote_id, ) msg.id = db_msg.id @@ -168,3 +169,7 @@ def escalate_to_immediate(self, db_id: int) -> GtRoomMessage: def get_read_index(self) -> Dict[int, int]: """返回当前读取进度字典(供持久化使用)。""" return self._agent_seq_read + + def get_message_by_id(self, db_id: int) -> GtRoomMessage | None: + """根据 db_id 查找内存中的消息,未找到返回 None。""" + return next((m for m in self._messages if m.id == db_id), None) diff --git a/src/util/llmApiUtil/client.py b/src/util/llmApiUtil/client.py index 1dcd05b8..4fdb7f7a 100644 --- a/src/util/llmApiUtil/client.py +++ b/src/util/llmApiUtil/client.py @@ -140,9 +140,40 @@ def _clean_base_url(url: str) -> str: return base_url.rstrip("/") +# DeepSeek R1/V4 等 thinking mode 模型要求:所有 assistant 消息都必须包含 reasoning_content 字段。 +# 即使历史消息中没有 reasoning_content(如 compact 前的消息或切换模型前的消息), +# 也必须补上空字符串,否则 API 报错 "The reasoning_content in the thinking mode must be passed back to the API." +_THINKING_MODE_MODEL_PREFIXES = ( + "deepseek-r1", + "deepseek-reasoner", + "deepseek-v4", + "deepseek-pro", +) + + +def _is_thinking_mode_model(model: str) -> bool: + """判断模型是否为 thinking mode 模型(需要 reasoning_content 字段)。""" + model_lower = model.lower() + return any(model_lower.startswith(prefix) for prefix in _THINKING_MODE_MODEL_PREFIXES) + + def _build_request_payload(request: OpenAIRequest) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]] | None]: model_name = request.model messages = [m.to_dict() for m in request.messages] + + # Thinking mode 模型要求所有 assistant 消息包含 reasoning_content 字段。 + # 两种情况需要补全: + # 1. 模型本身是 thinking mode 模型(如 deepseek-v4-pro) + # 2. 对话中已存在带 reasoning_content 的 assistant 消息(可能是从 thinking 模型切换过来的) + needs_padding = _is_thinking_mode_model(model_name) or any( + m.role == OpenaiApiRole.ASSISTANT and m.reasoning_content is not None + for m in request.messages + ) + if needs_padding: + for msg_dict in messages: + if msg_dict.get("role") == "assistant" and "reasoning_content" not in msg_dict: + msg_dict["reasoning_content"] = "" + tools: list[dict[str, Any]] | None = None if request.tools: tools = [t.model_dump(exclude_none=True) for t in request.tools]