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" + />
@@ -81,10 +76,7 @@ const handleInstall = (plugin) => {
-
+
{ />
-
-
- - {{ plugin.stars }} -
-
- - {{ new Date(plugin.updated_at).toLocaleString() }} -
-
+
@@ -273,12 +237,14 @@ const handleInstall = (plugin) => { > {{ tm("buttons.install") }} - ✓ {{ tm("status.installed") }} diff --git a/dashboard/src/components/folder/BaseFolderCard.vue b/dashboard/src/components/folder/BaseFolderCard.vue index f7323974b..dfab57224 100644 --- a/dashboard/src/components/folder/BaseFolderCard.vue +++ b/dashboard/src/components/folder/BaseFolderCard.vue @@ -1,89 +1,48 @@