diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 3e6bc4e4..1124135f 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -659,8 +659,11 @@ class AgentStreamExecutor: tool_calls_buffer[index]["arguments"] += func["arguments"] # Preserve _gemini_raw_parts for Gemini thoughtSignature round-trip + # (direct Gemini: list of parts; LinkAI proxy: base64 string of JSON parts) if "_gemini_raw_parts" in delta: gemini_raw_parts = delta["_gemini_raw_parts"] + elif isinstance(choice, dict) and choice.get("_gemini_raw_parts"): + gemini_raw_parts = choice["_gemini_raw_parts"] except Exception as e: error_str = str(e) diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 64a649aa..3251c286 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -297,8 +297,12 @@ class ChatChannel(Channel): logger.debug("[chat_channel] sending reply: {}, context: {}".format(reply, context)) # 如果是文本回复,尝试提取并发送图片 - if reply.type == ReplyType.TEXT: + # Web channel renders images/videos inline via renderMarkdown, + # so skip the extract-and-send step to avoid duplicate media. + if reply.type == ReplyType.TEXT and context.get("channel_type") != "web": self._extract_and_send_images(reply, context) + elif reply.type == ReplyType.TEXT: + self._send(reply, context) # 如果是图片回复但带有文本内容,先发文本再发图片 elif reply.type == ReplyType.IMAGE_URL and hasattr(reply, 'text_content') and reply.text_content: # 先发送文本 diff --git a/models/gemini/google_gemini_bot.py b/models/gemini/google_gemini_bot.py index ab3eae4c..6716e971 100644 --- a/models/gemini/google_gemini_bot.py +++ b/models/gemini/google_gemini_bot.py @@ -436,7 +436,6 @@ class GoogleGeminiBot(Bot): tool_result_data = {"result": tool_content} # Find the tool name from previous messages - # Look for the corresponding tool_call in model's message tool_name = None for prev_msg in reversed(messages): if prev_msg.get("role") == "assistant": @@ -450,13 +449,14 @@ class GoogleGeminiBot(Bot): if tool_name: break - # Gemini functionResponse format - parts.append({ - "functionResponse": { - "name": tool_name or "unknown", - "response": tool_result_data - } - }) + # Gemini functionResponse format (Gemini 3 requires `id`) + fn_response = { + "name": tool_name or "unknown", + "response": tool_result_data + } + if tool_use_id: + fn_response["id"] = tool_use_id + parts.append({"functionResponse": fn_response}) elif "text" in block: # Generic text field @@ -624,10 +624,11 @@ class GoogleGeminiBot(Bot): # Check for functionCall (per REST API docs) if "functionCall" in part: fc = part["functionCall"] - logger.info(f"[Gemini] Function call detected: {fc.get('name')}") + fc_id = fc.get("id") or f"call_{int(time.time() * 1000000)}" + logger.info(f"[Gemini] Function call detected: {fc.get('name')} (id={fc_id})") tool_calls.append({ - "id": f"call_{int(time.time() * 1000000)}", + "id": fc_id, "type": "function", "function": { "name": fc.get("name"), @@ -747,10 +748,12 @@ class GoogleGeminiBot(Bot): # Collect function calls if "functionCall" in part: fc = part["functionCall"] - logger.info(f"[Gemini] Function call: {fc.get('name')}") + logger.info(f"[Gemini] Function call: {fc.get('name')} (id={fc.get('id')})") + # Prefer Gemini's native id; fall back to generated one + fc_id = fc.get("id") or f"call_{int(time.time() * 1000000)}_{len(all_tool_calls)}" all_tool_calls.append({ - "index": len(all_tool_calls), # Add index to differentiate multiple tool calls - "id": f"call_{int(time.time() * 1000000)}_{len(all_tool_calls)}", + "index": len(all_tool_calls), + "id": fc_id, "type": "function", "function": { "name": fc.get("name"), diff --git a/models/linkai/link_ai_bot.py b/models/linkai/link_ai_bot.py index 212cbbd7..6eb1e142 100644 --- a/models/linkai/link_ai_bot.py +++ b/models/linkai/link_ai_bot.py @@ -673,6 +673,9 @@ def _handle_linkai_stream_response(self, base_url, headers, body): } return + # Forward SSE JSON as-is so extensions (e.g. delta._gemini_raw_parts + # for Gemini via LinkAI) reach agent_stream and are stored on assistant + # messages for the next request. Standard OpenAI fields are unchanged. yield chunk except Exception as e: