diff --git a/Dockerfile b/Dockerfile index 0b46d5d4..2bc60833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-dev \ libffi-dev \ libssl-dev \ + ca-certificates \ + bash \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN python -m pip install -r requirements.txt --no-cache-dir +RUN python -m pip install uv +RUN uv pip install -r requirements.txt --no-cache-dir --system +RUN uv pip install socksio uv pyffmpeg pilk --no-cache-dir --system -RUN python -m pip install socksio wechatpy cryptography --no-cache-dir +# 释出 ffmpeg +RUN python -c "from pyffmpeg import FFmpeg; ff = FFmpeg();" + +# add /root/.pyffmpeg/bin/ffmpeg to PATH, inorder to use ffmpeg +RUN echo 'export PATH=$PATH:/root/.pyffmpeg/bin' >> ~/.bashrc EXPOSE 6185 EXPOSE 6186 diff --git a/Dockerfile_with_node b/Dockerfile_with_node new file mode 100644 index 00000000..3bd37468 --- /dev/null +++ b/Dockerfile_with_node @@ -0,0 +1,35 @@ +FROM python:3.10-slim + +WORKDIR /AstrBot + +COPY . /AstrBot/ + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + build-essential \ + python3-dev \ + libffi-dev \ + libssl-dev \ + curl \ + unzip \ + ca-certificates \ + bash \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Installation of Node.js +ENV NVM_DIR="/root/.nvm" +RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \ + . "$NVM_DIR/nvm.sh" && \ + nvm install 22 && \ + nvm use 22 +RUN /bin/bash -c ". \"$NVM_DIR/nvm.sh\" && node -v && npm -v" + +RUN python -m pip install uv +RUN uv pip install -r requirements.txt --no-cache-dir --system +RUN uv pip install socksio uv pyffmpeg --no-cache-dir --system + +EXPOSE 6185 +EXPOSE 6186 + +CMD ["python", "main.py"] diff --git a/README.md b/README.md index 7493d557..80bae6c8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用 ## ✨ 主要功能 +> [!NOTE] +> 🪧 我们正基于前沿科研成果,设计并实现适用于角色扮演和情感陪伴的长短期记忆模型及情绪控制模型,旨在提升对话的真实性与情感表达能力。敬请期待 `v3.6.0` 版本! + 1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。 2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。 3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流。 @@ -70,7 +73,15 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用 #### 手动部署 -请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 +推荐使用 `uv`。 + +```bash +git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot +pip install uv +uv run main.py +``` + +或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 #### Replit 部署 diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index c45c8d81..879193f4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,7 +2,7 @@ 如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。 """ -VERSION = "3.5.0" +VERSION = "3.5.1" DB_PATH = "data/data_v3.db" # 默认配置 @@ -519,8 +519,9 @@ CONFIG_METADATA_2 = { "api_base": "https://generativelanguage.googleapis.com/", "timeout": 120, "model_config": { - "model": "gemini-1.5-flash", + "model": "gemini-2.0-flash-exp", }, + "gm_resp_image_modal": False, }, "DeepSeek": { "id": "deepseek_default", @@ -672,6 +673,11 @@ CONFIG_METADATA_2 = { }, }, "items": { + "gm_resp_image_modal": { + "description": "启用图片模态", + "type": "bool", + "hint": "启用后,将支持返回图片内容。需要模型支持,否则会报错。具体支持模型请查看 Google Gemini 官方网站。温馨提示,如果您需要生成图片,请关闭 `启用群员识别` 配置获得更好的效果。", + }, "rag_options": { "description": "RAG 选项", "type": "object", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 7a029311..f0e8e214 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -189,7 +189,16 @@ class AstrBotCoreLifecycle: for task in self.curr_tasks: task.cancel() - # 终止各个管理器以及控制面板 + for plugin in self.plugin_manager.context.get_all_stars(): + logger.info(f"正在终止插件 {plugin.name} ...") + try: + await self.plugin_manager._terminate_plugin(plugin) + except Exception as e: + logger.warning(traceback.format_exc()) + logger.warning( + f"插件 {plugin.name} 未被正常终止 {e!s}, 可能会导致资源泄露等问题。" + ) + await self.provider_manager.terminate() await self.platform_manager.terminate() self.dashboard_shutdown_event.set() diff --git a/astrbot/core/message/message_event_result.py b/astrbot/core/message/message_event_result.py index 83c03b7f..50e50ceb 100644 --- a/astrbot/core/message/message_event_result.py +++ b/astrbot/core/message/message_event_result.py @@ -1,8 +1,14 @@ import enum -from typing import List, Optional +from typing import List, Optional, Union from dataclasses import dataclass, field -from astrbot.core.message.components import BaseMessageComponent, Plain, Image +from astrbot.core.message.components import ( + BaseMessageComponent, + Plain, + Image, + At, + AtAll, +) from typing_extensions import deprecated @@ -31,6 +37,30 @@ class MessageChain: self.chain.append(Plain(message)) return self + def at(self, name: str, qq: Union[str, int]): + """添加一条 At 消息到消息链 `chain` 中。 + + Example: + + CommandResult().at("张三", "12345678910") + # 输出 @张三 + + """ + self.chain.append(At(name=name, qq=qq)) + return self + + def at_all(self): + """添加一条 AtAll 消息到消息链 `chain` 中。 + + Example: + + CommandResult().at_all() + # 输出 @所有人 + + """ + self.chain.append(AtAll()) + return self + @deprecated("请使用 message 方法代替。") def error(self, message: str): """添加一条错误消息到消息链 `chain` 中 diff --git a/astrbot/core/platform/sources/gewechat/client.py b/astrbot/core/platform/sources/gewechat/client.py index abab1790..ccecc0c7 100644 --- a/astrbot/core/platform/sources/gewechat/client.py +++ b/astrbot/core/platform/sources/gewechat/client.py @@ -735,3 +735,20 @@ class SimpleGewechatClient: json_blob = await resp.json() logger.debug(f"获取群信息结果: {json_blob}") return json_blob + + async def get_contacts_list(self): + """ + 获取通讯录列表 + 见 https://apifox.com/apidoc/shared/69ba62ca-cb7d-437e-85e4-6f3d3df271b1/api-196794504 + """ + payload = {"appId": self.appid} + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/contacts/fetchContactsList", + headers=self.headers, + json=payload, + ) as resp: + json_blob = await resp.json() + logger.debug(f"获取通讯录列表结果: {json_blob}") + return json_blob diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index ef904044..71647a5a 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -306,10 +306,42 @@ class ProviderManager: if len(self.provider_insts) == 0: self.curr_provider_inst = None + elif ( + self.curr_provider_inst is None + and len(self.provider_insts) > 0 + and self.provider_enabled + ): + self.curr_provider_inst = self.provider_insts[0] + self.selected_provider_id = self.curr_provider_inst.meta().id + logger.info( + f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。" + ) + if len(self.stt_provider_insts) == 0: self.curr_stt_provider_inst = None + elif ( + self.curr_stt_provider_inst is None + and len(self.stt_provider_insts) > 0 + and self.stt_enabled + ): + self.curr_stt_provider_inst = self.stt_provider_insts[0] + self.selected_stt_provider_id = self.curr_stt_provider_inst.meta().id + logger.info( + f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。" + ) + if len(self.tts_provider_insts) == 0: self.curr_tts_provider_inst = None + elif ( + self.curr_tts_provider_inst is None + and len(self.tts_provider_insts) > 0 + and self.tts_enabled + ): + self.curr_tts_provider_inst = self.tts_provider_insts[0] + self.selected_tts_provider_id = self.curr_tts_provider_inst.meta().id + logger.info( + f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。" + ) def get_insts(self): return self.provider_insts diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index c7887d3e..b6b758e2 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -35,6 +35,8 @@ class ProviderEdgeTTS(TTSProvider): self.pitch = provider_config.get("pitch", None) self.timeout = provider_config.get("timeout", 30) + self.proxy = os.getenv("https_proxy", None) + self.set_model("edge_tts") async def get_audio(self, text: str) -> str: @@ -42,7 +44,7 @@ class ProviderEdgeTTS(TTSProvider): mp3_path = f"data/temp/edge_tts_temp_{uuid.uuid4()}.mp3" wav_path = f"data/temp/edge_tts_{uuid.uuid4()}.wav" - # 构建Edge TTS参数 + # 构建 Edge TTS 参数 kwargs = {"text": text, "voice": self.voice} if self.rate: kwargs["rate"] = self.rate @@ -52,35 +54,47 @@ class ProviderEdgeTTS(TTSProvider): kwargs["pitch"] = self.pitch try: - communicate = edge_tts.Communicate(**kwargs) + communicate = edge_tts.Communicate(proxy=self.proxy, **kwargs) await communicate.save(mp3_path) - # 使用ffmpeg将MP3转换为标准WAV格式 - _ = await asyncio.create_subprocess_exec( - "ffmpeg", - "-y", # 覆盖输出文件 - "-i", - mp3_path, # 输入文件 - "-acodec", - "pcm_s16le", # 16位PCM编码 - "-ar", - "24000", # 采样率24kHz (适合微信语音) - "-ac", - "1", # 单声道 - "-af", - "apad=pad_dur=2", # 确保输出时长准确 - "-fflags", - "+genpts", # 强制生成时间戳 - "-hide_banner", # 隐藏版本信息 - wav_path, # 输出文件 - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - # 等待进程完成并获取输出 - stdout, stderr = await _.communicate() - logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}") - logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}") - logger.info(f"[EdgeTTS] 返回值(0代表成功): {_.returncode}") + try: + from pyffmpeg import FFmpeg + + ff = FFmpeg() + ff.convert(input=mp3_path, output=wav_path) + except Exception as e: + logger.debug( + f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换" + ) + # use ffmpeg command line + + # 使用ffmpeg将MP3转换为标准WAV格式 + p = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", # 覆盖输出文件 + "-i", + mp3_path, # 输入文件 + "-acodec", + "pcm_s16le", # 16位PCM编码 + "-ar", + "24000", # 采样率24kHz (适合微信语音) + "-ac", + "1", # 单声道 + "-af", + "apad=pad_dur=2", # 确保输出时长准确 + "-fflags", + "+genpts", # 强制生成时间戳 + "-hide_banner", # 隐藏版本信息 + wav_path, # 输出文件 + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # 等待进程完成并获取输出 + stdout, stderr = await p.communicate() + logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}") + logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}") + logger.info(f"[EdgeTTS] 返回值(0代表成功): {p.returncode}") + os.remove(mp3_path) if os.path.exists(wav_path) and os.path.getsize(wav_path) > 0: return wav_path diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 90a58423..3233b345 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -2,6 +2,9 @@ import base64 import aiohttp import json import random +import asyncio +import astrbot.core.message.components as Comp +from astrbot.core.message.message_event_result import MessageChain from astrbot.core.utils.io import download_image_by_url from astrbot.core.db import BaseDatabase from astrbot.api.provider import Provider, Personality @@ -39,6 +42,7 @@ class SimpleGoogleGenAIClient: model: str = "gemini-1.5-flash", system_instruction: str = "", tools: dict = None, + modalities: List[str] = ["Text"], ): payload = {} if system_instruction: @@ -46,6 +50,9 @@ class SimpleGoogleGenAIClient: if tools: payload["tools"] = [tools] payload["contents"] = contents + payload["generationConfig"] = { + "responseModalities": modalities, + } logger.debug(f"payload: {payload}") request_url = ( f"{self.api_base}/v1beta/models/{model}:generateContent?key={self.api_key}" @@ -185,22 +192,53 @@ class ProviderGoogleGenAI(Provider): logger.debug(f"google_genai_conversation: {google_genai_conversation}") - result = await self.client.generate_content( - contents=google_genai_conversation, - model=self.get_model(), - system_instruction=system_instruction, - tools=tool, - ) - logger.debug(f"result: {result}") + modalites = ["Text"] + if self.provider_config.get("gm_resp_image_modal", False): + modalites.append("Image") - if "candidates" not in result: - raise Exception("Gemini 返回异常结果: " + str(result)) + loop = True + while loop: + loop = False + result = await self.client.generate_content( + contents=google_genai_conversation, + model=self.get_model(), + system_instruction=system_instruction, + tools=tool, + modalities=modalites, + ) + logger.debug(f"result: {result}") + + # Developer instruction is not enabled for models/gemini-2.0-flash-exp + if "Developer instruction is not enabled" in str(result): + logger.warning( + f"{self.get_model()} 不支持 system prompt, 已自动去除, 将会影响人格设置。" + ) + system_instruction = "" + loop = True + + elif "Function calling is not enabled" in str(result): + logger.warning( + f"{self.get_model()} 不支持函数调用,已自动去除,不影响使用。" + ) + tool = None + loop = True + + elif "Multi-modal output is not supported" in str(result): + logger.warning( + f"{self.get_model()} 不支持多模态输出,降级为文本模态重新请求。" + ) + modalites = ["Text"] + loop = True + + elif "candidates" not in result: + raise Exception("Gemini 返回异常结果: " + str(result)) candidates = result["candidates"][0]["content"]["parts"] llm_response = LLMResponse("assistant") + chain = [] for candidate in candidates: if "text" in candidate: - llm_response.completion_text += candidate["text"] + chain.append(Comp.Plain(candidate["text"])) elif "functionCall" in candidate: llm_response.role = "tool" llm_response.tools_call_args.append(candidate["functionCall"]["args"]) @@ -208,8 +246,12 @@ class ProviderGoogleGenAI(Provider): llm_response.tools_call_ids.append( candidate["functionCall"]["name"] ) # 没有 tool id + elif "inlineData" in candidate: + mime_type: str = candidate["inlineData"]["mimeType"] + if mime_type.startswith("image/"): + chain.append(Comp.Image.fromBase64(candidate["inlineData"]["data"])) - llm_response.completion_text = llm_response.completion_text.strip() + llm_response.result_chain = MessageChain(chain=chain) return llm_response async def text_chat( @@ -253,46 +295,20 @@ class ProviderGoogleGenAI(Provider): llm_response = await self._query(payloads, func_tool) break except Exception as e: - if "maximum context length" in str(e): - retry_cnt = 20 - while retry_cnt > 0: - logger.warning( - f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}" - ) - try: - await self.pop_record(context_query) - llm_response = await self._query(payloads, func_tool) - break - except Exception as e: - if "maximum context length" in str(e): - retry_cnt -= 1 - else: - raise e - if retry_cnt == 0: - llm_response = LLMResponse( - "err", "err: 请尝试 /reset 重置会话" - ) - elif "Function calling is not enabled" in str(e): - logger.info( - f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。" - ) - if "tools" in payloads: - del payloads["tools"] - llm_response = await self._query(payloads, None) - break - elif "429" in str(e) or "API key not valid" in str(e): + if "429" in str(e) or "API key not valid" in str(e): keys.remove(chosen_key) if len(keys) > 0: chosen_key = random.choice(keys) logger.info( f"检测到 Key 异常({str(e)}),正在尝试更换 API Key 重试... 当前 Key: {chosen_key[:12]}..." ) + await asyncio.sleep(1) continue else: logger.error( f"检测到 Key 异常({str(e)}),且已没有可用的 Key。 当前 Key: {chosen_key[:12]}..." ) - raise Exception("API 资源已耗尽,且没有可用的 Key 重试...") + raise Exception("达到了 Gemini 速率限制, 请稍后再试...") else: logger.error( f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}" diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 628b1849..76683571 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -2,6 +2,8 @@ import base64 import json import os import inspect +import random +import asyncio from openai import AsyncOpenAI, AsyncAzureOpenAI from openai.types.chat.chat_completion import ChatCompletion @@ -176,77 +178,81 @@ class ProviderOpenAIOfficial(Provider): payloads = {"messages": context_query, **model_config} llm_response = None - try: - llm_response = await self._query(payloads, func_tool) - except UnprocessableEntityError as e: - logger.warning(f"不可处理的实体错误:{e},尝试删除图片。") - # 尝试删除所有 image - new_contexts = await self._remove_image_from_context(context_query) - payloads["messages"] = new_contexts - context_query = new_contexts - llm_response = await self._query(payloads, func_tool) - except Exception as e: - if "maximum context length" in str(e): - # 重试 10 次 - retry_cnt = 20 - while retry_cnt > 0: - logger.warning( - f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}" - ) - try: - await self.pop_record(context_query) - llm_response = await self._query(payloads, func_tool) - break - except Exception as e: - if "maximum context length" in str(e): - retry_cnt -= 1 - else: - raise e - if retry_cnt == 0: - llm_response = LLMResponse( - "err", "err: 请尝试 /reset 清除会话记录。" - ) - elif "The model is not a VLM" in str(e): # siliconcloud + + max_retries = 10 + available_api_keys = self.api_keys.copy() + chosen_key = random.choice(available_api_keys) + + e = None + retry_cnt = 0 + for retry_cnt in range(max_retries): + try: + self.client.api_key = chosen_key + llm_response = await self._query(payloads, func_tool) + break + except UnprocessableEntityError as e: + logger.warning(f"不可处理的实体错误:{e},尝试删除图片。") # 尝试删除所有 image new_contexts = await self._remove_image_from_context(context_query) payloads["messages"] = new_contexts - llm_response = await self._query(payloads, func_tool) - - # openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配 - elif ( - "does not support Function Calling" in str(e) - or "does not support tools" in str(e) - or "Function call is not supported" in str(e) - or "Function calling is not enabled" in str(e) - or "Tool calling is not supported" in str(e) - or "No endpoints found that support tool use" in str(e) - or "model does not support function calling" in str(e) - or ("tool" in str(e) and "support" in str(e).lower()) - or ("function" in str(e) and "support" in str(e).lower()) - ): - logger.info( - f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。" - ) - if "tools" in payloads: - del payloads["tools"] - llm_response = await self._query(payloads, None) - else: - logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}") - - if "tool" in str(e).lower() and "support" in str(e).lower(): + context_query = new_contexts + except Exception as e: + if "429" in str(e): + logger.warning( + f"API 调用过于频繁,尝试使用其他 Key 重试。当前 Key: {chosen_key[:12]}" + ) + # 最后一次不等待 + if retry_cnt < max_retries - 1: + await asyncio.sleep(1) + available_api_keys.remove(chosen_key) + if len(available_api_keys) > 0: + chosen_key = random.choice(available_api_keys) + continue + else: + raise e + elif "maximum context length" in str(e): + logger.warning( + f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}" + ) + await self.pop_record(context_query) + elif "The model is not a VLM" in str(e): # siliconcloud + # 尝试删除所有 image + new_contexts = await self._remove_image_from_context(context_query) + payloads["messages"] = new_contexts + elif ( + "Function calling is not enabled" in str(e) + or ("tool" in str(e) and "support" in str(e).lower()) + or ("function" in str(e) and "support" in str(e).lower()) + ): + # openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配 + logger.info( + f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。" + ) + if "tools" in payloads: + del payloads["tools"] + func_tool = None + else: logger.error( - "疑似该模型不支持函数调用工具调用。请输入 /tool off_all" + f"发生了错误。Provider 配置如下: {self.provider_config}" ) - if "Connection error." in str(e): - proxy = os.environ.get("http_proxy", None) - if proxy: + if "tool" in str(e).lower() and "support" in str(e).lower(): logger.error( - f"可能为代理原因,请检查代理是否正常。当前代理: {proxy}" + "疑似该模型不支持函数调用工具调用。请输入 /tool off_all" ) - raise e + if "Connection error." in str(e): + proxy = os.environ.get("http_proxy", None) + if proxy: + logger.error( + f"可能为代理原因,请检查代理是否正常。当前代理: {proxy}" + ) + raise e + + if retry_cnt == max_retries - 1: + logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") + raise e return llm_response async def _remove_image_from_context(self, contexts: List): diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index 4842b0e0..84087ecf 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -48,14 +48,6 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return os.path.join("data", "temp", f"{timestamp}") - async def _convert_audio(self, path: str) -> str: - from pyffmpeg import FFmpeg - - filename = await self.get_timestamped_path() + ".mp3" - ff = FFmpeg() - output_path = ff.convert(path, os.path.join('data","temp', filename)) - return output_path - async def _is_silk_file(self, file_path): silk_header = b"SILK" with open(file_path, "rb") as f: diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index ce474f4e..e38a81de 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -31,14 +31,6 @@ class ProviderOpenAIWhisperAPI(STTProvider): self.set_model(provider_config.get("model", None)) - async def _convert_audio(self, path: str) -> str: - from pyffmpeg import FFmpeg - - filename = str(uuid.uuid4()) + ".mp3" - ff = FFmpeg() - output_path = ff.convert(path, os.path.join("data/temp", filename)) - return output_path - async def _is_silk_file(self, file_path): silk_header = b"SILK" with open(file_path, "rb") as f: diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index 1bbc2a1d..cfd1267d 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -33,14 +33,6 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): ) logger.info("Whisper 模型加载完成。") - async def _convert_audio(self, path: str) -> str: - from pyffmpeg import FFmpeg - - filename = str(uuid.uuid4()) + ".mp3" - ff = FFmpeg() - output_path = ff.convert(path, os.path.join("data/temp", filename)) - return output_path - async def _is_silk_file(self, file_path): silk_header = b"SILK" with open(file_path, "rb") as f: diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 67796dec..13d1768e 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -558,7 +558,7 @@ class PluginManager: async def _terminate_plugin(self, star_metadata: StarMetadata): """终止插件,调用插件的 terminate() 和 __del__() 方法""" - logging.info(f"正在终止插件 {star_metadata.name} ...") + logger.info(f"正在终止插件 {star_metadata.name} ...") if not star_metadata.activated: # 说明之前已经被禁用了 diff --git a/astrbot/core/utils/shared_preferences.py b/astrbot/core/utils/shared_preferences.py index b469c9d7..bf88ba8d 100644 --- a/astrbot/core/utils/shared_preferences.py +++ b/astrbot/core/utils/shared_preferences.py @@ -16,6 +16,7 @@ class SharedPreferences: def _save_preferences(self): with open(self.path, "w") as f: json.dump(self._data, f, indent=4) + f.flush() def get(self, key, default=None): return self._data.get(key, default) diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py index 7a673fd1..5d4c05c6 100644 --- a/astrbot/dashboard/routes/static_file.py +++ b/astrbot/dashboard/routes/static_file.py @@ -20,6 +20,8 @@ class StaticFileRoute(Route): "/providers", "/about", "/extension-marketplace", + "/conversation", + "/tool-use" ] for i in index_: self.app.add_url_rule(i, view_func=self.index) diff --git a/changelogs/v3.5.1.md b/changelogs/v3.5.1.md new file mode 100644 index 00000000..80ef10b4 --- /dev/null +++ b/changelogs/v3.5.1.md @@ -0,0 +1,30 @@ +# What's Changed + +> 📢 在升级前,请完整阅读本次更新日志。 + +## ✨ 新增的功能 + +1. 适配 `gemini-2.0-flash-exp-image-generation` 对图片模态的输入 [#1017](https://github.com/Soulter/AstrBot/issues/1017) +2. 在 MessageChain 类中添加 at 和 at_all 方法,用于快速添加 At 消息 @left666 +3. Gewechat Client 增加获取通讯录列表接口 +4. 支持 /llm 指令快捷启停 LLM 功能 [#296](https://github.com/Soulter/AstrBot/issues/296) + +## 🎈 功能性优化 + +1. Edge TTS 支持使用代理 +2. 在 Lifecycle 新增插件资源清理逻辑 @Raven95676 +3. Docker 镜像提供内置 FFmpeg [#979](https://github.com/Soulter/AstrBot/issues/979) +4. 优化无对话情况下设置人格的反馈 @Raven95676 +5. 若禁用提供商,自动切换到另一个可用的提供商 @Raven95676 +6. openai_source 同步支持随机请求均衡,同时优化 LLM 请求逻辑的异常处理 +7. 保存 shared_preferences 时强制刷新文件缓冲区 +8. 优化空 At 回复 @advent259141 + +## 🐛 修复的 Bug + +1. 插件更新时没有正确应用加速地址 +2. newgroup 指令名显示错误 + +## 🧩 新增的插件 + +待补充 diff --git a/packages/astrbot/main.py b/packages/astrbot/main.py index bb246b55..3887fc92 100644 --- a/packages/astrbot/main.py +++ b/packages/astrbot/main.py @@ -2,6 +2,7 @@ import aiohttp import datetime import builtins import traceback +import re import astrbot.api.star as star import astrbot.api.event.filter as filter from astrbot.api.event import AstrMessageEvent, MessageEventResult @@ -87,6 +88,7 @@ class Main(star.Star): /alter_cmd: 设置指令权限(op) [大模型] +/llm: 开启/关闭 LLM /provider: 大模型提供商 /model: 模型列表 /ls: 对话列表 @@ -95,7 +97,7 @@ class Main(star.Star): /switch 序号: 切换对话 /rename 新名字: 重命名当前对话 /del: 删除当前会话对话(op) -/reset: 重置 LLM 会话(op) +/reset: 重置 LLM 会话 /history: 当前对话的对话记录 /persona: 人格情景(op) /tool ls: 函数工具 @@ -105,6 +107,20 @@ class Main(star.Star): event.set_result(MessageEventResult().message(msg).use_t2i(False)) + @filter.command("llm") + async def llm(self, event: AstrMessageEvent): + """开启/关闭 LLM""" + cfg = self.context.get_config() + enable = cfg["provider_settings"]["enable"] + if enable: + cfg["provider_settings"]["enable"] = False + status = "关闭" + else: + cfg["provider_settings"]["enable"] = True + status = "开启" + cfg.save_config() + yield event.plain_result(f"{status} LLM 聊天功能。") + @filter.command_group("tool") def tool(self): pass @@ -520,15 +536,18 @@ UID: {user_id} 此 ID 可用于设置管理员。 MessageEventResult().message("未找到任何 LLM 提供商。请先配置。") ) return + # 定义正则表达式匹配 API 密钥 + api_key_pattern = re.compile(r"key=[^&'\" ]+") if idx_or_name is None: models = [] try: models = await self.context.get_using_provider().get_models() except BaseException as e: + err_msg = api_key_pattern.sub("key=***", str(e)) message.set_result( MessageEventResult() - .message("获取模型列表失败: " + str(e)) + .message("获取模型列表失败: " + err_msg) .use_t2i(False) ) return @@ -754,7 +773,7 @@ UID: {user_id} 此 ID 可用于设置管理员。 ) else: message.set_result( - MessageEventResult().message("请输入群聊 ID。/newgroup 群聊ID。") + MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。") ) @filter.command("switch") @@ -999,6 +1018,13 @@ UID: {user_id} 此 ID 可用于设置管理员。 message.set_result(MessageEventResult().message("取消人格成功。")) else: ps = "".join(l[1:]).strip() + if not cid: + message.set_result( + MessageEventResult().message( + "当前没有对话,请先开始对话或使用 /new 创建一个对话。" + ) + ) + return if persona := next( builtins.filter( lambda persona: persona["name"] == ps, diff --git a/packages/session_controller/main.py b/packages/session_controller/main.py index 6bf3f71f..6c00bc81 100644 --- a/packages/session_controller/main.py +++ b/packages/session_controller/main.py @@ -83,10 +83,10 @@ class Waiter(Star): # 使用 LLM 生成回复 yield event.request_llm( - prompt="用户只是@我或唤醒我,请友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。", + prompt="注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。请你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。注意,你仅需要输出要回复用户的内容,不要输出其他任何东西", func_tool_manager=func_tools_mgr, session_id=curr_cid, - contexts=context, + contexts=[], system_prompt="", conversation=conversation, ) @@ -113,16 +113,7 @@ class Waiter(Star): try: await empty_mention_waiter(event) except TimeoutError as _: - try: - # 超时时也尝试使用 LLM 生成回复 - yield event.request_llm( - prompt="用户在提问后超时未回复,请生成一个温馨友好的提醒,告诉用户如果需要帮助可以再次提问,回答要符合人设。", - func_tool_manager=self.context.get_llm_tool_manager(), - system_prompt="", - ) - except Exception: - # LLM 回复失败,使用原始预设回复 - yield event.plain_result("如果需要帮助,请再次 @ 我哦~") + pass except Exception as e: yield event.plain_result("发生错误,请联系管理员: " + str(e)) finally: