diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 636f49114..ffa3aa5a1 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -774,47 +774,70 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if isinstance(resp, CallToolResult):
res = resp
_final_resp = resp
- # Process all content items in the result
- for content_index, content in enumerate(res.content):
- if isinstance(content, TextContent):
- _append_tool_call_result(
- func_tool_id,
- content.text,
- )
- elif isinstance(content, ImageContent):
+ if not res.content:
+ _append_tool_call_result(
+ func_tool_id,
+ "The tool returned no content.",
+ )
+ continue
+
+ result_parts: list[str] = []
+ for index, content_item in enumerate(res.content):
+ if isinstance(content_item, TextContent):
+ result_parts.append(content_item.text)
+ elif isinstance(content_item, ImageContent):
# Cache the image instead of sending directly
- yield _handle_image_content(
- base64_data=content.data,
- mime_type=content.mimeType or "image/png",
+ cached_img = tool_image_cache.save_image(
+ base64_data=content_item.data,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
- content_index=content_index,
+ index=index,
+ mime_type=content_item.mimeType or "image/png",
)
- elif isinstance(content, EmbeddedResource):
- resource = content.resource
+ result_parts.append(
+ f"Image returned and cached at path='{cached_img.file_path}'. "
+ f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
+ f"with type='image' and path='{cached_img.file_path}'."
+ )
+ # Yield image info for LLM visibility (will be handled in step())
+ yield _HandleFunctionToolsResult.from_cached_image(
+ cached_img
+ )
+ elif isinstance(content_item, EmbeddedResource):
+ resource = content_item.resource
if isinstance(resource, TextResourceContents):
- _append_tool_call_result(
- func_tool_id,
- resource.text,
- )
+ result_parts.append(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
# Cache the image instead of sending directly
- yield _handle_image_content(
+ cached_img = tool_image_cache.save_image(
base64_data=resource.blob,
- mime_type=resource.mimeType,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
- content_index=content_index,
+ index=index,
+ mime_type=resource.mimeType,
+ )
+ result_parts.append(
+ f"Image returned and cached at path='{cached_img.file_path}'. "
+ f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
+ f"with type='image' and path='{cached_img.file_path}'."
+ )
+ # Yield image info for LLM visibility
+ yield _HandleFunctionToolsResult.from_cached_image(
+ cached_img
)
else:
- _append_tool_call_result(
- func_tool_id,
- "The tool has returned a data type that is not supported.",
+ result_parts.append(
+ "The tool has returned a data type that is not supported."
)
+ if result_parts:
+ _append_tool_call_result(
+ func_tool_id,
+ "\n\n".join(result_parts),
+ )
elif resp is None:
# Tool 直接请求发送消息给用户
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index 88cb6bda7..2982b7467 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -449,6 +449,8 @@ async def _ensure_img_caption(
for url in req.image_urls:
compressed_url = await _compress_image_for_provider(url, cfg)
compressed_urls.append(compressed_url)
+ if _is_generated_compressed_image_path(url, compressed_url):
+ event.track_temporary_local_file(compressed_url)
caption = await _request_img_caption(
image_caption_provider,
cfg,
@@ -578,6 +580,57 @@ def _is_generated_compressed_image_path(
return os.path.exists(compressed_path)
+def _get_image_compress_args(
+ provider_settings: dict[str, object] | None,
+) -> tuple[bool, int, int]:
+ if not isinstance(provider_settings, dict):
+ return True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY
+
+ enabled = provider_settings.get("image_compress_enabled", True)
+ if not isinstance(enabled, bool):
+ enabled = True
+
+ raw_options = provider_settings.get("image_compress_options", {})
+ options = raw_options if isinstance(raw_options, dict) else {}
+
+ max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE)
+ if not isinstance(max_size, int):
+ max_size = IMAGE_COMPRESS_DEFAULT_MAX_SIZE
+ max_size = max(max_size, 1)
+
+ quality = options.get("quality", IMAGE_COMPRESS_DEFAULT_QUALITY)
+ if not isinstance(quality, int):
+ quality = IMAGE_COMPRESS_DEFAULT_QUALITY
+ quality = min(max(quality, 1), 100)
+
+ return enabled, max_size, quality
+
+
+async def _compress_image_for_provider(
+ url_or_path: str,
+ provider_settings: dict[str, object] | None,
+) -> str:
+ try:
+ enabled, max_size, quality = _get_image_compress_args(provider_settings)
+ if not enabled:
+ return url_or_path
+ return await compress_image(url_or_path, max_size=max_size, quality=quality)
+ except Exception as exc: # noqa: BLE001
+ logger.error("Image compression failed: %s", exc)
+ return url_or_path
+
+
+def _is_generated_compressed_image_path(
+ original_path: str,
+ compressed_path: str | None,
+) -> bool:
+ if not compressed_path or compressed_path == original_path:
+ return False
+ if compressed_path.startswith("http") or compressed_path.startswith("data:image"):
+ return False
+ return os.path.exists(compressed_path)
+
+
async def _process_quote_message(
event: AstrMessageEvent,
req: ProviderRequest,
@@ -626,13 +679,15 @@ async def _process_quote_message(
if prov and isinstance(prov, Provider):
path = await image_seg.convert_to_file_path()
- image_path = await _compress_image_for_provider(
+ compress_path = await _compress_image_for_provider(
path,
config.provider_settings if config else None,
)
+ if path and _is_generated_compressed_image_path(path, compress_path):
+ event.track_temporary_local_file(compress_path)
llm_resp = await prov.text_chat(
- prompt=IMAGE_CAPTION_DEFAULT_PROMPT,
- image_urls=[image_path],
+ prompt="Please describe the image content.",
+ image_urls=[compress_path],
)
if llm_resp.completion_text:
content_parts.append(
@@ -1062,6 +1117,8 @@ async def build_main_agent(
path,
config.provider_settings,
)
+ if _is_generated_compressed_image_path(path, image_path):
+ event.track_temporary_local_file(image_path)
req.image_urls.append(image_path)
req.extra_user_content_parts.append(
TextPart(text=f"[Image Attachment: path {image_path}]")
@@ -1093,6 +1150,8 @@ async def build_main_agent(
path,
config.provider_settings,
)
+ if _is_generated_compressed_image_path(path, image_path):
+ event.track_temporary_local_file(image_path)
req.image_urls.append(image_path)
_append_quoted_image_attachment(req, image_path)
elif isinstance(reply_comp, File):
diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py
index 9a1c9c7b8..de1b2350d 100644
--- a/astrbot/core/astr_main_agent_resources.py
+++ b/astrbot/core/astr_main_agent_resources.py
@@ -468,7 +468,17 @@ async def retrieve_knowledge_base(
if not kb_names:
return
+<<<<<<< HEAD
logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
+=======
+ all_kbs = [await kb_mgr.get_kb_by_name(kb) for kb in kb_names]
+
+ if check_all_kb(all_kbs):
+ logger.debug("所配置的所有知识库全为空,跳过检索过程")
+ return
+
+ logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
+>>>>>>> eab231fd94b8416ac854047e9a976385fcbb386a
kb_context = await kb_mgr.retrieve(
query=query,
kb_names=kb_names,
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 43f980c64..21d8e8658 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -193,7 +193,7 @@ DEFAULT_CONFIG = {
},
"image_compress_enabled": True,
"image_compress_options": {
- "max_size": 1024,
+ "max_size": 1280,
"quality": 95,
},
},
@@ -3520,6 +3520,29 @@ CONFIG_METADATA_3 = {
},
"slider": {"min": 1, "max": 100, "step": 1},
},
+ "provider_settings.image_compress_enabled": {
+ "description": "启用图片压缩",
+ "type": "bool",
+ "hint": "启用后,发送给多模态模型前会先压缩本地大图片。",
+ },
+ "provider_settings.image_compress_options.max_size": {
+ "description": "最大边长",
+ "type": "int",
+ "hint": "压缩后图片的最长边,单位为像素。超过该尺寸时会按比例缩放。",
+ "condition": {
+ "provider_settings.image_compress_enabled": True,
+ },
+ "slider": {"min": 256, "max": 4096, "step": 64},
+ },
+ "provider_settings.image_compress_options.quality": {
+ "description": "压缩质量",
+ "type": "int",
+ "hint": "JPEG 输出质量,范围为 1-100。值越高,画质越好,文件也越大。",
+ "condition": {
+ "provider_settings.image_compress_enabled": True,
+ },
+ "slider": {"min": 1, "max": 100, "step": 1},
+ },
"provider_tts_settings.dual_output": {
"description": "开启 TTS 时同时输出语音和文字内容",
"type": "bool",
diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py
index 041f37bad..803b04a5e 100644
--- a/astrbot/core/platform/manager.py
+++ b/astrbot/core/platform/manager.py
@@ -144,9 +144,69 @@ class PlatformManager:
logger.info(
f"载入 {platform_config['type']}({platform_config['id']}) 平台适配器 ...",
)
- module_path = PLATFORM_ADAPTER_MODULES.get(platform_config["type"])
- if module_path is not None:
- import_module(module_path, package=__package__)
+ match platform_config["type"]:
+ case "aiocqhttp":
+ from .sources.aiocqhttp.aiocqhttp_platform_adapter import (
+ AiocqhttpAdapter, # noqa: F401
+ )
+ case "qq_official":
+ from .sources.qqofficial.qqofficial_platform_adapter import (
+ QQOfficialPlatformAdapter, # noqa: F401
+ )
+ case "qq_official_webhook":
+ from .sources.qqofficial_webhook.qo_webhook_adapter import (
+ QQOfficialWebhookPlatformAdapter, # noqa: F401
+ )
+ case "lark":
+ from .sources.lark.lark_adapter import (
+ LarkPlatformAdapter, # noqa: F401
+ )
+ case "dingtalk":
+ from .sources.dingtalk.dingtalk_adapter import (
+ DingtalkPlatformAdapter, # noqa: F401
+ )
+ case "telegram":
+ from .sources.telegram.tg_adapter import (
+ TelegramPlatformAdapter, # noqa: F401
+ )
+ case "wecom":
+ from .sources.wecom.wecom_adapter import (
+ WecomPlatformAdapter, # noqa: F401
+ )
+ case "wecom_ai_bot":
+ from .sources.wecom_ai_bot.wecomai_adapter import (
+ WecomAIBotAdapter, # noqa: F401
+ )
+ case "weixin_official_account":
+ from .sources.weixin_official_account.weixin_offacc_adapter import (
+ WeixinOfficialAccountPlatformAdapter, # noqa: F401
+ )
+ case "discord":
+ from .sources.discord.discord_platform_adapter import (
+ DiscordPlatformAdapter, # noqa: F401
+ )
+ case "misskey":
+ from .sources.misskey.misskey_adapter import (
+ MisskeyPlatformAdapter, # noqa: F401
+ )
+ case "weixin_oc":
+ from .sources.weixin_oc.weixin_oc_adapter import (
+ WeixinOCAdapter, # noqa: F401
+ )
+ case "slack":
+ from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
+ case "satori":
+ from .sources.satori.satori_adapter import (
+ SatoriPlatformAdapter, # noqa: F401
+ )
+ case "line":
+ from .sources.line.line_adapter import (
+ LinePlatformAdapter, # noqa: F401
+ )
+ case "kook":
+ from .sources.kook.kook_adapter import (
+ KookPlatformAdapter, # noqa: F401
+ )
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py
index 2673efcc3..b8199aa5c 100644
--- a/astrbot/core/platform/sources/lark/lark_event.py
+++ b/astrbot/core/platform/sources/lark/lark_event.py
@@ -762,30 +762,13 @@ class LarkMessageEvent(AstrMessageEvent):
async def send_streaming(self, generator, use_fallback: bool = False):
"""使用 CardKit 流式卡片实现打字机效果。
- 流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
- 使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
- 发送频率由网络 RTT 自然限流。
+ 流程:首字到来时创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
+ 卡片创建延迟到第一个文本 token 到达时,避免工具调用阶段就渲染空卡片。
+ 使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
+ 发送频率由网络 RTT 自然限流。
"""
- # Step 1: 创建流式卡片实体
- card_id = await self._create_streaming_card()
- if not card_id:
- logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
- await self._fallback_send_streaming(generator, use_fallback)
- return
-
- # Step 2: 发送卡片消息
- sent = await self._send_card_message(
- card_id,
- reply_message_id=self.message_obj.message_id,
- )
- if not sent:
- logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
- await self._fallback_send_streaming(generator, use_fallback)
- return
-
- logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
-
- # Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
+ # Lazy-init: card & sender loop created on first text token
+ card_id = None
sequence = 0
delta = ""
last_sent = ""
@@ -842,7 +825,21 @@ class LarkMessageEvent(AstrMessageEvent):
continue
if chain.type == "break":
- # 飞书卡片不支持分段,忽略 break
+ # Tool call boundary: close current card, next text
+ # token will lazily create a new one below the tool
+ # status message.
+ if card_id and sender_task:
+ done = True
+ text_changed.set()
+ await sender_task
+ await _flush_and_close_card()
+ # Reset for lazy new-card creation
+ card_id = None
+ sequence = 0
+ delta = ""
+ last_sent = ""
+ done = False
+ sender_task = None
continue
for comp in chain.chain:
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index 743c98afb..9294f423e 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -165,10 +165,7 @@ class Context:
system_prompt: str | None = None,
contexts: list[Message] | None = None,
max_steps: int = 30,
- tool_call_timeout: int = 60,
- stream: bool = False,
- agent_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
- agent_context: AstrAgentContext | None = None,
+ tool_call_timeout: int = 120,
**kwargs: Any,
) -> LLMResponse:
"""Run an agent loop that allows the LLM to call tools iteratively until a final answer is produced.
diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py
index b7a402424..af1a0ca6c 100644
--- a/astrbot/core/utils/media_utils.py
+++ b/astrbot/core/utils/media_utils.py
@@ -16,6 +16,8 @@ from PIL import Image as PILImage
from PIL import Image as PILImage
+from PIL import Image as PILImage
+
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue
index 949f5c7e8..d60c12bba 100644
--- a/dashboard/src/components/extension/MarketPluginCard.vue
+++ b/dashboard/src/components/extension/MarketPluginCard.vue
@@ -50,13 +50,8 @@ const handleInstall = (plugin) => {
+ class="plugin-cover__image"
+ />