diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 37ad1ba19..0b1ea2903 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -238,6 +238,9 @@ class QQOfficialPlatformAdapter(Platform): ) elif session.message_type == MessageType.FRIEND_MESSAGE: + # 参考 https://bot.q.qq.com/wiki/develop/pythonsdk/api/message/post_message.html + # msg_id 缺失时认为是主动推送,而似乎至少在私聊上主动推送是没有被限制的,这里直接移除 msg_id 可以避免越权或 msg_id 不可用的bug + payload.pop("msg_id", None) payload["msg_seq"] = random.randint(1, 10000) if image_base64: media = await QQOfficialMessageEvent.upload_group_and_c2c_image( @@ -268,9 +271,6 @@ class QQOfficialPlatformAdapter(Platform): if media: payload["media"] = media payload["msg_type"] = 7 - # QQ API rejects msg_id for media (video/file) messages sent - # via the proactive tool-call path; remove it to avoid 越权 error. - payload.pop("msg_id", None) if file_source: media = await QQOfficialMessageEvent.upload_group_and_c2c_media( send_helper, @@ -282,7 +282,6 @@ class QQOfficialPlatformAdapter(Platform): if media: payload["media"] = media payload["msg_type"] = 7 - payload.pop("msg_id", None) ret = await QQOfficialMessageEvent.post_c2c_message( send_helper, diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index 7e2501af6..07810ca47 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -895,7 +895,13 @@ class WeixinOCAdapter(Platform): await asyncio.sleep(self.qr_poll_interval) continue - await self._poll_inbound_updates() + try: + await self._poll_inbound_updates() + except asyncio.TimeoutError: + logger.debug( + "weixin_oc(%s): inbound long-poll timeout", + self.meta().id, + ) except asyncio.CancelledError: raise except Exception as e: diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 2b62d865c..9bf127766 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -24,9 +24,15 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): if proxy: logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}") http_client = httpx.AsyncClient(proxy=proxy) - api_base = provider_config.get( - "embedding_api_base", "https://api.openai.com/v1" - ).strip() + api_base = ( + provider_config.get("embedding_api_base", "https://api.openai.com/v1") + .strip() + .rstrip("/") + .rstrip("/embeddings") + ) + if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"): + # /v4 see #5699 + api_base = api_base + "/v1" logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}") self.client = AsyncOpenAI( api_key=provider_config.get("embedding_api_key"), diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 3da13d49c..72cdbd722 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -335,11 +335,15 @@ class ProviderOpenAIOfficial(Provider): choice = chunk.choices[0] delta = choice.delta - # siliconflow workaround if dtcs := delta.tool_calls: - for tc in dtcs: + for idx, tc in enumerate(dtcs): + # siliconflow workaround if tc.function and tc.function.arguments: tc.type = "function" + # Fix for #6661: Add missing 'index' field to tool_call deltas + # Gemini and some OpenAI-compatible proxies omit this field + if not hasattr(tc, "index") or tc.index is None: + tc.index = idx try: state.handle_chunk(chunk) except Exception as e: diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py index 4b4782999..fc632d1f5 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -51,19 +51,19 @@ class SessionManagementRoute(Route): async def _get_umo_rules( self, page: int = 1, page_size: int = 10, search: str = "" ) -> tuple[dict, int]: - """获取所有带有自定义规则的 umo 及其规则内容(支持分页和搜索)。 + """获取所有带有自定义规则的 umo 及其规则内容(支持分页和搜索)。 - 如果某个 umo 在 preference 中有以下字段,则表示有自定义规则: + 如果某个 umo 在 preference 中有以下字段,则表示有自定义规则: - 1. session_service_config (包含了 是否启用这个umo, 这个umo是否启用 llm, 这个umo是否启用tts, umo自定义名称。) + 1. session_service_config (包含了 是否启用这个umo, 这个umo是否启用 llm, 这个umo是否启用tts, umo自定义名称。) 2. session_plugin_config (包含了 这个 umo 的 plugin set) 3. provider_perf_{ProviderType.value} (包含了这个 umo 所选择使用的 provider 信息) 4. kb_config (包含了这个 umo 的知识库相关配置) Args: - page: 页码,从 1 开始 + page: 页码,从 1 开始 page_size: 每页数量 - search: 搜索关键词,匹配 umo 或 custom_name + search: 搜索关键词,匹配 umo 或 custom_name Returns: tuple[dict, int]: (umo_rules, total) - 分页后的 umo 规则和总数 @@ -118,14 +118,14 @@ class SessionManagementRoute(Route): return paginated_rules, total async def list_session_rule(self): - """获取所有自定义的规则(支持分页和搜索) + """获取所有自定义的规则(支持分页和搜索) - 返回已配置规则的 umo 列表及其规则内容,以及可用的 personas 和 providers + 返回已配置规则的 umo 列表及其规则内容,以及可用的 personas 和 providers Query 参数: - page: 页码,默认为 1 - page_size: 每页数量,默认为 10 - search: 搜索关键词,匹配 umo 或 custom_name + page: 页码,默认为 1 + page_size: 每页数量,默认为 10 + search: 搜索关键词,匹配 umo 或 custom_name """ try: # 获取分页和搜索参数 @@ -196,7 +196,7 @@ class SessionManagementRoute(Route): for p in provider_manager.tts_provider_insts ] - # 获取可用的插件列表(排除 reserved 的系统插件) + # 获取可用的插件列表(排除 reserved 的系统插件) plugin_manager = self.core_lifecycle.plugin_manager available_plugins = [ { @@ -255,7 +255,7 @@ class SessionManagementRoute(Route): { "umo": "平台:消息类型:会话ID", "rule_key": "session_service_config" | "session_plugin_config" | "kb_config" | "provider_perf_xxx", - "rule_value": {...} // 规则值,具体结构根据 rule_key 不同而不同 + "rule_value": {...} // 规则值,具体结构根据 rule_key 不同而不同 } """ try: @@ -294,7 +294,7 @@ class SessionManagementRoute(Route): 请求体: { "umo": "平台:消息类型:会话ID", - "rule_key": "session_service_config" | "session_plugin_config" | ... (可选,不传则删除所有规则) + "rule_key": "session_service_config" | "session_plugin_config" | ... (可选,不传则删除所有规则) } """ try: @@ -328,37 +328,87 @@ class SessionManagementRoute(Route): 请求体: { - "umos": ["平台:消息类型:会话ID", ...] // umo 列表 + "umos": ["平台:消息类型:会话ID", ...], // 可选 + "scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围 + "group_id": "分组ID", // 当 scope 为 custom_group 时必填 + "rule_key": "session_service_config" | ... (可选,不传则删除所有规则) } """ + try: data = await request.get_json() umos = data.get("umos", []) + scope = data.get("scope", "") + group_id = data.get("group_id", "") + rule_key = data.get("rule_key") + + # 如果指定了 scope,获取符合条件的所有 umo + if scope and not umos: + # 如果是自定义分组 + if scope == "custom_group": + if not group_id: + return Response().error("请指定分组 ID").__dict__ + groups = self._get_groups() + if group_id not in groups: + return Response().error(f"分组 '{group_id}' 不存在").__dict__ + umos = groups[group_id].get("umos", []) + else: + async with self.db_helper.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ConversationV2.user_id).distinct() + ) + all_umos = [row[0] for row in result.fetchall()] + + if scope == "group": + umos = [ + u + for u in all_umos + if ":group:" in u.lower() or ":groupmessage:" in u.lower() + ] + elif scope == "private": + umos = [ + u + for u in all_umos + if ":private:" in u.lower() or ":friend" in u.lower() + ] + elif scope == "all": + umos = all_umos if not umos: - return Response().error("缺少必要参数: umos").__dict__ + return Response().error("缺少必要参数: umos 或有效的 scope").__dict__ if not isinstance(umos, list): return Response().error("参数 umos 必须是数组").__dict__ + if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS: + return Response().error(f"不支持的规则键: {rule_key}").__dict__ + # 批量删除 - deleted_count = 0 + success_count = 0 failed_umos = [] for umo in umos: try: - await sp.clear_async("umo", umo) - deleted_count += 1 + if rule_key: + await sp.session_remove(umo, rule_key) + else: + await sp.clear_async("umo", umo) + success_count += 1 except Exception as e: logger.error(f"删除 umo {umo} 的规则失败: {e!s}") failed_umos.append(umo) + message = f"已删除 {success_count} 条规则" + if rule_key: + message = f"已删除 {success_count} 条 {rule_key} 规则" + if failed_umos: return ( Response() .ok( { - "message": f"已删除 {deleted_count} 条规则,{len(failed_umos)} 条删除失败", - "deleted_count": deleted_count, + "message": f"{message},{len(failed_umos)} 条删除失败", + "success_count": success_count, "failed_umos": failed_umos, } ) @@ -369,8 +419,8 @@ class SessionManagementRoute(Route): Response() .ok( { - "message": f"已删除 {deleted_count} 条规则", - "deleted_count": deleted_count, + "message": message, + "success_count": success_count, } ) .__dict__ @@ -380,9 +430,9 @@ class SessionManagementRoute(Route): return Response().error(f"批量删除会话规则失败: {e!s}").__dict__ async def list_umos(self): - """列出所有有对话记录的 umo,从 Conversations 表中找 + """列出所有有对话记录的 umo,从 Conversations 表中找 - 仅返回 umo 字符串列表,用于用户在创建规则时选择 umo + 仅返回 umo 字符串列表,用于用户在创建规则时选择 umo """ try: # 从 Conversation 表获取所有 distinct user_id (即 umo) @@ -401,11 +451,11 @@ class SessionManagementRoute(Route): return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__ async def list_all_umos_with_status(self): - """获取所有有对话记录的 UMO 及其服务状态(支持分页、搜索、筛选) + """获取所有有对话记录的 UMO 及其服务状态(支持分页、搜索、筛选) Query 参数: - page: 页码,默认为 1 - page_size: 每页数量,默认为 20 + page: 页码,默认为 1 + page_size: 每页数量,默认为 20 search: 搜索关键词 message_type: 筛选消息类型 (group/private/all) platform: 筛选平台 @@ -560,10 +610,10 @@ class SessionManagementRoute(Route): 请求体: { - "umos": ["平台:消息类型:会话ID", ...], // 可选,如果不传则根据 scope 筛选 - "scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围 + "umos": ["平台:消息类型:会话ID", ...], // 可选,如果不传则根据 scope 筛选 + "scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围 "group_id": "分组ID", // 当 scope 为 custom_group 时必填 - "llm_enabled": true/false/null, // 可选,null表示不修改 + "llm_enabled": true/false/null, // 可选,null表示不修改 "tts_enabled": true/false/null, // 可选 "session_enabled": true/false/null // 可选 } @@ -581,7 +631,7 @@ class SessionManagementRoute(Route): if llm_enabled is None and tts_enabled is None and session_enabled is None: return Response().error("至少需要指定一个要修改的状态").__dict__ - # 如果指定了 scope,获取符合条件的所有 umo + # 如果指定了 scope,获取符合条件的所有 umo if scope and not umos: # 如果是自定义分组 if scope == "custom_group": @@ -713,7 +763,7 @@ class SessionManagementRoute(Route): provider_type_enum = provider_type_map[provider_type] - # 如果指定了 scope,获取符合条件的所有 umo + # 如果指定了 scope,获取符合条件的所有 umo group_id = data.get("group_id", "") if scope and not umos: # 如果是自定义分组 @@ -797,7 +847,7 @@ class SessionManagementRoute(Route): """获取所有分组列表""" try: groups = self._get_groups() - # 转换为列表格式,方便前端使用 + # 转换为列表格式,方便前端使用 groups_list = [] for group_id, group_data in groups.items(): groups_list.append( @@ -857,7 +907,7 @@ class SessionManagementRoute(Route): return Response().error(f"创建分组失败: {e!s}").__dict__ async def update_group(self): - """更新分组(改名、增删成员)""" + """更新分组(改名、增删成员)""" try: data = await request.json group_id = data.get("id") diff --git a/dashboard/src/components/extension/PinnedPluginItem.vue b/dashboard/src/components/extension/PinnedPluginItem.vue index 241059e98..f836b1134 100644 --- a/dashboard/src/components/extension/PinnedPluginItem.vue +++ b/dashboard/src/components/extension/PinnedPluginItem.vue @@ -39,6 +39,16 @@ const emit = defineEmits([ const handlePinnedImgError = (e) => { e.target.src = defaultPluginIcon; }; + +const authorDisplay = computed(() => { + const p = props.plugin || {}; + if (typeof p.author === 'string' && p.author.trim()) return p.author; + if (Array.isArray(p.authors) && p.authors.length) return p.authors.join(', '); + if (typeof p.author_name === 'string' && p.author_name.trim()) return p.author_name; + if (typeof p.owner === 'string' && p.owner.trim()) return p.owner; + if (p.author && typeof p.author === 'object' && p.author.name) return p.author.name; + return ''; +}); - - + +
+ + + +
+
{{ plugin.display_name || plugin.name }}
+
{{ authorDisplay || (plugin.author || '') }}
+
+
+
+ + + - + - + - + - + - + @@ -191,13 +153,11 @@ const handlePinnedImgError = (e) => { size="small" class="pinned-pin-btn" :color="isPinned ? 'primary' : 'secondary'" + @click.stop="$emit('toggle-pin', plugin)" :title="isPinned ? tm('buttons.unpin') : tm('buttons.pin')" style="position:absolute; top:6px; right:6px; min-width:22px; width:22px; height:22px;" - @click.stop="$emit('toggle-pin', plugin)" > - - {{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }} - + {{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }} diff --git a/dashboard/src/components/shared/ExtensionCard.vue b/dashboard/src/components/shared/ExtensionCard.vue index 23546ab56..3c5ca382e 100644 --- a/dashboard/src/components/shared/ExtensionCard.vue +++ b/dashboard/src/components/shared/ExtensionCard.vue @@ -68,6 +68,17 @@ const astrbotVersionRequirement = computed(() => { : ""; }); +// 作者显示(兼容多种字段名) +const authorDisplay = computed(() => { + const ext = props.extension || {}; + if (typeof ext.author === 'string' && ext.author.trim()) return ext.author; + if (Array.isArray(ext.authors) && ext.authors.length) return ext.authors.join(', '); + if (typeof ext.author_name === 'string' && ext.author_name.trim()) return ext.author_name; + if (typeof ext.owner === 'string' && ext.owner.trim()) return ext.owner; + if (ext.author && typeof ext.author === 'object' && ext.author.name) return ext.author.name; + return ''; +}); + const logoLoadFailed = ref(false); const logoSrc = computed(() => { @@ -377,6 +388,10 @@ const viewChangelog = () => { {{ tag === "danger" ? tm("tags.danger") : tag }} + + + {{ authorDisplay }} +
- + {{ tm('customRules.title') }} - - - {{ totalItems }} {{ tm('customRules.rulesCount') }} - - - + + {{ totalItems }} {{ tm('customRules.rulesCount') }} + + - + {{ tm('buttons.batchDelete') }} ({{ selectedItems.length }}) - + {{ tm('buttons.addRule') }} - + {{ tm('buttons.refresh') }} - + - + + -