AstrBot 4.6.1__py3-none-any.whl → 4.7.1__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.
Files changed (37) hide show
  1. astrbot/core/agent/mcp_client.py +3 -3
  2. astrbot/core/agent/runners/base.py +7 -4
  3. astrbot/core/agent/runners/coze/coze_agent_runner.py +367 -0
  4. astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +403 -0
  5. astrbot/core/agent/runners/dify/dify_agent_runner.py +336 -0
  6. astrbot/core/{utils → agent/runners/dify}/dify_api_client.py +51 -13
  7. astrbot/core/agent/runners/tool_loop_agent_runner.py +0 -6
  8. astrbot/core/config/default.py +141 -26
  9. astrbot/core/config/i18n_utils.py +110 -0
  10. astrbot/core/core_lifecycle.py +11 -13
  11. astrbot/core/db/po.py +1 -1
  12. astrbot/core/db/sqlite.py +2 -2
  13. astrbot/core/pipeline/process_stage/method/agent_request.py +48 -0
  14. astrbot/core/pipeline/process_stage/method/{llm_request.py → agent_sub_stages/internal.py} +13 -34
  15. astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +202 -0
  16. astrbot/core/pipeline/process_stage/method/star_request.py +1 -1
  17. astrbot/core/pipeline/process_stage/stage.py +8 -5
  18. astrbot/core/pipeline/result_decorate/stage.py +15 -5
  19. astrbot/core/provider/manager.py +43 -41
  20. astrbot/core/star/session_llm_manager.py +0 -107
  21. astrbot/core/star/session_plugin_manager.py +0 -81
  22. astrbot/core/umop_config_router.py +19 -0
  23. astrbot/core/utils/migra_helper.py +73 -0
  24. astrbot/core/utils/shared_preferences.py +1 -28
  25. astrbot/dashboard/routes/chat.py +13 -1
  26. astrbot/dashboard/routes/config.py +20 -16
  27. astrbot/dashboard/routes/knowledge_base.py +0 -156
  28. astrbot/dashboard/routes/session_management.py +311 -606
  29. {astrbot-4.6.1.dist-info → astrbot-4.7.1.dist-info}/METADATA +1 -1
  30. {astrbot-4.6.1.dist-info → astrbot-4.7.1.dist-info}/RECORD +34 -30
  31. {astrbot-4.6.1.dist-info → astrbot-4.7.1.dist-info}/WHEEL +1 -1
  32. astrbot/core/provider/sources/coze_source.py +0 -650
  33. astrbot/core/provider/sources/dashscope_source.py +0 -207
  34. astrbot/core/provider/sources/dify_source.py +0 -285
  35. /astrbot/core/{provider/sources → agent/runners/coze}/coze_api_client.py +0 -0
  36. {astrbot-4.6.1.dist-info → astrbot-4.7.1.dist-info}/entry_points.txt +0 -0
  37. {astrbot-4.6.1.dist-info → astrbot-4.7.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,336 @@
1
+ import base64
2
+ import os
3
+ import sys
4
+ import typing as T
5
+
6
+ import astrbot.core.message.components as Comp
7
+ from astrbot.core import logger, sp
8
+ from astrbot.core.message.message_event_result import MessageChain
9
+ from astrbot.core.provider.entities import (
10
+ LLMResponse,
11
+ ProviderRequest,
12
+ )
13
+ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
14
+ from astrbot.core.utils.io import download_file
15
+
16
+ from ...hooks import BaseAgentRunHooks
17
+ from ...response import AgentResponseData
18
+ from ...run_context import ContextWrapper, TContext
19
+ from ..base import AgentResponse, AgentState, BaseAgentRunner
20
+ from .dify_api_client import DifyAPIClient
21
+
22
+ if sys.version_info >= (3, 12):
23
+ from typing import override
24
+ else:
25
+ from typing_extensions import override
26
+
27
+
28
+ class DifyAgentRunner(BaseAgentRunner[TContext]):
29
+ """Dify Agent Runner"""
30
+
31
+ @override
32
+ async def reset(
33
+ self,
34
+ request: ProviderRequest,
35
+ run_context: ContextWrapper[TContext],
36
+ agent_hooks: BaseAgentRunHooks[TContext],
37
+ provider_config: dict,
38
+ **kwargs: T.Any,
39
+ ) -> None:
40
+ self.req = request
41
+ self.streaming = kwargs.get("streaming", False)
42
+ self.final_llm_resp = None
43
+ self._state = AgentState.IDLE
44
+ self.agent_hooks = agent_hooks
45
+ self.run_context = run_context
46
+
47
+ self.api_key = provider_config.get("dify_api_key", "")
48
+ self.api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
49
+ self.api_type = provider_config.get("dify_api_type", "chat")
50
+ self.workflow_output_key = provider_config.get(
51
+ "dify_workflow_output_key",
52
+ "astrbot_wf_output",
53
+ )
54
+ self.dify_query_input_key = provider_config.get(
55
+ "dify_query_input_key",
56
+ "astrbot_text_query",
57
+ )
58
+ self.variables: dict = provider_config.get("variables", {}) or {}
59
+ self.timeout = provider_config.get("timeout", 60)
60
+ if isinstance(self.timeout, str):
61
+ self.timeout = int(self.timeout)
62
+
63
+ self.api_client = DifyAPIClient(self.api_key, self.api_base)
64
+
65
+ @override
66
+ async def step(self):
67
+ """
68
+ 执行 Dify Agent 的一个步骤
69
+ """
70
+ if not self.req:
71
+ raise ValueError("Request is not set. Please call reset() first.")
72
+
73
+ if self._state == AgentState.IDLE:
74
+ try:
75
+ await self.agent_hooks.on_agent_begin(self.run_context)
76
+ except Exception as e:
77
+ logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
78
+
79
+ # 开始处理,转换到运行状态
80
+ self._transition_state(AgentState.RUNNING)
81
+
82
+ try:
83
+ # 执行 Dify 请求并处理结果
84
+ async for response in self._execute_dify_request():
85
+ yield response
86
+ except Exception as e:
87
+ logger.error(f"Dify 请求失败:{str(e)}")
88
+ self._transition_state(AgentState.ERROR)
89
+ self.final_llm_resp = LLMResponse(
90
+ role="err", completion_text=f"Dify 请求失败:{str(e)}"
91
+ )
92
+ yield AgentResponse(
93
+ type="err",
94
+ data=AgentResponseData(
95
+ chain=MessageChain().message(f"Dify 请求失败:{str(e)}")
96
+ ),
97
+ )
98
+ finally:
99
+ await self.api_client.close()
100
+
101
+ @override
102
+ async def step_until_done(
103
+ self, max_step: int = 30
104
+ ) -> T.AsyncGenerator[AgentResponse, None]:
105
+ while not self.done():
106
+ async for resp in self.step():
107
+ yield resp
108
+
109
+ async def _execute_dify_request(self):
110
+ """执行 Dify 请求的核心逻辑"""
111
+ prompt = self.req.prompt or ""
112
+ session_id = self.req.session_id or "unknown"
113
+ image_urls = self.req.image_urls or []
114
+ system_prompt = self.req.system_prompt
115
+
116
+ conversation_id = await sp.get_async(
117
+ scope="umo",
118
+ scope_id=session_id,
119
+ key="dify_conversation_id",
120
+ default="",
121
+ )
122
+ result = ""
123
+
124
+ # 处理图片上传
125
+ files_payload = []
126
+ for image_url in image_urls:
127
+ # image_url is a base64 string
128
+ try:
129
+ image_data = base64.b64decode(image_url)
130
+ file_response = await self.api_client.file_upload(
131
+ file_data=image_data,
132
+ user=session_id,
133
+ mime_type="image/png",
134
+ file_name="image.png",
135
+ )
136
+ logger.debug(f"Dify 上传图片响应:{file_response}")
137
+ if "id" not in file_response:
138
+ logger.warning(
139
+ f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
140
+ )
141
+ continue
142
+ files_payload.append(
143
+ {
144
+ "type": "image",
145
+ "transfer_method": "local_file",
146
+ "upload_file_id": file_response["id"],
147
+ }
148
+ )
149
+ except Exception as e:
150
+ logger.warning(f"上传图片失败:{e}")
151
+ continue
152
+
153
+ # 获得会话变量
154
+ payload_vars = self.variables.copy()
155
+ # 动态变量
156
+ session_var = await sp.get_async(
157
+ scope="umo",
158
+ scope_id=session_id,
159
+ key="session_variables",
160
+ default={},
161
+ )
162
+ payload_vars.update(session_var)
163
+ payload_vars["system_prompt"] = system_prompt
164
+
165
+ # 处理不同的 API 类型
166
+ match self.api_type:
167
+ case "chat" | "agent" | "chatflow":
168
+ if not prompt:
169
+ prompt = "请描述这张图片。"
170
+
171
+ async for chunk in self.api_client.chat_messages(
172
+ inputs={
173
+ **payload_vars,
174
+ },
175
+ query=prompt,
176
+ user=session_id,
177
+ conversation_id=conversation_id,
178
+ files=files_payload,
179
+ timeout=self.timeout,
180
+ ):
181
+ logger.debug(f"dify resp chunk: {chunk}")
182
+ if chunk["event"] == "message" or chunk["event"] == "agent_message":
183
+ result += chunk["answer"]
184
+ if not conversation_id:
185
+ await sp.put_async(
186
+ scope="umo",
187
+ scope_id=session_id,
188
+ key="dify_conversation_id",
189
+ value=chunk["conversation_id"],
190
+ )
191
+ conversation_id = chunk["conversation_id"]
192
+
193
+ # 如果是流式响应,发送增量数据
194
+ if self.streaming and chunk["answer"]:
195
+ yield AgentResponse(
196
+ type="streaming_delta",
197
+ data=AgentResponseData(
198
+ chain=MessageChain().message(chunk["answer"])
199
+ ),
200
+ )
201
+ elif chunk["event"] == "message_end":
202
+ logger.debug("Dify message end")
203
+ break
204
+ elif chunk["event"] == "error":
205
+ logger.error(f"Dify 出现错误:{chunk}")
206
+ raise Exception(
207
+ f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}"
208
+ )
209
+
210
+ case "workflow":
211
+ async for chunk in self.api_client.workflow_run(
212
+ inputs={
213
+ self.dify_query_input_key: prompt,
214
+ "astrbot_session_id": session_id,
215
+ **payload_vars,
216
+ },
217
+ user=session_id,
218
+ files=files_payload,
219
+ timeout=self.timeout,
220
+ ):
221
+ logger.debug(f"dify workflow resp chunk: {chunk}")
222
+ match chunk["event"]:
223
+ case "workflow_started":
224
+ logger.info(
225
+ f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。"
226
+ )
227
+ case "node_finished":
228
+ logger.debug(
229
+ f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。"
230
+ )
231
+ case "text_chunk":
232
+ if self.streaming and chunk["data"]["text"]:
233
+ yield AgentResponse(
234
+ type="streaming_delta",
235
+ data=AgentResponseData(
236
+ chain=MessageChain().message(
237
+ chunk["data"]["text"]
238
+ )
239
+ ),
240
+ )
241
+ case "workflow_finished":
242
+ logger.info(
243
+ f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
244
+ )
245
+ logger.debug(f"Dify 工作流结果:{chunk}")
246
+ if chunk["data"]["error"]:
247
+ logger.error(
248
+ f"Dify 工作流出现错误:{chunk['data']['error']}"
249
+ )
250
+ raise Exception(
251
+ f"Dify 工作流出现错误:{chunk['data']['error']}"
252
+ )
253
+ if self.workflow_output_key not in chunk["data"]["outputs"]:
254
+ raise Exception(
255
+ f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
256
+ )
257
+ result = chunk
258
+ case _:
259
+ raise Exception(f"未知的 Dify API 类型:{self.api_type}")
260
+
261
+ if not result:
262
+ logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
263
+
264
+ # 解析结果
265
+ chain = await self.parse_dify_result(result)
266
+
267
+ # 创建最终响应
268
+ self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
269
+ self._transition_state(AgentState.DONE)
270
+
271
+ try:
272
+ await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
273
+ except Exception as e:
274
+ logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
275
+
276
+ # 返回最终结果
277
+ yield AgentResponse(
278
+ type="llm_result",
279
+ data=AgentResponseData(chain=chain),
280
+ )
281
+
282
+ async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
283
+ """解析 Dify 的响应结果"""
284
+ if isinstance(chunk, str):
285
+ # Chat
286
+ return MessageChain(chain=[Comp.Plain(chunk)])
287
+
288
+ async def parse_file(item: dict):
289
+ match item["type"]:
290
+ case "image":
291
+ return Comp.Image(file=item["url"], url=item["url"])
292
+ case "audio":
293
+ # 仅支持 wav
294
+ temp_dir = os.path.join(get_astrbot_data_path(), "temp")
295
+ path = os.path.join(temp_dir, f"{item['filename']}.wav")
296
+ await download_file(item["url"], path)
297
+ return Comp.Image(file=item["url"], url=item["url"])
298
+ case "video":
299
+ return Comp.Video(file=item["url"])
300
+ case _:
301
+ return Comp.File(name=item["filename"], file=item["url"])
302
+
303
+ output = chunk["data"]["outputs"][self.workflow_output_key]
304
+ chains = []
305
+ if isinstance(output, str):
306
+ # 纯文本输出
307
+ chains.append(Comp.Plain(output))
308
+ elif isinstance(output, list):
309
+ # 主要适配 Dify 的 HTTP 请求结点的多模态输出
310
+ for item in output:
311
+ # handle Array[File]
312
+ if (
313
+ not isinstance(item, dict)
314
+ or item.get("dify_model_identity", "") != "__dify__file__"
315
+ ):
316
+ chains.append(Comp.Plain(str(output)))
317
+ break
318
+ else:
319
+ chains.append(Comp.Plain(str(output)))
320
+
321
+ # scan file
322
+ files = chunk["data"].get("files", [])
323
+ for item in files:
324
+ comp = await parse_file(item)
325
+ chains.append(comp)
326
+
327
+ return MessageChain(chain=chains)
328
+
329
+ @override
330
+ def done(self) -> bool:
331
+ """检查 Agent 是否已完成工作"""
332
+ return self._state in (AgentState.DONE, AgentState.ERROR)
333
+
334
+ @override
335
+ def get_final_llm_resp(self) -> LLMResponse | None:
336
+ return self.final_llm_resp
@@ -3,7 +3,7 @@ import json
3
3
  from collections.abc import AsyncGenerator
