AstrBot 4.5.7__py3-none-any.whl → 4.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot/core/agent/mcp_client.py +152 -26
- astrbot/core/agent/message.py +8 -1
- astrbot/core/config/default.py +8 -1
- astrbot/core/core_lifecycle.py +8 -0
- astrbot/core/db/__init__.py +50 -1
- astrbot/core/db/migration/migra_webchat_session.py +131 -0
- astrbot/core/db/po.py +49 -13
- astrbot/core/db/sqlite.py +102 -3
- astrbot/core/knowledge_base/kb_helper.py +314 -33
- astrbot/core/knowledge_base/kb_mgr.py +45 -1
- astrbot/core/knowledge_base/parsers/url_parser.py +103 -0
- astrbot/core/knowledge_base/prompts.py +65 -0
- astrbot/core/pipeline/process_stage/method/llm_request.py +28 -14
- astrbot/core/pipeline/process_stage/utils.py +60 -16
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +13 -10
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +8 -4
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +0 -4
- astrbot/core/provider/entities.py +22 -9
- astrbot/core/provider/func_tool_manager.py +12 -9
- astrbot/core/provider/sources/gemini_source.py +25 -8
- astrbot/core/provider/sources/openai_source.py +9 -16
- astrbot/dashboard/routes/chat.py +134 -77
- astrbot/dashboard/routes/knowledge_base.py +172 -0
- {astrbot-4.5.7.dist-info → astrbot-4.6.0.dist-info}/METADATA +4 -3
- {astrbot-4.5.7.dist-info → astrbot-4.6.0.dist-info}/RECORD +28 -25
- {astrbot-4.5.7.dist-info → astrbot-4.6.0.dist-info}/WHEEL +0 -0
- {astrbot-4.5.7.dist-info → astrbot-4.6.0.dist-info}/entry_points.txt +0 -0
- {astrbot-4.5.7.dist-info → astrbot-4.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
TEXT_REPAIR_SYSTEM_PROMPT = """You are a meticulous digital archivist. Your mission is to reconstruct a clean, readable article from raw, noisy text chunks.
|
|
2
|
+
|
|
3
|
+
**Core Task:**
|
|
4
|
+
1. **Analyze:** Examine the text chunk to separate "signal" (substantive information) from "noise" (UI elements, ads, navigation, footers).
|
|
5
|
+
2. **Process:** Clean and repair the signal. **Do not translate it.** Keep the original language.
|
|
6
|
+
|
|
7
|
+
**Crucial Rules:**
|
|
8
|
+
- **NEVER discard a chunk if it contains ANY valuable information.** Your primary duty is to salvage content.
|
|
9
|
+
- **If a chunk contains multiple distinct topics, split them.** Enclose each topic in its own `<repaired_text>` tag.
|
|
10
|
+
- Your output MUST be ONLY `<repaired_text>...</repaired_text>` tags or a single `<discard_chunk />` tag.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
**Example 1: Chunk with Noise and Signal**
|
|
14
|
+
|
|
15
|
+
*Input Chunk:*
|
|
16
|
+
"Home | About | Products | **The Llama is a domesticated South American camelid.** | © 2025 ACME Corp."
|
|
17
|
+
|
|
18
|
+
*Your Thought Process:*
|
|
19
|
+
1. "Home | About | Products..." and "© 2025 ACME Corp." are noise.
|
|
20
|
+
2. "The Llama is a domesticated..." is the signal.
|
|
21
|
+
3. I must extract the signal and wrap it.
|
|
22
|
+
|
|
23
|
+
*Your Output:*
|
|
24
|
+
<repaired_text>
|
|
25
|
+
The Llama is a domesticated South American camelid.
|
|
26
|
+
</repaired_text>
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
**Example 2: Chunk with ONLY Noise**
|
|
30
|
+
|
|
31
|
+
*Input Chunk:*
|
|
32
|
+
"Next Page > | Subscribe to our newsletter | Follow us on X"
|
|
33
|
+
|
|
34
|
+
*Your Thought Process:*
|
|
35
|
+
1. This entire chunk is noise. There is no signal.
|
|
36
|
+
2. I must discard this.
|
|
37
|
+
|
|
38
|
+
*Your Output:*
|
|
39
|
+
<discard_chunk />
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
**Example 3: Chunk with Multiple Topics (Requires Splitting)**
|
|
43
|
+
|
|
44
|
+
*Input Chunk:*
|
|
45
|
+
"## Chapter 1: The Sun
|
|
46
|
+
The Sun is the star at the center of the Solar System.
|
|
47
|
+
|
|
48
|
+
## Chapter 2: The Moon
|
|
49
|
+
The Moon is Earth's only natural satellite."
|
|
50
|
+
|
|
51
|
+
*Your Thought Process:*
|
|
52
|
+
1. This chunk contains two distinct topics.
|
|
53
|
+
2. I must process them separately to maintain semantic integrity.
|
|
54
|
+
3. I will create two `<repaired_text>` blocks.
|
|
55
|
+
|
|
56
|
+
*Your Output:*
|
|
57
|
+
<repaired_text>
|
|
58
|
+
## Chapter 1: The Sun
|
|
59
|
+
The Sun is the star at the center of the Solar System.
|
|
60
|
+
</repaired_text>
|
|
61
|
+
<repaired_text>
|
|
62
|
+
## Chapter 2: The Moon
|
|
63
|
+
The Moon is Earth's only natural satellite.
|
|
64
|
+
</repaired_text>
|
|
65
|
+
"""
|
|
@@ -32,7 +32,7 @@ from ....astr_agent_run_util import AgentRunner, run_agent
|
|
|
32
32
|
from ....astr_agent_tool_exec import FunctionToolExecutor
|
|
33
33
|
from ...context import PipelineContext, call_event_hook
|
|
34
34
|
from ..stage import Stage
|
|
35
|
-
from ..utils import
|
|
35
|
+
from ..utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class LLMRequestSubStage(Stage):
|
|
@@ -57,6 +57,7 @@ class LLMRequestSubStage(Stage):
|
|
|
57
57
|
self.max_step = 30
|
|
58
58
|
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
|
|
59
59
|
self.show_reasoning = settings.get("display_reasoning_text", False)
|
|
60
|
+
self.kb_agentic_mode: bool = conf.get("kb_agentic_mode", False)
|
|
60
61
|
|
|
61
62
|
for bwp in self.bot_wake_prefixs:
|
|
62
63
|
if self.provider_wake_prefix.startswith(bwp):
|
|
@@ -95,20 +96,33 @@ class LLMRequestSubStage(Stage):
|
|
|
95
96
|
raise RuntimeError("无法创建新的对话。")
|
|
96
97
|
return conversation
|
|
97
98
|
|
|
98
|
-
async def
|
|
99
|
+
async def _apply_kb(
|
|
99
100
|
self,
|
|
100
101
|
event: AstrMessageEvent,
|
|
101
102
|
req: ProviderRequest,
|
|
102
103
|
):
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
104
|
+
"""Apply knowledge base context to the provider request"""
|
|
105
|
+
if not self.kb_agentic_mode:
|
|
106
|
+
if req.prompt is None:
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
kb_result = await retrieve_knowledge_base(
|
|
110
|
+
query=req.prompt,
|
|
111
|
+
umo=event.unified_msg_origin,
|
|
112
|
+
context=self.ctx.plugin_manager.context,
|
|
113
|
+
)
|
|
114
|
+
if not kb_result:
|
|
115
|
+
return
|
|
116
|
+
if req.system_prompt is not None:
|
|
117
|
+
req.system_prompt += (
|
|
118
|
+
f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
|
|
119
|
+
)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Error occurred while retrieving knowledge base: {e}")
|
|
122
|
+
else:
|
|
123
|
+
if req.func_tool is None:
|
|
124
|
+
req.func_tool = ToolSet()
|
|
125
|
+
req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
|
|
112
126
|
|
|
113
127
|
def _truncate_contexts(
|
|
114
128
|
self,
|
|
@@ -356,13 +370,13 @@ class LLMRequestSubStage(Stage):
|
|
|
356
370
|
if not req.prompt and not req.image_urls:
|
|
357
371
|
return
|
|
358
372
|
|
|
359
|
-
# apply knowledge base context
|
|
360
|
-
await self._apply_kb_context(event, req)
|
|
361
|
-
|
|
362
373
|
# call event hook
|
|
363
374
|
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
|
|
364
375
|
return
|
|
365
376
|
|
|
377
|
+
# apply knowledge base feature
|
|
378
|
+
await self._apply_kb(event, req)
|
|
379
|
+
|
|
366
380
|
# fix contexts json str
|
|
367
381
|
if isinstance(req.contexts, str):
|
|
368
382
|
req.contexts = json.loads(req.contexts)
|
|
@@ -1,23 +1,64 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
3
|
-
|
|
4
|
-
from ..context import PipelineContext
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from pydantic.dataclasses import dataclass
|
|
5
3
|
|
|
4
|
+
from astrbot.api import logger, sp
|
|
5
|
+
from astrbot.core.agent.run_context import ContextWrapper
|
|
6
|
+
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
|
7
|
+
from astrbot.core.astr_agent_context import AstrAgentContext
|
|
8
|
+
from astrbot.core.star.context import Context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
|
13
|
+
name: str = "astr_kb_search"
|
|
14
|
+
description: str = (
|
|
15
|
+
"Query the knowledge base for facts or relevant context. "
|
|
16
|
+
"Use this tool when the user's question requires factual information, "
|
|
17
|
+
"definitions, background knowledge, or previously indexed content. "
|
|
18
|
+
"Only send short keywords or a concise question as the query."
|
|
19
|
+
)
|
|
20
|
+
parameters: dict = Field(
|
|
21
|
+
default_factory=lambda: {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"query": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "A concise keyword query for the knowledge base.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
"required": ["query"],
|
|
30
|
+
}
|
|
31
|
+
)
|
|
6
32
|
|
|
7
|
-
async def
|
|
33
|
+
async def call(
|
|
34
|
+
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
|
35
|
+
) -> ToolExecResult:
|
|
36
|
+
query = kwargs.get("query", "")
|
|
37
|
+
if not query:
|
|
38
|
+
return "error: Query parameter is empty."
|
|
39
|
+
result = await retrieve_knowledge_base(
|
|
40
|
+
query=kwargs.get("query", ""),
|
|
41
|
+
umo=context.context.event.unified_msg_origin,
|
|
42
|
+
context=context.context.context,
|
|
43
|
+
)
|
|
44
|
+
if not result:
|
|
45
|
+
return "No relevant knowledge found."
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def retrieve_knowledge_base(
|
|
50
|
+
query: str,
|
|
8
51
|
umo: str,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
) -> None:
|
|
52
|
+
context: Context,
|
|
53
|
+
) -> str | None:
|
|
12
54
|
"""Inject knowledge base context into the provider request
|
|
13
55
|
|
|
14
56
|
Args:
|
|
15
57
|
umo: Unique message object (session ID)
|
|
16
58
|
p_ctx: Pipeline context
|
|
17
|
-
req: Provider request
|
|
18
|
-
|
|
19
59
|
"""
|
|
20
|
-
kb_mgr =
|
|
60
|
+
kb_mgr = context.kb_manager
|
|
61
|
+
config = context.get_config(umo=umo)
|
|
21
62
|
|
|
22
63
|
# 1. 优先读取会话级配置
|
|
23
64
|
session_config = await sp.session_get(umo, "kb_config", default={})
|
|
@@ -54,18 +95,18 @@ async def inject_kb_context(
|
|
|
54
95
|
|
|
55
96
|
logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
|
|
56
97
|
else:
|
|
57
|
-
kb_names =
|
|
58
|
-
top_k =
|
|
98
|
+
kb_names = config.get("kb_names", [])
|
|
99
|
+
top_k = config.get("kb_final_top_k", 5)
|
|
59
100
|
logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
|
|
60
101
|
|
|
61
|
-
top_k_fusion =
|
|
102
|
+
top_k_fusion = config.get("kb_fusion_top_k", 20)
|
|
62
103
|
|
|
63
104
|
if not kb_names:
|
|
64
105
|
return
|
|
65
106
|
|
|
66
107
|
logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
|
|
67
108
|
kb_context = await kb_mgr.retrieve(
|
|
68
|
-
query=
|
|
109
|
+
query=query,
|
|
69
110
|
kb_names=kb_names,
|
|
70
111
|
top_k_fusion=top_k_fusion,
|
|
71
112
|
top_m_final=top_k,
|
|
@@ -78,4 +119,7 @@ async def inject_kb_context(
|
|
|
78
119
|
if formatted:
|
|
79
120
|
results = kb_context.get("results", [])
|
|
80
121
|
logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
|
|
81
|
-
|
|
122
|
+
return formatted
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
|
@@ -30,7 +30,7 @@ from .wecomai_api import (
|
|
|
30
30
|
WecomAIBotStreamMessageBuilder,
|
|
31
31
|
)
|
|
32
32
|
from .wecomai_event import WecomAIBotMessageEvent
|
|
33
|
-
from .wecomai_queue_mgr import WecomAIQueueMgr
|
|
33
|
+
from .wecomai_queue_mgr import WecomAIQueueMgr
|
|
34
34
|
from .wecomai_server import WecomAIBotServer
|
|
35
35
|
from .wecomai_utils import (
|
|
36
36
|
WecomAIBotConstants,
|
|
@@ -144,9 +144,12 @@ class WecomAIBotAdapter(Platform):
|
|
|
144
144
|
# 事件循环和关闭信号
|
|
145
145
|
self.shutdown_event = asyncio.Event()
|
|
146
146
|
|
|
147
|
+
# 队列管理器
|
|
148
|
+
self.queue_mgr = WecomAIQueueMgr()
|
|
149
|
+
|
|
147
150
|
# 队列监听器
|
|
148
151
|
self.queue_listener = WecomAIQueueListener(
|
|
149
|
-
|
|
152
|
+
self.queue_mgr,
|
|
150
153
|
self._handle_queued_message,
|
|
151
154
|
)
|
|
152
155
|
|
|
@@ -189,7 +192,7 @@ class WecomAIBotAdapter(Platform):
|
|
|
189
192
|
stream_id,
|
|
190
193
|
session_id,
|
|
191
194
|
)
|
|
192
|
-
|
|
195
|
+
self.queue_mgr.set_pending_response(stream_id, callback_params)
|
|
193
196
|
|
|
194
197
|
resp = WecomAIBotStreamMessageBuilder.make_text_stream(
|
|
195
198
|
stream_id,
|
|
@@ -207,7 +210,7 @@ class WecomAIBotAdapter(Platform):
|
|
|
207
210
|
elif msgtype == "stream":
|
|
208
211
|
# wechat server is requesting for updates of a stream
|
|
209
212
|
stream_id = message_data["stream"]["id"]
|
|
210
|
-
if not
|
|
213
|
+
if not self.queue_mgr.has_back_queue(stream_id):
|
|
211
214
|
logger.error(f"Cannot find back queue for stream_id: {stream_id}")
|
|
212
215
|
|
|
213
216
|
# 返回结束标志,告诉微信服务器流已结束
|
|
@@ -222,7 +225,7 @@ class WecomAIBotAdapter(Platform):
|
|
|
222
225
|
callback_params["timestamp"],
|
|
223
226
|
)
|
|
224
227
|
return resp
|
|
225
|
-
queue =
|
|
228
|
+
queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
|
226
229
|
if queue.empty():
|
|
227
230
|
logger.debug(
|
|
228
231
|
f"No new messages in back queue for stream_id: {stream_id}",
|
|
@@ -242,10 +245,9 @@ class WecomAIBotAdapter(Platform):
|
|
|
242
245
|
elif msg["type"] == "end":
|
|
243
246
|
# stream end
|
|
244
247
|
finish = True
|
|
245
|
-
|
|
248
|
+
self.queue_mgr.remove_queues(stream_id)
|
|
246
249
|
break
|
|
247
|
-
|
|
248
|
-
pass
|
|
250
|
+
|
|
249
251
|
logger.debug(
|
|
250
252
|
f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}",
|
|
251
253
|
)
|
|
@@ -313,8 +315,8 @@ class WecomAIBotAdapter(Platform):
|
|
|
313
315
|
session_id: str,
|
|
314
316
|
):
|
|
315
317
|
"""将消息放入队列进行异步处理"""
|
|
316
|
-
input_queue =
|
|
317
|
-
_ =
|
|
318
|
+
input_queue = self.queue_mgr.get_or_create_queue(stream_id)
|
|
319
|
+
_ = self.queue_mgr.get_or_create_back_queue(stream_id)
|
|
318
320
|
message_payload = {
|
|
319
321
|
"message_data": message_data,
|
|
320
322
|
"callback_params": callback_params,
|
|
@@ -453,6 +455,7 @@ class WecomAIBotAdapter(Platform):
|
|
|
453
455
|
platform_meta=self.meta(),
|
|
454
456
|
session_id=message.session_id,
|
|
455
457
|
api_client=self.api_client,
|
|
458
|
+
queue_mgr=self.queue_mgr,
|
|
456
459
|
)
|
|
457
460
|
|
|
458
461
|
self.commit_event(message_event)
|
|
@@ -8,7 +8,7 @@ from astrbot.api.message_components import (
|
|
|
8
8
|
)
|
|
9
9
|
|
|
10
10
|
from .wecomai_api import WecomAIBotAPIClient
|
|
11
|
-
from .wecomai_queue_mgr import
|
|
11
|
+
from .wecomai_queue_mgr import WecomAIQueueMgr
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class WecomAIBotMessageEvent(AstrMessageEvent):
|
|
@@ -21,6 +21,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
|
|
21
21
|
platform_meta,
|
|
22
22
|
session_id: str,
|
|
23
23
|
api_client: WecomAIBotAPIClient,
|
|
24
|
+
queue_mgr: WecomAIQueueMgr,
|
|
24
25
|
):
|
|
25
26
|
"""初始化消息事件
|
|
26
27
|
|
|
@@ -34,14 +35,16 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
|
|
34
35
|
"""
|
|
35
36
|
super().__init__(message_str, message_obj, platform_meta, session_id)
|
|
36
37
|
self.api_client = api_client
|
|
38
|
+
self.queue_mgr = queue_mgr
|
|
37
39
|
|
|
38
40
|
@staticmethod
|
|
39
41
|
async def _send(
|
|
40
42
|
message_chain: MessageChain,
|
|
41
43
|
stream_id: str,
|
|
44
|
+
queue_mgr: WecomAIQueueMgr,
|
|
42
45
|
streaming: bool = False,
|
|
43
46
|
):
|
|
44
|
-
back_queue =
|
|
47
|
+
back_queue = queue_mgr.get_or_create_back_queue(stream_id)
|
|
45
48
|
|
|
46
49
|
if not message_chain:
|
|
47
50
|
await back_queue.put(
|
|
@@ -94,7 +97,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
|
|
94
97
|
"wecom_ai_bot platform event raw_message should be a dict"
|
|
95
98
|
)
|
|
96
99
|
stream_id = raw.get("stream_id", self.session_id)
|
|
97
|
-
await WecomAIBotMessageEvent._send(message, stream_id)
|
|
100
|
+
await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr)
|
|
98
101
|
await super().send(message)
|
|
99
102
|
|
|
100
103
|
async def send_streaming(self, generator, use_fallback=False):
|
|
@@ -105,7 +108,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
|
|
105
108
|
"wecom_ai_bot platform event raw_message should be a dict"
|
|
106
109
|
)
|
|
107
110
|
stream_id = raw.get("stream_id", self.session_id)
|
|
108
|
-
back_queue =
|
|
111
|
+
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
|
109
112
|
|
|
110
113
|
# 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,积累发送
|
|
111
114
|
increment_plain = ""
|
|
@@ -134,6 +137,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
|
|
134
137
|
final_data += await WecomAIBotMessageEvent._send(
|
|
135
138
|
chain,
|
|
136
139
|
stream_id=stream_id,
|
|
140
|
+
queue_mgr=self.queue_mgr,
|
|
137
141
|
streaming=True,
|
|
138
142
|
)
|
|
139
143
|
|
|
@@ -211,6 +211,8 @@ class LLMResponse:
|
|
|
211
211
|
"""Tool call names."""
|
|
212
212
|
tools_call_ids: list[str] = field(default_factory=list)
|
|
213
213
|
"""Tool call IDs."""
|
|
214
|
+
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
215
|
+
"""Tool call extra content. tool_call_id -> extra_content dict"""
|
|
214
216
|
reasoning_content: str = ""
|
|
215
217
|
"""The reasoning content extracted from the LLM, if any."""
|
|
216
218
|
|
|
@@ -233,6 +235,7 @@ class LLMResponse:
|
|
|
233
235
|
tools_call_args: list[dict[str, Any]] | None = None,
|
|
234
236
|
tools_call_name: list[str] | None = None,
|
|
235
237
|
tools_call_ids: list[str] | None = None,
|
|
238
|
+
tools_call_extra_content: dict[str, dict[str, Any]] | None = None,
|
|
236
239
|
raw_completion: ChatCompletion
|
|
237
240
|
| GenerateContentResponse
|
|
238
241
|
| AnthropicMessage
|
|
@@ -256,6 +259,8 @@ class LLMResponse:
|
|
|
256
259
|
tools_call_name = []
|
|
257
260
|
if tools_call_ids is None:
|
|
258
261
|
tools_call_ids = []
|
|
262
|
+
if tools_call_extra_content is None:
|
|
263
|
+
tools_call_extra_content = {}
|
|
259
264
|
|
|
260
265
|
self.role = role
|
|
261
266
|
self.completion_text = completion_text
|
|
@@ -263,6 +268,7 @@ class LLMResponse:
|
|
|
263
268
|
self.tools_call_args = tools_call_args
|
|
264
269
|
self.tools_call_name = tools_call_name
|
|
265
270
|
self.tools_call_ids = tools_call_ids
|
|
271
|
+
self.tools_call_extra_content = tools_call_extra_content
|
|
266
272
|
self.raw_completion = raw_completion
|
|
267
273
|
self.is_chunk = is_chunk
|
|
268
274
|
|
|
@@ -288,16 +294,19 @@ class LLMResponse:
|
|
|
288
294
|
"""Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead."""
|
|
289
295
|
ret = []
|
|
290
296
|
for idx, tool_call_arg in enumerate(self.tools_call_args):
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
"
|
|
295
|
-
|
|
296
|
-
"arguments": json.dumps(tool_call_arg),
|
|
297
|
-
},
|
|
298
|
-
"type": "function",
|
|
297
|
+
payload = {
|
|
298
|
+
"id": self.tools_call_ids[idx],
|
|
299
|
+
"function": {
|
|
300
|
+
"name": self.tools_call_name[idx],
|
|
301
|
+
"arguments": json.dumps(tool_call_arg),
|
|
299
302
|
},
|
|
300
|
-
|
|
303
|
+
"type": "function",
|
|
304
|
+
}
|
|
305
|
+
if self.tools_call_extra_content.get(self.tools_call_ids[idx]):
|
|
306
|
+
payload["extra_content"] = self.tools_call_extra_content[
|
|
307
|
+
self.tools_call_ids[idx]
|
|
308
|
+
]
|
|
309
|
+
ret.append(payload)
|
|
301
310
|
return ret
|
|
302
311
|
|
|
303
312
|
def to_openai_to_calls_model(self) -> list[ToolCall]:
|
|
@@ -311,6 +320,10 @@ class LLMResponse:
|
|
|
311
320
|
name=self.tools_call_name[idx],
|
|
312
321
|
arguments=json.dumps(tool_call_arg),
|
|
313
322
|
),
|
|
323
|
+
# the extra_content will not serialize if it's None when calling ToolCall.model_dump()
|
|
324
|
+
extra_content=self.tools_call_extra_content.get(
|
|
325
|
+
self.tools_call_ids[idx]
|
|
326
|
+
),
|
|
314
327
|
),
|
|
315
328
|
)
|
|
316
329
|
return ret
|
|
@@ -280,19 +280,22 @@ class FunctionToolManager:
|
|
|
280
280
|
async def _terminate_mcp_client(self, name: str) -> None:
|
|
281
281
|
"""关闭并清理MCP客户端"""
|
|
282
282
|
if name in self.mcp_client_dict:
|
|
283
|
+
client = self.mcp_client_dict[name]
|
|
283
284
|
try:
|
|
284
285
|
# 关闭MCP连接
|
|
285
|
-
await
|
|
286
|
-
self.mcp_client_dict.pop(name)
|
|
286
|
+
await client.cleanup()
|
|
287
287
|
except Exception as e:
|
|
288
288
|
logger.error(f"清空 MCP 客户端资源 {name}: {e}。")
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
289
|
+
finally:
|
|
290
|
+
# Remove client from dict after cleanup attempt (successful or not)
|
|
291
|
+
self.mcp_client_dict.pop(name, None)
|
|
292
|
+
# 移除关联的FuncTool
|
|
293
|
+
self.func_list = [
|
|
294
|
+
f
|
|
295
|
+
for f in self.func_list
|
|
296
|
+
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
|
297
|
+
]
|
|
298
|
+
logger.info(f"已关闭 MCP 服务 {name}")
|
|
296
299
|
|
|
297
300
|
@staticmethod
|
|
298
301
|
async def test_mcp_server_connection(config: dict) -> list[str]:
|
|
@@ -290,13 +290,24 @@ class ProviderGoogleGenAI(Provider):
|
|
|
290
290
|
parts = [types.Part.from_text(text=content)]
|
|
291
291
|
append_or_extend(gemini_contents, parts, types.ModelContent)
|
|
292
292
|
elif not native_tool_enabled and "tool_calls" in message:
|
|
293
|
-
parts = [
|
|
294
|
-
|
|
293
|
+
parts = []
|
|
294
|
+
for tool in message["tool_calls"]:
|
|
295
|
+
part = types.Part.from_function_call(
|
|
295
296
|
name=tool["function"]["name"],
|
|
296
297
|
args=json.loads(tool["function"]["arguments"]),
|
|
297
298
|
)
|
|
298
|
-
|
|
299
|
-
|
|
299
|
+
# we should set thought_signature back to part if exists
|
|
300
|
+
# for more info about thought_signature, see:
|
|
301
|
+
# https://ai.google.dev/gemini-api/docs/thought-signatures
|
|
302
|
+
if "extra_content" in tool and tool["extra_content"]:
|
|
303
|
+
ts_bs64 = (
|
|
304
|
+
tool["extra_content"]
|
|
305
|
+
.get("google", {})
|
|
306
|
+
.get("thought_signature")
|
|
307
|
+
)
|
|
308
|
+
if ts_bs64:
|
|
309
|
+
part.thought_signature = base64.b64decode(ts_bs64)
|
|
310
|
+
parts.append(part)
|
|
300
311
|
append_or_extend(gemini_contents, parts, types.ModelContent)
|
|
301
312
|
else:
|
|
302
313
|
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
|
|
@@ -393,10 +404,15 @@ class ProviderGoogleGenAI(Provider):
|
|
|
393
404
|
llm_response.role = "tool"
|
|
394
405
|
llm_response.tools_call_name.append(part.function_call.name)
|
|
395
406
|
llm_response.tools_call_args.append(part.function_call.args)
|
|
396
|
-
#
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
407
|
+
# function_call.id might be None, use name as fallback
|
|
408
|
+
tool_call_id = part.function_call.id or part.function_call.name
|
|
409
|
+
llm_response.tools_call_ids.append(tool_call_id)
|
|
410
|
+
# extra_content
|
|
411
|
+
if part.thought_signature:
|
|
412
|
+
ts_bs64 = base64.b64encode(part.thought_signature).decode("utf-8")
|
|
413
|
+
llm_response.tools_call_extra_content[tool_call_id] = {
|
|
414
|
+
"google": {"thought_signature": ts_bs64}
|
|
415
|
+
}
|
|
400
416
|
elif (
|
|
401
417
|
part.inline_data
|
|
402
418
|
and part.inline_data.mime_type
|
|
@@ -435,6 +451,7 @@ class ProviderGoogleGenAI(Provider):
|
|
|
435
451
|
contents=conversation,
|
|
436
452
|
config=config,
|
|
437
453
|
)
|
|
454
|
+
logger.debug(f"genai result: {result}")
|
|
438
455
|
|
|
439
456
|
if not result.candidates:
|
|
440
457
|
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
|
|
@@ -8,7 +8,7 @@ import re
|
|
|
8
8
|
from collections.abc import AsyncGenerator
|
|
9
9
|
|
|
10
10
|
from openai import AsyncAzureOpenAI, AsyncOpenAI
|
|
11
|
-
from openai._exceptions import NotFoundError
|
|
11
|
+
from openai._exceptions import NotFoundError
|
|
12
12
|
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
|
|
13
13
|
from openai.types.chat.chat_completion import ChatCompletion
|
|
14
14
|
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
|
@@ -279,6 +279,7 @@ class ProviderOpenAIOfficial(Provider):
|
|
|
279
279
|
args_ls = []
|
|
280
280
|
func_name_ls = []
|
|
281
281
|
tool_call_ids = []
|
|
282
|
+
tool_call_extra_content_dict = {}
|
|
282
283
|
for tool_call in choice.message.tool_calls:
|
|
283
284
|
if isinstance(tool_call, str):
|
|
284
285
|
# workaround for #1359
|
|
@@ -296,11 +297,16 @@ class ProviderOpenAIOfficial(Provider):
|
|
|
296
297
|
args_ls.append(args)
|
|
297
298
|
func_name_ls.append(tool_call.function.name)
|
|
298
299
|
tool_call_ids.append(tool_call.id)
|
|
300
|
+
|
|
301
|
+
# gemini-2.5 / gemini-3 series extra_content handling
|
|
302
|
+
extra_content = getattr(tool_call, "extra_content", None)
|
|
303
|
+
if extra_content is not None:
|
|
304
|
+
tool_call_extra_content_dict[tool_call.id] = extra_content
|
|
299
305
|
llm_response.role = "tool"
|
|
300
306
|
llm_response.tools_call_args = args_ls
|
|
301
307
|
llm_response.tools_call_name = func_name_ls
|
|
302
308
|
llm_response.tools_call_ids = tool_call_ids
|
|
303
|
-
|
|
309
|
+
llm_response.tools_call_extra_content = tool_call_extra_content_dict
|
|
304
310
|
# specially handle finish reason
|
|
305
311
|
if choice.finish_reason == "content_filter":
|
|
306
312
|
raise Exception(
|
|
@@ -353,7 +359,7 @@ class ProviderOpenAIOfficial(Provider):
|
|
|
353
359
|
|
|
354
360
|
payloads = {"messages": context_query, **model_config}
|
|
355
361
|
|
|
356
|
-
# xAI
|
|
362
|
+
# xAI origin search tool inject
|
|
357
363
|
self._maybe_inject_xai_search(payloads, **kwargs)
|
|
358
364
|
|
|
359
365
|
return payloads, context_query
|
|
@@ -475,12 +481,6 @@ class ProviderOpenAIOfficial(Provider):
|
|
|
475
481
|
self.client.api_key = chosen_key
|
|
476
482
|
llm_response = await self._query(payloads, func_tool)
|
|
477
483
|
break
|
|
478
|
-
except UnprocessableEntityError as e:
|
|
479
|
-
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
|
|
480
|
-
# 尝试删除所有 image
|
|
481
|
-
new_contexts = await self._remove_image_from_context(context_query)
|
|
482
|
-
payloads["messages"] = new_contexts
|
|
483
|
-
context_query = new_contexts
|
|
484
484
|
except Exception as e:
|
|
485
485
|
last_exception = e
|
|
486
486
|
(
|
|
@@ -545,12 +545,6 @@ class ProviderOpenAIOfficial(Provider):
|
|
|
545
545
|
async for response in self._query_stream(payloads, func_tool):
|
|
546
546
|
yield response
|
|
547
547
|
break
|
|
548
|
-
except UnprocessableEntityError as e:
|
|
549
|
-
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
|
|
550
|
-
# 尝试删除所有 image
|
|
551
|
-
new_contexts = await self._remove_image_from_context(context_query)
|
|
552
|
-
payloads["messages"] = new_contexts
|
|
553
|
-
context_query = new_contexts
|
|
554
548
|
except Exception as e:
|
|
555
549
|
last_exception = e
|
|
556
550
|
(
|
|
@@ -646,4 +640,3 @@ class ProviderOpenAIOfficial(Provider):
|
|
|
646
640
|
with open(image_url, "rb") as f:
|
|
647
641
|
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
|
|
648
642
|
return "data:image/jpeg;base64," + image_bs64
|
|
649
|
-
return ""
|