4
4
  from typing import Any
5
5
 
6
- from aiohttp import ClientResponse, ClientSession
6
+ from aiohttp import ClientResponse, ClientSession, FormData
7
7
 
8
8
  from astrbot.core import logger
9
9
 
@@ -101,21 +101,59 @@ class DifyAPIClient:
101
101
 
102
102
  async def file_upload(
103
103
  self,
104
- file_path: str,
105
104
  user: str,
105
+ file_path: str | None = None,
106
+ file_data: bytes | None = None,
107
+ file_name: str | None = None,
108
+ mime_type: str | None = None,
106
109
  ) -> dict[str, Any]:
110
+ """Upload a file to Dify. Must provide either file_path or file_data.
111
+
112
+ Args:
113
+ user: The user ID.
114
+ file_path: The path to the file to upload.
115
+ file_data: The file data in bytes.
116
+ file_name: Optional file name when using file_data.
117
+ Returns:
118
+ A dictionary containing the uploaded file information.
119
+ """
107
120
  url = f"{self.api_base}/files/upload"
108
- with open(file_path, "rb") as f:
109
- payload = {
110
- "user": user,
111
- "file": f,
112
- }
113
- async with self.session.post(
114
- url,
115
- data=payload,
116
- headers=self.headers,
117
- ) as resp:
118
- return await resp.json() # {"id": "xxx", ...}
121
+
122
+ form = FormData()
123
+ form.add_field("user", user)
124
+
125
+ if file_data is not None:
126
+ # 使用 bytes 数据
127
+ form.add_field(
128
+ "file",
129
+ file_data,
130
+ filename=file_name or "uploaded_file",
131
+ content_type=mime_type or "application/octet-stream",
132
+ )
133
+ elif file_path is not None:
134
+ # 使用文件路径
135
+ import os
136
+
137
+ with open(file_path, "rb") as f:
138
+ file_content = f.read()
139
+ form.add_field(
140
+ "file",
141
+ file_content,
142
+ filename=os.path.basename(file_path),
143
+ content_type=mime_type or "application/octet-stream",
144
+ )
145
+ else:
146
+ raise ValueError("file_path 和 file_data 不能同时为 None")
147
+
148
+ async with self.session.post(
149
+ url,
150
+ data=form,
151
+ headers=self.headers, # 不包含 Content-Type,让 aiohttp 自动设置
152
+ ) as resp:
153
+ if resp.status != 200 and resp.status != 201:
154
+ text = await resp.text()
155
+ raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
156
+ return await resp.json() # {"id": "xxx", ...}
119
157
 
120
158
  async def close(self):
121
159
  await self.session.close()
@@ -69,12 +69,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
69
69
  )
70
70
  self.run_context.messages = messages
71
71
 
72
- def _transition_state(self, new_state: AgentState) -> None:
73
- """转换 Agent 状态"""
74
- if self._state != new_state:
75
- logger.debug(f"Agent state transition: {self._state} -> {new_state}")
76
- self._state = new_state
77
-
78
72
  async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
79
73
  """Yields chunks *and* a final LLMResponse."""
80
74
  if self.streaming: