Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a20446aeb9 | |||
| 7b23d76559 | |||
| 8315cf5818 | |||
| ed16265bde | |||
| dff205faf6 | |||
| 9aae8aee0c | |||
| 7c818ced2b | |||
| 218e887558 | |||
| a68860b35a | |||
| 82d4d43383 | |||
| 94618e8feb | |||
| 55de7d4494 | |||
| 7ed639f741 | |||
| 41f2870c29 | |||
| ba198490fa | |||
| 0f9ab082ab | |||
| 97b58965f2 | |||
| f2566c68e3 | |||
| a456bf5449 | |||
| a09998f910 | |||
| be662b913c | |||
| e7ddc8448d |
@@ -1,6 +1,8 @@
|
||||
|
||||
<p align="center">
|
||||
<img width=200 src="https://github.com/user-attachments/assets/3dd6a669-0830-4db4-b821-c8b279ea19a6"/>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/de10f24d-cd64-433a-90b8-16c0a60de24a" width=500>
|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -26,7 +28,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
## ✨ 多消息平台部署
|
||||
|
||||
1. QQ 群、QQ 频道、微信个人号、Telegram。
|
||||
2. 支持文本转图片,Markdown 渲染。
|
||||
2. 内置 Web Chat,即使不部署到消息平台也能聊天。
|
||||
3. 支持文本转图片,Markdown 渲染。
|
||||
|
||||
## ✨ 多 LLM 配置
|
||||
|
||||
@@ -35,6 +38,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
3. 支持 LLMTuner 载入微调模型。
|
||||
4. 支持 Ollama 载入自部署模型。
|
||||
4. 支持网页搜索(Web Search)、自然语言待办提醒。
|
||||
5. 支持 Whisper 语音转文字
|
||||
|
||||
## ✨ 管理面板
|
||||
|
||||
@@ -52,58 +56,14 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
基于 Docker 的沙箱化代码执行器(Beta 测试中)
|
||||
|
||||
> [!NOTE]
|
||||
> 文件输入/输出目前仅支持 Napcat(QQ)
|
||||
|
||||
<div align='center'>
|
||||
<img src="https://github.com/user-attachments/assets/700a545e-7450-4f23-90ff-af6d0d60e501" height=300>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0b0c5344-e98b-4902-92ad-fe9f0bb10c2a" height=300>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/b9b98ff4-8630-46fb-9a39-ecbad9d601ae" height=300>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/9fe6e44c-e4f6-4347-9d5f-281677d47feb" height=300>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
## ✨ Demo
|
||||
> 文件输入/输出目前仅测试了 Napcat(QQ), Lagrange(QQ)
|
||||
|
||||
<div align='center'>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
|
||||
|
||||
_✨ 多模态、网页搜索、长文本转图片(可配置) ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
|
||||
|
||||
_✨ 自然语言待办事项 ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
|
||||
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
|
||||
|
||||
_✨ 插件系统——部分插件展示 ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/caadf2bd-a0ee-43d0-a95e-566d63e3e34d" height=330>
|
||||
<img src="https://github.com/user-attachments/assets/b418f281-e920-49db-9fe1-d6a13ce28a84" height=350>
|
||||
|
||||
_✨ 管理面板 ✨_
|
||||
|
||||
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ## ✨ ATRI [Beta 测试]
|
||||
|
||||
该功能作为插件载入。插件仓库地址:[astrbot_plugin_atri](https://github.com/Soulter/astrbot_plugin_atri)
|
||||
|
||||
1. 基于《ATRI ~ My Dear Moments》主角 ATRI 角色台词作为微调数据集的 `Qwen1.5-7B-Chat Lora` 微调模型。
|
||||
2. 长期记忆
|
||||
3. 表情包理解与回复
|
||||
4. TTS
|
||||
-->
|
||||
## ✨ 云部署
|
||||
|
||||
[](https://repl.it/github/Soulter/AstrBot)
|
||||
@@ -124,3 +84,46 @@ _✨ 管理面板 ✨_
|
||||
- Star 这个项目!
|
||||
- 在[爱发电](https://afdian.com/a/soulter)支持我!
|
||||
- 在[微信](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)支持我~
|
||||
|
||||
|
||||
|
||||
## ✨ Demo
|
||||
|
||||
<div align='center'>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
|
||||
|
||||
_✨ 多模态、网页搜索、长文本转图片(可配置) ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
|
||||
|
||||
_✨ 自然语言待办事项 ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
|
||||
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
|
||||
|
||||
_✨ 插件系统——部分插件展示 ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width=600>
|
||||
|
||||
_✨ 管理面板 ✨_
|
||||
|
||||

|
||||
|
||||
_✨ 内置 Web Chat,在线与机器人交互 ✨_
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ## ✨ ATRI [Beta 测试]
|
||||
|
||||
该功能作为插件载入。插件仓库地址:[astrbot_plugin_atri](https://github.com/Soulter/astrbot_plugin_atri)
|
||||
|
||||
1. 基于《ATRI ~ My Dear Moments》主角 ATRI 角色台词作为微调数据集的 `Qwen1.5-7B-Chat Lora` 微调模型。
|
||||
2. 长期记忆
|
||||
3. 表情包理解与回复
|
||||
4. TTS
|
||||
-->
|
||||
|
||||
_アトリは、高性能ですから!_
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from astrbot.core.provider import Provider, Personality, ProviderMetaData
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.provider import Provider, STTProvider, Personality
|
||||
from astrbot.core.provider.entites import ProviderRequest, ProviderType, ProviderMetaData
|
||||
@@ -20,6 +20,6 @@ if os.environ.get('TESTING', ""):
|
||||
db_helper = SQLiteDatabase(DB_PATH)
|
||||
sp = SharedPreferences() # 简单的偏好设置存储
|
||||
pip_installer = PipInstaller(astrbot_config.get('pip_install_arg', ''))
|
||||
web_chat_queue = asyncio.Queue()
|
||||
web_chat_back_queue = asyncio.Queue()
|
||||
web_chat_queue = asyncio.Queue(maxsize=32)
|
||||
web_chat_back_queue = asyncio.Queue(maxsize=32)
|
||||
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
|
||||
"""
|
||||
|
||||
VERSION = "3.4.4"
|
||||
VERSION = "3.4.6"
|
||||
DB_PATH = "data/data_v3.db"
|
||||
|
||||
# 默认配置
|
||||
@@ -33,6 +33,10 @@ DEFAULT_CONFIG = {
|
||||
"default_personality": "如果用户寻求帮助或者打招呼,请告诉他可以用 /help 查看 AstrBot 帮助。",
|
||||
"prompt_prefix": "",
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
"provider_id": "",
|
||||
},
|
||||
"content_safety": {
|
||||
"internal_keywords": {"enable": True, "extra_keywords": []},
|
||||
"baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""},
|
||||
@@ -315,9 +319,30 @@ CONFIG_METADATA_2 = {
|
||||
"dify_api_key": "",
|
||||
"dify_api_base": "https://api.dify.ai/v1",
|
||||
"dify_workflow_output_key": "",
|
||||
},
|
||||
"whisper(API)": {
|
||||
"id": "whisper",
|
||||
"type": "openai_whisper_api",
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"api_base": "",
|
||||
"model": "whisper-1",
|
||||
},
|
||||
"whisper(本地加载)": {
|
||||
"whisper_hint": "(不用修改我)",
|
||||
"enable": False,
|
||||
"id": "whisper",
|
||||
"type": "openai_whisper_selfhost",
|
||||
"model": "tiny",
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"whisper_hint": {
|
||||
"description": "本地部署 Whisper 模型须知",
|
||||
"type": "string",
|
||||
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
|
||||
"obvious_hint": True
|
||||
},
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"type": "string",
|
||||
@@ -416,7 +441,8 @@ CONFIG_METADATA_2 = {
|
||||
"enable": {
|
||||
"description": "启用大语言模型聊天",
|
||||
"type": "bool",
|
||||
"hint": "是否启用大语言模型聊天。默认启用",
|
||||
"hint": "如需切换大语言模型提供商,请使用 `/provider` 命令。",
|
||||
"obvious_hint": True
|
||||
},
|
||||
"wake_prefix": {
|
||||
"description": "LLM 聊天额外唤醒前缀",
|
||||
@@ -450,6 +476,23 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"description": "语音转文本(STT)",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"enable": {
|
||||
"description": "启用语音转文本(STT)",
|
||||
"type": "bool",
|
||||
"hint": "启用前请在 服务提供商配置 处创建支持 语音转文本任务 的提供商。如 whisper。",
|
||||
"obvious_hint": True
|
||||
},
|
||||
"provider_id": {
|
||||
"description": "提供商 ID,不填则默认第一个STT提供商",
|
||||
"type": "string",
|
||||
"hint": "语音转文本提供商 ID。如果不填写将使用载入的第一个提供商。",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"misc_config_group": {
|
||||
|
||||
@@ -123,7 +123,7 @@ class Record(BaseMessageComponent):
|
||||
proxy: T.Optional[bool] = True
|
||||
timeout: T.Optional[int] = 0
|
||||
# 额外
|
||||
path: T.Optional[str]
|
||||
path: T.Optional[str] # 用这个
|
||||
|
||||
def __init__(self, file: T.Optional[str], **_):
|
||||
for k in _.keys():
|
||||
|
||||
@@ -3,6 +3,7 @@ from astrbot.core.message.message_event_result import MessageEventResult, EventR
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .respond.stage import RespondStage
|
||||
@@ -12,6 +13,7 @@ STAGES_ORDER = [
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"RateLimitCheckStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage" # 发送消息
|
||||
@@ -21,6 +23,7 @@ __all__ = [
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
"ContentSafetyCheckStage",
|
||||
"PreProcessStage",
|
||||
"ProcessStage",
|
||||
"ResultDecorateStage",
|
||||
"RespondStage",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import traceback
|
||||
import asyncio
|
||||
from typing import Union, AsyncGenerator
|
||||
from ..stage import Stage, register_stage
|
||||
from ..context import PipelineContext
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.components import Plain, Record
|
||||
|
||||
@register_stage
|
||||
class PreProcessStage(Stage):
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
self.ctx = ctx
|
||||
self.config = ctx.astrbot_config
|
||||
self.plugin_manager = ctx.plugin_manager
|
||||
|
||||
self.stt_settings: dict = self.config.get('provider_stt_settings', {})
|
||||
|
||||
|
||||
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
|
||||
'''在处理事件之前的预处理'''
|
||||
|
||||
if self.stt_settings.get('enable', False):
|
||||
# STT 处理
|
||||
# TODO: 独立
|
||||
stt_provider = self.plugin_manager.context.provider_manager.curr_stt_provider_inst
|
||||
if stt_provider:
|
||||
message_chain = event.get_messages()
|
||||
for idx, component in enumerate(message_chain):
|
||||
if isinstance(component, Record) and component.url:
|
||||
|
||||
path = component.url
|
||||
|
||||
path.removeprefix("file:///")
|
||||
|
||||
retry = 5
|
||||
|
||||
for i in range(retry):
|
||||
try:
|
||||
result = await stt_provider.get_text(audio_url=path)
|
||||
if result:
|
||||
logger.info("语音转文本结果: " + result)
|
||||
message_chain[idx] = Plain(result)
|
||||
event.message_str += result
|
||||
event.message_obj.message_str += result
|
||||
break
|
||||
except FileNotFoundError as e:
|
||||
# napcat workaround
|
||||
logger.warning(e)
|
||||
logger.warning(f"语音文件不存在: {path}, 重试中: {i + 1}/{retry}")
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"语音转文本失败: {e}")
|
||||
break
|
||||
@@ -41,4 +41,8 @@ class PipelineScheduler():
|
||||
async def execute(self, event: AstrMessageEvent):
|
||||
'''执行 pipeline'''
|
||||
await self._process_stages(event)
|
||||
|
||||
if not event._has_send_oper and event.get_platform_name() == "webchat":
|
||||
await event.send(None)
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
@@ -1,7 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
@dataclass
|
||||
class PlatformMetadata():
|
||||
name: str # 平台的名称
|
||||
description: str # 平台的描述
|
||||
name: str
|
||||
'''平台的名称'''
|
||||
description: str
|
||||
'''平台的描述'''
|
||||
|
||||
default_config_tmpl: dict = None # 平台的默认配置模板
|
||||
default_config_tmpl: dict = None
|
||||
'''平台的默认配置模板'''
|
||||
adapter_display_name: str = None
|
||||
'''显示在 WebUI 配置页中的平台名称,如空则是 name'''
|
||||
@@ -7,7 +7,12 @@ platform_registry: List[PlatformMetadata] = []
|
||||
platform_cls_map: Dict[str, Type] = {}
|
||||
'''维护了平台适配器名称和适配器类的映射'''
|
||||
|
||||
def register_platform_adapter(adapter_name: str, desc: str, default_config_tmpl: dict = None):
|
||||
def register_platform_adapter(
|
||||
adapter_name: str,
|
||||
desc: str,
|
||||
default_config_tmpl: dict = None,
|
||||
adapter_display_name: str = None
|
||||
):
|
||||
'''用于注册平台适配器的带参装饰器。
|
||||
|
||||
default_config_tmpl 指定了平台适配器的默认配置模板。用户填写好后将会作为 platform_config 传入你的 Platform 类的实现类。
|
||||
@@ -26,7 +31,8 @@ def register_platform_adapter(adapter_name: str, desc: str, default_config_tmpl:
|
||||
pm = PlatformMetadata(
|
||||
name=adapter_name,
|
||||
description=desc,
|
||||
default_config_tmpl=default_config_tmpl
|
||||
default_config_tmpl=default_config_tmpl,
|
||||
adapter_display_name=adapter_display_name
|
||||
)
|
||||
platform_registry.append(pm)
|
||||
platform_cls_map[adapter_name] = cls
|
||||
|
||||
@@ -13,6 +13,7 @@ from .aiocqhttp_message_event import AiocqhttpMessageEvent
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from ...register import register_platform_adapter
|
||||
from aiocqhttp.exceptions import ActionFailed
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
@register_platform_adapter("aiocqhttp", "适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。")
|
||||
class AiocqhttpAdapter(Platform):
|
||||
@@ -81,22 +82,36 @@ class AiocqhttpAdapter(Platform):
|
||||
if t == 'text':
|
||||
message_str += m['data']['text'].strip()
|
||||
elif t == 'file':
|
||||
try:
|
||||
# Napcat, LLBot
|
||||
ret = await self.bot.call_action(action="get_file", file_id=event.message[0]['data']['file_id'])
|
||||
if not ret.get('file', None):
|
||||
raise ValueError(f"无法解析文件响应: {ret}")
|
||||
if not os.path.exists(ret['file']):
|
||||
raise FileNotFoundError(f"文件不存在: {ret['file']}。如果您使用 Docker 部署了 AstrBot 或者消息协议端(Napcat等),暂时无法获取用户上传的文件。")
|
||||
if m['data']['url'] and m['data']['url'].startswith("http"):
|
||||
# Lagrange
|
||||
logger.info("guessing lagrange")
|
||||
|
||||
file_name = m['data'].get('file_name', "file")
|
||||
path = os.path.join("data/temp", file_name)
|
||||
await download_file(m['data']['url'], path)
|
||||
|
||||
m['data'] = {
|
||||
"file": ret['file'],
|
||||
"name": ret['file_name']
|
||||
"file": path,
|
||||
"name": file_name
|
||||
}
|
||||
except ActionFailed as e:
|
||||
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
|
||||
except BaseException as e:
|
||||
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
|
||||
|
||||
else:
|
||||
try:
|
||||
# Napcat, LLBot
|
||||
ret = await self.bot.call_action(action="get_file", file_id=event.message[0]['data']['file_id'])
|
||||
if not ret.get('file', None):
|
||||
raise ValueError(f"无法解析文件响应: {ret}")
|
||||
if not os.path.exists(ret['file']):
|
||||
raise FileNotFoundError(f"文件不存在: {ret['file']}。如果您使用 Docker 部署了 AstrBot 或者消息协议端(Napcat等),暂时无法获取用户上传的文件。")
|
||||
|
||||
m['data'] = {
|
||||
"file": ret['file'],
|
||||
"name": ret['file_name']
|
||||
}
|
||||
except ActionFailed as e:
|
||||
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
|
||||
except BaseException as e:
|
||||
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
|
||||
|
||||
a = ComponentTypes[t](**m['data']) # noqa: F405
|
||||
abm.message.append(a)
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from typing import Awaitable, Any
|
||||
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import Plain, Image # noqa: F403
|
||||
from astrbot.api.message_components import Plain, Image, Record # noqa: F403
|
||||
from astrbot.api import logger
|
||||
from astrbot.core import web_chat_queue, web_chat_back_queue
|
||||
from .webchat_event import WebChatMessageEvent
|
||||
@@ -70,6 +70,14 @@ class WebChatAdapter(Platform):
|
||||
abm.message.append(Image.fromFileSystem(os.path.join(self.imgs_dir, img)))
|
||||
else:
|
||||
abm.message.append(Image.fromFileSystem(os.path.join(self.imgs_dir, payload['image_url'])))
|
||||
if payload['audio_url']:
|
||||
if isinstance(payload['audio_url'], list):
|
||||
for audio in payload['audio_url']:
|
||||
path = os.path.join(self.imgs_dir, audio)
|
||||
abm.message.append(Record(file=path, path=path))
|
||||
else:
|
||||
path = os.path.join(self.imgs_dir, payload['audio_url'])
|
||||
abm.message.append(Record(file=path, path=path))
|
||||
|
||||
logger.debug(f"WebChatAdapter: {abm.message}")
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
if not message:
|
||||
web_chat_back_queue.put_nowait(None)
|
||||
return
|
||||
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
await web_chat_back_queue.put(comp.text)
|
||||
web_chat_back_queue.put_nowait(comp.text)
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = str(uuid.uuid4()) + ".jpg"
|
||||
@@ -26,6 +30,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
f.write(f2.read())
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
await download_image_by_url(comp.file, path=path)
|
||||
await web_chat_back_queue.put(f"[IMAGE]{filename}")
|
||||
await web_chat_back_queue.put(None)
|
||||
web_chat_back_queue.put_nowait(f"[IMAGE]{filename}")
|
||||
web_chat_back_queue.put_nowait(None)
|
||||
await super().send(message)
|
||||
@@ -1,4 +1,4 @@
|
||||
from .provider import Provider, Personality
|
||||
from .provider import Provider, Personality, STTProvider
|
||||
|
||||
from .entites import ProviderMetaData
|
||||
|
||||
@@ -6,4 +6,5 @@ __all__ = [
|
||||
"Provider",
|
||||
"Personality",
|
||||
"ProviderMetaData",
|
||||
"STTProvider"
|
||||
]
|
||||
@@ -1,13 +1,27 @@
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Type
|
||||
from .func_tool_manager import FuncCall
|
||||
|
||||
|
||||
class ProviderType(enum.Enum):
|
||||
CHAT_COMPLETION = "chat_completion"
|
||||
SPEECH_TO_TEXT = "speech_to_text"
|
||||
TEXT_TO_SPEECH = "text_to_speech"
|
||||
|
||||
@dataclass
|
||||
class ProviderMetaData():
|
||||
type: str # 提供商适配器名称,如 openai, ollama
|
||||
desc: str = "" # 提供商适配器描述.
|
||||
type: str
|
||||
'''提供商适配器名称,如 openai, ollama'''
|
||||
desc: str = ""
|
||||
'''提供商适配器描述.'''
|
||||
provider_type: ProviderType = ProviderType.CHAT_COMPLETION
|
||||
cls_type: Type = None
|
||||
|
||||
default_config_tmpl: dict = None
|
||||
'''平台的默认配置模板'''
|
||||
provider_display_name: str = None
|
||||
'''显示在 WebUI 配置页中的提供商名称,如空则是 type'''
|
||||
|
||||
@dataclass
|
||||
class ProviderRequest():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import traceback
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from .provider import Provider
|
||||
from .provider import Provider, STTProvider
|
||||
from .entites import ProviderType
|
||||
from typing import List
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from collections import defaultdict
|
||||
@@ -11,10 +12,17 @@ class ProviderManager():
|
||||
def __init__(self, config: AstrBotConfig, db_helper: BaseDatabase):
|
||||
self.providers_config: List = config['provider']
|
||||
self.provider_settings: dict = config['provider_settings']
|
||||
self.provider_stt_settings: dict = config.get('provider_stt_settings', {})
|
||||
|
||||
self.provider_insts: List[Provider] = []
|
||||
'''加载的 Provider 的实例'''
|
||||
self.stt_provider_insts: List[STTProvider] = []
|
||||
'''加载的 Speech To Text Provider 的实例'''
|
||||
self.llm_tools = llm_tools
|
||||
self.curr_provider_inst: Provider = None
|
||||
'''当前使用的 Provider 实例'''
|
||||
self.curr_stt_provider_inst: STTProvider = None
|
||||
'''当前使用的 Speech To Text Provider 实例'''
|
||||
self.loaded_ids = defaultdict(bool)
|
||||
self.db_helper = db_helper
|
||||
|
||||
@@ -31,19 +39,29 @@ class ProviderManager():
|
||||
raise ValueError(f"Provider ID 重复:{provider_cfg['id']}。")
|
||||
self.loaded_ids[provider_cfg['id']] = True
|
||||
|
||||
match provider_cfg['type']:
|
||||
case "openai_chat_completion":
|
||||
from .sources.openai_source import ProviderOpenAIOfficial # noqa: F401
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu # noqa: F401
|
||||
case "llm_tuner":
|
||||
logger.info("加载 LLM Tuner 工具 ...")
|
||||
from .sources.llmtuner_source import LLMTunerModelLoader # noqa: F401
|
||||
case "dify":
|
||||
from .sources.dify_source import ProviderDify # noqa: F401
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import ProviderGoogleGenAI # noqa: F401
|
||||
|
||||
try:
|
||||
match provider_cfg['type']:
|
||||
case "openai_chat_completion":
|
||||
from .sources.openai_source import ProviderOpenAIOfficial # noqa: F401
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu # noqa: F401
|
||||
case "llm_tuner":
|
||||
logger.info("加载 LLM Tuner 工具 ...")
|
||||
from .sources.llmtuner_source import LLMTunerModelLoader # noqa: F401
|
||||
case "dify":
|
||||
from .sources.dify_source import ProviderDify # noqa: F401
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import ProviderGoogleGenAI # noqa: F401
|
||||
case "openai_whisper_api":
|
||||
from .sources.whisper_api_source import ProviderOpenAIWhisperAPI # noqa: F401
|
||||
case "openai_whisper_selfhost":
|
||||
from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(f"加载 {provider_cfg['type']}({provider_cfg['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.critical(f"加载 {provider_cfg['type']}({provider_cfg['id']}) 提供商适配器失败:{e}。未知原因")
|
||||
continue
|
||||
|
||||
async def initialize(self):
|
||||
for provider_config in self.providers_config:
|
||||
@@ -53,23 +71,54 @@ class ProviderManager():
|
||||
logger.error(f"未找到适用于 {provider_config['type']}({provider_config['id']}) 的提供商适配器,请检查是否已经安装或者名称填写错误。已跳过。")
|
||||
continue
|
||||
selected_provider_id = sp.get("curr_provider")
|
||||
cls_type = provider_cls_map[provider_config['type']]
|
||||
selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
|
||||
provider_enabled = self.provider_settings.get("enable", False)
|
||||
stt_enabled = self.provider_stt_settings.get("enable", False)
|
||||
|
||||
provider_metadata = provider_cls_map[provider_config['type']]
|
||||
logger.info(f"尝试实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器 ...")
|
||||
try:
|
||||
inst = cls_type(provider_config, self.provider_settings, self.db_helper, self.provider_settings.get('persistant_history', True))
|
||||
self.provider_insts.append(inst)
|
||||
if selected_provider_id == provider_config['id']:
|
||||
self.curr_provider_inst = inst
|
||||
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。")
|
||||
# 按任务实例化提供商
|
||||
|
||||
if provider_metadata.provider_type == ProviderType.SPEECH_TO_TEXT:
|
||||
# STT 任务
|
||||
inst = provider_metadata.cls_type(provider_config, self.provider_settings)
|
||||
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
|
||||
self.stt_provider_insts.append(inst)
|
||||
if selected_stt_provider_id == provider_config['id'] and stt_enabled:
|
||||
self.curr_stt_provider_inst = inst
|
||||
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。")
|
||||
|
||||
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
|
||||
# 文本生成任务
|
||||
inst = provider_metadata.cls_type(provider_config, self.provider_settings, self.db_helper, self.provider_settings.get('persistant_history', True))
|
||||
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
|
||||
self.provider_insts.append(inst)
|
||||
if selected_provider_id == provider_config['id'] and provider_enabled:
|
||||
self.curr_provider_inst = inst
|
||||
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。")
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logger.error(f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}")
|
||||
|
||||
if len(self.provider_insts) > 0 and not self.curr_provider_inst:
|
||||
if len(self.provider_insts) > 0 and not self.curr_provider_inst and provider_enabled:
|
||||
self.curr_provider_inst = self.provider_insts[0]
|
||||
|
||||
if len(self.stt_provider_insts) > 0 and not self.curr_stt_provider_inst and stt_enabled:
|
||||
self.curr_stt_provider_inst = self.stt_provider_insts[0]
|
||||
|
||||
if not self.curr_provider_inst:
|
||||
logger.warning("未启用任何提供商适配器。")
|
||||
logger.warning("未启用任何用于 文本生成 的提供商适配器。")
|
||||
if self.provider_stt_settings.get("enable"):
|
||||
if not self.curr_stt_provider_inst:
|
||||
logger.warning("未启用任何用于 语音转文本 的提供商适配器。")
|
||||
|
||||
def get_insts(self):
|
||||
return self.provider_insts
|
||||
|
||||
@@ -125,6 +125,33 @@ class Provider(abc.ABC):
|
||||
'''重置某一个 session_id 的上下文'''
|
||||
raise NotImplementedError()
|
||||
|
||||
def meta(self) -> ProviderMeta:
|
||||
'''获取 Provider 的元数据'''
|
||||
return ProviderMeta(
|
||||
id=self.provider_config['id'],
|
||||
model=self.get_model(),
|
||||
type=self.provider_config['type']
|
||||
)
|
||||
|
||||
|
||||
class STTProvider():
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
self.provider_config = provider_config
|
||||
self.provider_settings = provider_settings
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
'''获取音频的文本'''
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_model(self, model_name: str):
|
||||
'''设置当前使用的模型名称'''
|
||||
self.model_name = model_name
|
||||
|
||||
def get_model(self) -> str:
|
||||
'''获取当前使用的模型'''
|
||||
return self.provider_config.get("model", "")
|
||||
|
||||
def meta(self) -> ProviderMeta:
|
||||
'''获取 Provider 的元数据'''
|
||||
return ProviderMeta(
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
from typing import List, Dict, Type
|
||||
from .entites import ProviderMetaData
|
||||
from .entites import ProviderMetaData, ProviderType
|
||||
from astrbot.core import logger
|
||||
from .func_tool_manager import FuncCall
|
||||
|
||||
provider_registry: List[ProviderMetaData] = []
|
||||
'''维护了通过装饰器注册的 Provider'''
|
||||
provider_cls_map: Dict[str, Type] = {}
|
||||
'''维护了 Provider 类型名称和 Provider 类的映射'''
|
||||
provider_cls_map: Dict[str, ProviderMetaData] = {}
|
||||
'''维护了 Provider 类型名称和 ProviderMetadata 的映射'''
|
||||
|
||||
llm_tools = FuncCall()
|
||||
|
||||
def register_provider_adapter(provider_type_name: str, desc: str):
|
||||
def register_provider_adapter(
|
||||
provider_type_name: str,
|
||||
desc: str,
|
||||
provider_type: ProviderType = ProviderType.CHAT_COMPLETION,
|
||||
default_config_tmpl: dict = None,
|
||||
provider_display_name: str = None
|
||||
):
|
||||
'''用于注册平台适配器的带参装饰器'''
|
||||
def decorator(cls):
|
||||
if provider_type_name in provider_cls_map:
|
||||
raise ValueError(f"检测到大模型提供商适配器 {provider_type_name} 已经注册,可能发生了大模型提供商适配器类型命名冲突。")
|
||||
|
||||
# 添加必备选项
|
||||
if default_config_tmpl:
|
||||
if 'type' not in default_config_tmpl:
|
||||
default_config_tmpl['type'] = provider_type_name
|
||||
if 'enable' not in default_config_tmpl:
|
||||
default_config_tmpl['enable'] = False
|
||||
|
||||
pm = ProviderMetaData(
|
||||
type=provider_type_name,
|
||||
desc=desc,
|
||||
provider_type=provider_type,
|
||||
cls_type=cls,
|
||||
default_config_tmpl=default_config_tmpl,
|
||||
provider_display_name=provider_display_name
|
||||
)
|
||||
provider_registry.append(pm)
|
||||
provider_cls_map[provider_type_name] = cls
|
||||
logger.debug(f"Provider {provider_type_name} 已注册")
|
||||
provider_cls_map[provider_type_name] = pm
|
||||
logger.debug(f"服务提供商 Provider {provider_type_name} 已注册")
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import uuid
|
||||
import os
|
||||
import io
|
||||
from openai import AsyncOpenAI, NOT_GIVEN
|
||||
from ..provider import STTProvider
|
||||
from ..entites import ProviderType
|
||||
from astrbot.core.utils.io import download_file
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core import logger
|
||||
|
||||
@register_provider_adapter("openai_whisper_api", "OpenAI Whisper API", provider_type=ProviderType.SPEECH_TO_TEXT)
|
||||
class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.chosen_api_key = provider_config.get("api_key", "")
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=self.chosen_api_key,
|
||||
base_url=provider_config.get("api_base", None),
|
||||
timeout=provider_config.get("timeout", NOT_GIVEN),
|
||||
)
|
||||
|
||||
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 _pcm_to_wav(self, input_io: io.BytesIO, output_path: str) -> str:
|
||||
import wave
|
||||
|
||||
with wave.open(output_path, 'wb') as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24000)
|
||||
wav.writeframes(input_io.read())
|
||||
|
||||
return output_path
|
||||
|
||||
async def _convert_silk(self, path: str) -> str:
|
||||
import pysilk
|
||||
filename = str(uuid.uuid4()) + '.wav'
|
||||
output_path = os.path.join('data/temp', filename)
|
||||
with open(path, "rb") as f:
|
||||
input_data = f.read()
|
||||
if input_data.startswith(b'\x02'):
|
||||
# tencent 我爱你
|
||||
input_data = input_data[1:]
|
||||
input_io = io.BytesIO(input_data)
|
||||
output_io = io.BytesIO()
|
||||
pysilk.decode(input_io, output_io, 24000)
|
||||
output_io.seek(0)
|
||||
await self._pcm_to_wav(output_io, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
async def _is_silk_file(self, file_path):
|
||||
silk_header = b"SILK"
|
||||
with open(file_path, "rb") as f:
|
||||
file_header = f.read(8)
|
||||
|
||||
if silk_header in file_header:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
'''only supports mp3, mp4, mpeg, m4a, wav, webm'''
|
||||
is_tencent = False
|
||||
|
||||
if audio_url.startswith("http"):
|
||||
if "multimedia.nt.qq.com.cn" in audio_url:
|
||||
is_tencent = True
|
||||
|
||||
name = str(uuid.uuid4())
|
||||
path = os.path.join("data/temp", name)
|
||||
await download_file(audio_url, path)
|
||||
audio_url = path
|
||||
|
||||
if not os.path.exists(audio_url):
|
||||
raise FileNotFoundError(f"文件不存在: {audio_url}")
|
||||
|
||||
if audio_url.endswith(".amr") or audio_url.endswith(".silk") or is_tencent:
|
||||
is_silk = await self._is_silk_file(audio_url)
|
||||
if is_silk:
|
||||
logger.info("Converting silk file to wav ...")
|
||||
audio_url = await self._convert_silk(audio_url)
|
||||
|
||||
|
||||
result = await self.client.audio.transcriptions.create(
|
||||
model=self.model_name,
|
||||
file=open(audio_url, "rb"),
|
||||
)
|
||||
return result.text
|
||||
@@ -0,0 +1,99 @@
|
||||
import uuid
|
||||
import os
|
||||
import io
|
||||
import asyncio
|
||||
import whisper
|
||||
from ..provider import STTProvider
|
||||
from ..entites import ProviderType
|
||||
from astrbot.core.utils.io import download_file
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
@register_provider_adapter("openai_whisper_selfhost", "OpenAI Whisper 模型部署", provider_type=ProviderType.SPEECH_TO_TEXT)
|
||||
class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.set_model(provider_config.get("model", None))
|
||||
self.model = None
|
||||
|
||||
async def initialize(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...")
|
||||
self.model = await loop.run_in_executor(None, whisper.load_model, self.model_name)
|
||||
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 _pcm_to_wav(self, input_io: io.BytesIO, output_path: str) -> str:
|
||||
import wave
|
||||
|
||||
with wave.open(output_path, 'wb') as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24000)
|
||||
wav.writeframes(input_io.read())
|
||||
|
||||
return output_path
|
||||
|
||||
async def _convert_silk(self, path: str) -> str:
|
||||
import pysilk
|
||||
filename = str(uuid.uuid4()) + '.wav'
|
||||
output_path = os.path.join('data/temp', filename)
|
||||
with open(path, "rb") as f:
|
||||
input_data = f.read()
|
||||
if input_data.startswith(b'\x02'):
|
||||
# tencent 我爱你
|
||||
input_data = input_data[1:]
|
||||
input_io = io.BytesIO(input_data)
|
||||
output_io = io.BytesIO()
|
||||
pysilk.decode(input_io, output_io, 24000)
|
||||
output_io.seek(0)
|
||||
await self._pcm_to_wav(output_io, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
async def _is_silk_file(self, file_path):
|
||||
silk_header = b"SILK"
|
||||
with open(file_path, "rb") as f:
|
||||
file_header = f.read(8)
|
||||
|
||||
if silk_header in file_header:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
is_tencent = False
|
||||
|
||||
if audio_url.startswith("http"):
|
||||
if "multimedia.nt.qq.com.cn" in audio_url:
|
||||
is_tencent = True
|
||||
|
||||
name = str(uuid.uuid4())
|
||||
path = os.path.join("data/temp", name)
|
||||
await download_file(audio_url, path)
|
||||
audio_url = path
|
||||
|
||||
if not os.path.exists(audio_url):
|
||||
raise FileNotFoundError(f"文件不存在: {audio_url}")
|
||||
|
||||
if audio_url.endswith(".amr") or audio_url.endswith(".silk") or is_tencent:
|
||||
is_silk = await self._is_silk_file(audio_url)
|
||||
if is_silk:
|
||||
logger.info("Converting silk file to wav ...")
|
||||
audio_url = await self._convert_silk(audio_url)
|
||||
|
||||
result = await loop.run_in_executor(None, self.model.transcribe, audio_url)
|
||||
return result['text']
|
||||
@@ -17,10 +17,6 @@ from .filter.regex import RegexFilter
|
||||
from typing import Awaitable
|
||||
from astrbot.core.rag.knowledge_db_mgr import KnowledgeDBManager
|
||||
|
||||
class StarCommand(TypedDict):
|
||||
full_command_name: str
|
||||
command_name: str
|
||||
|
||||
class Context:
|
||||
'''
|
||||
暴露给插件的接口上下文。
|
||||
@@ -168,13 +164,13 @@ class Context:
|
||||
|
||||
def register_provider(self, provider: Provider):
|
||||
'''
|
||||
注册一个 LLM Provider。
|
||||
注册一个 LLM Provider(Chat_Completion 类型)。
|
||||
'''
|
||||
self.provider_manager.provider_insts.append(provider)
|
||||
|
||||
def get_provider_by_id(self, provider_id: str) -> Provider:
|
||||
'''
|
||||
通过 ID 获取 LLM Provider。
|
||||
通过 ID 获取 LLM Provider(Chat_Completion 类型)。
|
||||
'''
|
||||
for provider in self.provider_manager.provider_insts:
|
||||
if provider.meta().id == provider_id:
|
||||
@@ -183,13 +179,13 @@ class Context:
|
||||
|
||||
def get_all_providers(self) -> List[Provider]:
|
||||
'''
|
||||
获取所有 LLM Provider。
|
||||
获取所有 LLM Provider(Chat_Completion 类型)。
|
||||
'''
|
||||
return self.provider_manager.provider_insts
|
||||
|
||||
def get_using_provider(self) -> Provider:
|
||||
'''
|
||||
获取当前使用的 LLM Provider。
|
||||
获取当前使用的 LLM Provider(Chat_Completion 类型)。
|
||||
|
||||
通过 /provider 指令切换。
|
||||
'''
|
||||
|
||||
@@ -87,7 +87,7 @@ async def download_image_by_url(url: str, post: bool = False, post_data: dict =
|
||||
with open(path, "wb") as f:
|
||||
f.write(await resp.read())
|
||||
return path
|
||||
except aiohttp.client_exceptions.ClientConnectorSSLError:
|
||||
except aiohttp.client.ClientConnectorSSLError:
|
||||
# 关闭SSL验证
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.set_ciphers('DEFAULT')
|
||||
@@ -116,8 +116,18 @@ async def download_file(url: str, path: str):
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
raise e
|
||||
except aiohttp.client.ClientConnectorSSLError:
|
||||
# 关闭SSL验证
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.set_ciphers('DEFAULT')
|
||||
async with aiohttp.ClientSession(trust_env=False) as session:
|
||||
async with session.get(url, ssl=ssl_context, timeout=20) as resp:
|
||||
with open(path, 'wb') as f:
|
||||
while True:
|
||||
chunk = await resp.content.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
def file_to_base64(file_path: str) -> str:
|
||||
with open(file_path, "rb") as f:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from .server import AstrBotDashboard
|
||||
@@ -13,8 +14,16 @@ class AstrBotDashBoardLifecycle:
|
||||
|
||||
async def start(self):
|
||||
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
|
||||
await core_lifecycle.initialize()
|
||||
core_task = core_lifecycle.start()
|
||||
|
||||
core_task = []
|
||||
try:
|
||||
await core_lifecycle.initialize()
|
||||
core_task = core_lifecycle.start()
|
||||
except Exception as e:
|
||||
logger.critical(f"初始化 AstrBot 失败:{e} !!!!!!!")
|
||||
logger.critical(f"初始化 AstrBot 失败:{e} !!!!!!!")
|
||||
logger.critical(f"初始化 AstrBot 失败:{e} !!!!!!!")
|
||||
|
||||
self.dashboard_server = AstrBotDashboard(core_lifecycle, self.db)
|
||||
task = asyncio.gather(core_task, self.dashboard_server.run())
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ from astrbot.core import web_chat_queue, web_chat_back_queue
|
||||
from quart import request, Response as QuartResponse, g
|
||||
from astrbot.core.db import BaseDatabase
|
||||
import asyncio
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
|
||||
|
||||
class ChatRoute(Route):
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase, core_lifecycle: AstrBotCoreLifecycle) -> None:
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
'/chat/send': ('POST', self.chat),
|
||||
@@ -17,11 +19,24 @@ class ChatRoute(Route):
|
||||
'/chat/get_conversation': ('GET', self.get_conversation),
|
||||
'/chat/delete_conversation': ('GET', self.delete_conversation),
|
||||
'/chat/get_file': ('GET', self.get_file),
|
||||
'/chat/post_image': ('POST', self.post_image)
|
||||
'/chat/post_image': ('POST', self.post_image),
|
||||
'/chat/post_file': ('POST', self.post_file),
|
||||
'/chat/status': ('GET', self.status),
|
||||
}
|
||||
self.db = db
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.register_routes()
|
||||
self.imgs_dir = "data/webchat/imgs"
|
||||
|
||||
self.supported_imgs = ['jpg', 'jpeg', 'png', 'gif', 'webp']
|
||||
|
||||
async def status(self):
|
||||
has_llm_enabled = self.core_lifecycle.provider_manager.curr_provider_inst is not None
|
||||
has_stt_enabled = self.core_lifecycle.provider_manager.curr_stt_provider_inst is not None
|
||||
return Response().ok(data={
|
||||
'llm_enabled': has_llm_enabled,
|
||||
'stt_enabled': has_stt_enabled
|
||||
}).__dict__
|
||||
|
||||
async def get_file(self):
|
||||
filename = request.args.get('filename')
|
||||
@@ -30,7 +45,13 @@ class ChatRoute(Route):
|
||||
|
||||
try:
|
||||
with open(os.path.join(self.imgs_dir, filename), "rb") as f:
|
||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
||||
if filename.endswith(".wav"):
|
||||
return QuartResponse(f.read(), mimetype="audio/wav")
|
||||
elif filename.split('.')[-1] in self.supported_imgs:
|
||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
||||
else:
|
||||
return QuartResponse(f.read())
|
||||
|
||||
except FileNotFoundError:
|
||||
return Response().error("File not found").__dict__
|
||||
|
||||
@@ -47,6 +68,25 @@ class ChatRoute(Route):
|
||||
return Response().ok(data={
|
||||
'filename': filename
|
||||
}).__dict__
|
||||
|
||||
async def post_file(self):
|
||||
post_data = await request.files
|
||||
if 'file' not in post_data:
|
||||
return Response().error("Missing key: file").__dict__
|
||||
|
||||
file = post_data['file']
|
||||
filename = f"{str(uuid.uuid4())}"
|
||||
print(file)
|
||||
# 通过文件格式判断文件类型
|
||||
if file.content_type.startswith('audio'):
|
||||
filename += ".wav"
|
||||
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
await file.save(path)
|
||||
|
||||
return Response().ok(data={
|
||||
'filename': filename
|
||||
}).__dict__
|
||||
|
||||
async def chat(self):
|
||||
username = g.get('username', 'guest')
|
||||
@@ -61,20 +101,26 @@ class ChatRoute(Route):
|
||||
message = post_data['message']
|
||||
conversation_id = post_data['conversation_id']
|
||||
image_url = post_data.get('image_url')
|
||||
if not message and not image_url:
|
||||
return Response().error("Message and image_url are empty").__dict__
|
||||
audio_url = post_data.get('audio_url')
|
||||
if not message and not image_url and not audio_url:
|
||||
return Response().error("Message and image_url and audio_url are empty").__dict__
|
||||
if not conversation_id:
|
||||
return Response().error("conversation_id is empty").__dict__
|
||||
|
||||
await web_chat_queue.put((username, conversation_id, {
|
||||
'message': message,
|
||||
'image_url': image_url # list
|
||||
'image_url': image_url, # list
|
||||
'audio_url': audio_url
|
||||
}))
|
||||
|
||||
async def stream():
|
||||
ret = []
|
||||
while True:
|
||||
result = await web_chat_back_queue.get()
|
||||
try:
|
||||
result = await asyncio.wait_for(web_chat_back_queue.get(), timeout=30) # 设置超时时间为5秒
|
||||
except asyncio.TimeoutError:
|
||||
yield '[Error] 30 秒内没有返回数据,已放弃。\n'
|
||||
return
|
||||
|
||||
if result is None:
|
||||
break
|
||||
@@ -98,6 +144,8 @@ class ChatRoute(Route):
|
||||
}
|
||||
if image_url:
|
||||
new_his['image_url'] = image_url
|
||||
if audio_url:
|
||||
new_his['audio_url'] = audio_url
|
||||
history.append(new_his)
|
||||
for r in ret:
|
||||
history.append({
|
||||
|
||||
@@ -8,6 +8,7 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.star.config import update_config
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.platform.register import platform_registry
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
|
||||
def try_cast(value: str, type_: str):
|
||||
if type_ == "int" and value.isdigit():
|
||||
@@ -123,11 +124,18 @@ class ConfigRoute(Route):
|
||||
async def _get_astrbot_config(self):
|
||||
config = self.config
|
||||
|
||||
# 平台适配器的默认配置模板注入
|
||||
platform_default_tmpl = CONFIG_METADATA_2['platform_group']['metadata']['platform']['config_template']
|
||||
for platform in platform_registry:
|
||||
if platform.default_config_tmpl:
|
||||
platform_default_tmpl[platform.name] = platform.default_config_tmpl
|
||||
|
||||
# 服务提供商的默认配置模板注入
|
||||
provider_default_tmpl = CONFIG_METADATA_2['provider_group']['metadata']['provider']['config_template']
|
||||
for provider in provider_registry:
|
||||
if provider.default_config_tmpl:
|
||||
provider_default_tmpl[provider.type] = provider.default_config_tmpl
|
||||
|
||||
return {
|
||||
"metadata": CONFIG_METADATA_2,
|
||||
"config": config
|
||||
|
||||
@@ -3,7 +3,7 @@ class StaticFileRoute(Route):
|
||||
def __init__(self, context: RouteContext) -> None:
|
||||
super().__init__(context)
|
||||
|
||||
index_ = ['/', '/auth/login', '/config', '/logs', '/extension', '/dashboard/default', '/project-atri', '/console']
|
||||
index_ = ['/', '/auth/login', '/config', '/logs', '/extension', '/dashboard/default', '/project-atri', '/console', '/chat']
|
||||
for i in index_:
|
||||
self.app.add_url_rule(i, view_func=self.index)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class AstrBotDashboard():
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.chat_route = ChatRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
@@ -68,8 +68,14 @@ class AstrBotDashboard():
|
||||
|
||||
def run(self):
|
||||
ip_addr = get_local_ip_addresses()
|
||||
logger.info(f"""🌈 管理面板已启动,可访问
|
||||
logger.info(f"""
|
||||
✨✨✨
|
||||
AstrBot 管理面板已启动,可访问
|
||||
|
||||
1. http://{ip_addr}:6185
|
||||
2. http://localhost:6185
|
||||
登录。默认用户名和密码是 astrbot。""")
|
||||
|
||||
默认用户名和密码是 astrbot。
|
||||
✨✨✨
|
||||
""")
|
||||
return self.app.run_task(host="0.0.0.0", port=6185, shutdown_trigger=self.shutdown_trigger_placeholder)
|
||||
@@ -0,0 +1,9 @@
|
||||
# What's Changed
|
||||
|
||||
- 支持接入 STT(语音转文字)Provider
|
||||
- 内置支持 OpenAI Whisper API/本地运行模型。[看这里](https://astrbot.lwl.lol/use/whisper.html)
|
||||
- WebChat 支持语音输入
|
||||
- WebChat 支持显示当前 Provider 状态
|
||||
- 优化了 WebChat 在没有消息返回时的处理方式
|
||||
- 修复了 reminder 在初始化历史待办时没有正常传入 session_id 的问题
|
||||
- 代码执行器在成功回复后清空文件 buffer。
|
||||
@@ -0,0 +1,9 @@
|
||||
# What's Changed
|
||||
|
||||
- 文件和语音功能适配 Lagrange
|
||||
- 面板文件更新检查和引导提示
|
||||
- WebUI AboutPage 关于页
|
||||
- 支持并完善服务提供商(Provider)默认配置模板接口
|
||||
- 修复 WebUI 配置页官方文档链接 404 的问题
|
||||
- 修复 WebUI WebChat 刷新时 404 的问题
|
||||
- 优化 download_file 的 SSL 连接错误处理
|
||||
@@ -33,8 +33,6 @@
|
||||
"vue3-apexcharts": "1.4.4",
|
||||
"vue3-print-nb": "0.1.4",
|
||||
"vuetify": "3.3.14",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yup": "1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
|
||||
<g>
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="93.7287" y1="106.6446" x2="52.9011" y2="81.6944">
|
||||
<stop offset="0.0969" style="stop-color:#FFB300"/>
|
||||
<stop offset="1" style="stop-color:#FFB300;stop-opacity:0"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_1_);" d="M123.04,107.67c-4.08-4.12-9.38-9.48-14.92-15.06c-0.34,1.29-0.93,2.39-1.79,3.26
|
||||
c-6.43,6.43-25.6-1.99-45.31-19.1c-2.46-2.13-16.74,20.28-14.1,22.87c3.27,3.2,26,17.86,33.78,20.73
|
||||
c22.66,8.35,34.3,0.22,38.24-3.59C121.16,114.61,122.51,111.5,123.04,107.67z"/>
|
||||
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="115.2813" y1="82.3624" x2="14.863" y2="0.8196">
|
||||
<stop offset="0" style="stop-color:#FFB300"/>
|
||||
<stop offset="0.7062" style="stop-color:#FDD835"/>
|
||||
<stop offset="0.8408" style="stop-color:#FDDC36"/>
|
||||
<stop offset="0.9842" style="stop-color:#FFE93A"/>
|
||||
<stop offset="1" style="stop-color:#FFEB3B"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_2_);" d="M25.05,27.7c-1.54-4.81-2.88-11.1-0.4-13.5c7.51-7.3,31.69,4.88,54.25,27.43
|
||||
c22.55,22.55,34.84,46.84,27.43,54.25c-0.07,0.07-0.16,0.13-0.23,0.2c6.13,5.82,12.2,11.6,16.1,15.31
|
||||
c4.87-14.43-6.45-44.11-31.5-69.96c-4.07-4.2-16.12-16.56-26.55-23.56C54.61,11.47,44.19,5.59,32.57,4.2
|
||||
C25,3.29,11.45,5.24,14.25,15.98c0.55,2.12,2.31,7.22,8.15,13.3C23.56,30.49,25.56,29.3,25.05,27.7z"/>
|
||||
<g>
|
||||
<path style="fill:#FDD835;" d="M55.98,42.1l-0.75,20c-0.06,1.53,0.72,2.98,2.04,3.77l16.86,10.11c1.85,1.25,1.46,4.09-0.66,4.79
|
||||
L54.79,85.5c-1.51,0.38-2.69,1.57-3.06,3.08l-4.89,19.93c-0.62,2.15-3.43,2.65-4.76,0.85L31.06,92.91
|
||||
c-0.85-1.26-2.31-1.97-3.83-1.85L7.49,92.61c-2.23,0.07-3.58-2.45-2.28-4.27l12.6-16.19c0.96-1.23,1.16-2.89,0.52-4.31
|
||||
l-7.88-17.57c-0.76-2.1,1.22-4.17,3.35-3.49l18.39,6.95c1.44,0.54,3.05,0.26,4.22-0.74l15.22-13
|
||||
C53.39,38.62,55.96,39.87,55.98,42.1z"/>
|
||||
<g>
|
||||
<path style="fill:#FFFF8D;" d="M46.99,59.33l4.66-12.75c0.28-0.7,0.7-1.93,1.79-1.4c0.86,0.42,0.46,2.43,0.46,2.43l-1.05,11.54
|
||||
c-0.41,4.39-1.6,5.38-3.3,5.49C47.6,64.75,45.65,62.98,46.99,59.33z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path style="fill:#F4B400;" d="M53.89,83.73l14.53-3.13c0.73-0.18,2.01-0.42,1.64-1.58c-0.29-0.91-2.34-0.8-2.34-0.8l-10.97-0.86
|
||||
c-3.21-0.38-5.72,0.14-6.74,1.84C48.65,81.48,49.89,84.32,53.89,83.73z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -213,11 +213,5 @@ commonStore.getStartTime();
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
|
||||
<v-btn class="text-primary mr-4" @click="open('https://github.com/Soulter/AstrBot')" color="lightprimary"
|
||||
variant="flat" rounded="sm">
|
||||
GitHub Star! 🌟
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@ const sidebarMenu = shallowRef(sidebarItems);
|
||||
</template>
|
||||
</v-list>
|
||||
<div class="text-center">
|
||||
<v-chip color="inputBorder" size="small"> v{{ version }} </v-chip>
|
||||
<v-chip color="inputBorder" size="small"> {{ version }} </v-chip>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; bottom: 32px; width: 100%" class="text-center">
|
||||
@@ -28,7 +28,14 @@ const sidebarMenu = shallowRef(sidebarItems);
|
||||
</v-list-item>
|
||||
<small style="display: block;" v-if="buildVer">构建: {{ buildVer }}</small>
|
||||
<small style="display: block;" v-else="buildVer">构建: embedded</small>
|
||||
<small style="display: block; margin-top: 8px;">© 2024 AstrBot</small>
|
||||
<v-tooltip text="使用 /dashbord_update 指令更新管理面板">
|
||||
<template v-slot:activator="{ props }">
|
||||
<small v-bind="props" v-if="buildVer != version" style="display: block; margin-top: 4px;">面板有更新</small>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
|
||||
<small style="display: block; margin-top: 8px;">© 2025 AstrBot</small>
|
||||
</div>
|
||||
|
||||
</v-navigation-drawer>
|
||||
@@ -54,14 +61,14 @@ export default {
|
||||
// 不是版本,不显示 😎
|
||||
return
|
||||
}
|
||||
this.buildVer = res
|
||||
this.buildVer = res.replace(/\s+/g, '')
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
get_version() {
|
||||
axios.get('/api/stat/version')
|
||||
.then((res) => {
|
||||
this.version = res.data.data.version;
|
||||
this.version = "v" + res.data.data.version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
|
||||
@@ -40,6 +40,11 @@ const sidebarItem: menu[] = [
|
||||
icon: 'mdi-console',
|
||||
to: '/console'
|
||||
},
|
||||
{
|
||||
title: '关于',
|
||||
icon: 'mdi-information',
|
||||
to: '/about'
|
||||
},
|
||||
// {
|
||||
// title: 'Project ATRI',
|
||||
// icon: 'mdi-grain',
|
||||
|
||||
@@ -41,6 +41,11 @@ const MainRoutes = {
|
||||
name: 'Chat',
|
||||
path: '/chat',
|
||||
component: () => import('@/views/ChatPage.vue')
|
||||
},
|
||||
{
|
||||
name: 'About',
|
||||
path: '/about',
|
||||
component: () => import('@/views/AboutPage.vue')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<v-card style="height: 100%;">
|
||||
<v-card-text style="padding: 0; height: 100%;">
|
||||
<div
|
||||
style="display: flex; justify-content: center; align-items: center; height: 100%; flex-direction: column;">
|
||||
<div @click="selectedLogo = selectedLogo == 0 ? 1 : 0" style="height: 300px;">
|
||||
<img v-if="selectedLogo == 0" width="300" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo" class="fade-in">
|
||||
<img v-if="selectedLogo == 1" width="300" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo" class="fade-in">
|
||||
</div>
|
||||
|
||||
<h1 class="mt-8">AstrBot</h1>
|
||||
|
||||
<span style="color: #777;" class="mt-4">By <a href="https://soulter.top">Soulter</a> And <a href="https://github.com/Soulter/AstrBot/graphs/contributors">AstrBot Contributors</a></span>
|
||||
|
||||
<v-btn class="text-primary mt-16" @click="open('https://github.com/Soulter/AstrBot')"
|
||||
color="lightprimary" variant="flat" rounded="sm">
|
||||
Star 这个项目! 🌟
|
||||
</v-btn>
|
||||
|
||||
<v-btn class="text-primary mt-4" @click="open('https://github.com/Soulter/AstrBot/issues')"
|
||||
color="lightprimary" variant="flat" rounded="sm">
|
||||
有使用问题或者功能建议?提交 Issue!
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'AboutPage',
|
||||
data() {
|
||||
return {
|
||||
selectedLogo: 0
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
open(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,7 @@ marked.setOptions({
|
||||
:disabled="!currCid">+ 创建对话</v-btn>
|
||||
|
||||
<v-card class="mx-auto" min-width="200">
|
||||
<v-list dense nav rounded="xl" v-if="conversations.length > 0"
|
||||
<v-list dense nav v-if="conversations.length > 0" style="max-height: 500px; overflow-y: auto;"
|
||||
@update:selected="getConversationMessages">
|
||||
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
|
||||
color="primary" rounded="xl">
|
||||
@@ -31,12 +31,24 @@ marked.setOptions({
|
||||
</v-list>
|
||||
</v-card>
|
||||
|
||||
<div>
|
||||
|
||||
<v-chip class="mt-4" color="primary" :append-icon="status?.llm_enabled ? 'mdi-check' : 'mdi-close'">
|
||||
LLM
|
||||
</v-chip>
|
||||
|
||||
<v-chip class="mt-4 ml-2" color="success" :append-icon="status?.stt_enabled ? 'mdi-check' : 'mdi-close'">
|
||||
语音转文本
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-btn variant="tonal" rounded="xl"
|
||||
style="position: fixed; bottom: 48px; margin-bottom: 16px; min-width: 200px;" v-if="currCid"
|
||||
@click="deleteConversation(currCid)" color="error">删除此对话</v-btn>
|
||||
</div>
|
||||
|
||||
<div style="height: 100%; width: 100%;">
|
||||
<div style="height: calc(100% - 130px); overflow-y: auto; padding: 16px; " ref="messageContainer">
|
||||
<div style="height: calc(100% - 120px); overflow-y: auto; padding: 16px; " ref="messageContainer">
|
||||
<div class="fade-in" v-if="messages.length == 0"
|
||||
style="height: 100%; display: flex; justify-content: center; align-items: center; flex-direction: column;">
|
||||
<div>
|
||||
@@ -49,6 +61,12 @@ marked.setOptions({
|
||||
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">/help</span>
|
||||
<span>获取帮助 😊</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #aaa;">
|
||||
<span>按</span>
|
||||
<span
|
||||
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">K</span>
|
||||
<span>开始语音 🎤</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else style="max-height: 100%; padding: 16px; max-width: 700px; margin: 0 auto;">
|
||||
@@ -58,13 +76,21 @@ marked.setOptions({
|
||||
<div
|
||||
style="padding: 12px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15)">
|
||||
<span>{{ msg.message }}</span>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;" v-if="msg.image_url && msg.image_url.length > 0">
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;"
|
||||
v-if="msg.image_url && msg.image_url.length > 0">
|
||||
<div v-for="(img, index) in msg.image_url" :key="index"
|
||||
style="position: relative; display: inline-block;">
|
||||
<img :src="img"
|
||||
style="width: 100px; height: 100px; border-radius: 8px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- audio -->
|
||||
<div>
|
||||
<audio controls v-if="msg.audio_url && msg.audio_url.length > 0">
|
||||
<source :src="msg.audio_url" type="audio/wav">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="display: flex; justify-content: flex-start; gap: 16px;">
|
||||
@@ -79,26 +105,36 @@ marked.setOptions({
|
||||
|
||||
<div
|
||||
style="width: 100%; justify-content: center; align-items: center; display: flex; flex-direction: column; margin-top: 8px;">
|
||||
|
||||
<v-text-field id="input-field" variant="outlined" v-model="prompt" label="聊天吧!"
|
||||
|
||||
<v-text-field id="input-field" variant="outlined" v-model="prompt" :label="inputFieldLabel"
|
||||
placeholder="Start typing..." loading clear-icon="mdi-close-circle" clearable
|
||||
@click:clear="clearMessage" @keyup.enter="sendMessage"
|
||||
style="width: 100%; max-width: 930px;">
|
||||
@click:clear="clearMessage" style="width: 100%; max-width: 850px;">
|
||||
<template v-slot:loader>
|
||||
<v-progress-linear
|
||||
:active="loadingChat"
|
||||
:color="color"
|
||||
height="6"
|
||||
indeterminate
|
||||
></v-progress-linear>
|
||||
<v-progress-linear :active="loadingChat" height="6"
|
||||
indeterminate></v-progress-linear>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon @click="sendMessage" size="35" icon="mdi-arrow-up-circle" />
|
||||
<v-tooltip text="发送">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" @click="sendMessage" size="35"
|
||||
icon="mdi-arrow-up-circle" />
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
|
||||
<v-tooltip text="语音输入">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon :color="isRecording ? 'error' : ''" v-bind="props"
|
||||
@click="isRecording ? stopRecording() : startRecording()" size="35"
|
||||
icon="mdi-record-circle" />
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; gap: 8px; margin-top: -8px;">
|
||||
<div v-for="(img, index) in stagedImagesUrl" :key="index"
|
||||
style="position: relative; display: inline-block;">
|
||||
<img :src="img"
|
||||
@@ -106,6 +142,15 @@ marked.setOptions({
|
||||
<v-icon @click="removeImage(index)" size="20" color="red"
|
||||
style="position: absolute; top: 0; right: 0; cursor: pointer;">mdi-close-circle</v-icon>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 50px; height: 50px;">
|
||||
<div v-if="stagedAudioUrl"
|
||||
style="position: relative; padding: 6px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15); display: inline-block;">
|
||||
新录音
|
||||
<v-icon @click="removeAudio" size="20" color="red"
|
||||
style="position: absolute; top: 0; right: 0; cursor: pointer;">mdi-close-circle</v-icon>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,18 +173,95 @@ export default {
|
||||
conversations: [],
|
||||
currCid: '',
|
||||
stagedImagesUrl: [],
|
||||
loadingChat: false
|
||||
loadingChat: false,
|
||||
|
||||
inputFieldLabel: '聊天吧!',
|
||||
|
||||
isRecording: false,
|
||||
audioChunks: [],
|
||||
stagedAudioUrl: "",
|
||||
mediaRecorder: null,
|
||||
|
||||
status: {},
|
||||
statusText: ''
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.checkStatus();
|
||||
this.getConversations();
|
||||
let inputField = document.getElementById('input-field');
|
||||
inputField.addEventListener('paste', this.handlePaste);
|
||||
|
||||
inputField.addEventListener('keydown', function (e) {
|
||||
if (e.keyCode == 13 && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
}.bind(this));
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.keyCode == 75) {
|
||||
this.isRecording ? this.stopRecording() : this.startRecording();
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
removeAudio() {
|
||||
this.stagedAudioUrl = null;
|
||||
},
|
||||
|
||||
checkStatus() {
|
||||
axios.get('/api/chat/status').then(response => {
|
||||
console.log(response.data);
|
||||
this.status = response.data.data;
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
|
||||
async startRecording() {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
this.audioChunks.push(event.data);
|
||||
};
|
||||
this.mediaRecorder.start();
|
||||
this.isRecording = true;
|
||||
this.inputFieldLabel = "录音中,请说话...";
|
||||
},
|
||||
|
||||
async stopRecording() {
|
||||
this.isRecording = false;
|
||||
this.inputFieldLabel = "聊天吧!";
|
||||
this.mediaRecorder.stop();
|
||||
this.mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
|
||||
this.audioChunks = [];
|
||||
|
||||
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/chat/post_file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
});
|
||||
|
||||
const audio = response.data.data.filename;
|
||||
console.log('Audio uploaded:', audio);
|
||||
|
||||
this.stagedAudioUrl = `/api/chat/get_file?filename=${audio}`;
|
||||
} catch (err) {
|
||||
console.error('Error uploading audio:', err);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async handlePaste(event) {
|
||||
console.log('Pasting image...');
|
||||
const items = event.clipboardData.items;
|
||||
@@ -160,7 +282,6 @@ export default {
|
||||
const img = response.data.data.filename;
|
||||
this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`);
|
||||
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
console.error('Error uploading image:', err);
|
||||
}
|
||||
@@ -198,6 +319,9 @@ export default {
|
||||
message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`;
|
||||
}
|
||||
}
|
||||
if (message[i].audio_url) {
|
||||
message[i].audio_url = `/api/chat/get_file?filename=${message[i].audio_url}`;
|
||||
}
|
||||
}
|
||||
this.messages = message;
|
||||
}).catch(err => {
|
||||
@@ -250,24 +374,26 @@ export default {
|
||||
this.messages.push({
|
||||
type: 'user',
|
||||
message: this.prompt,
|
||||
image_url: this.stagedImagesUrl
|
||||
image_url: this.stagedImagesUrl,
|
||||
audio_url: this.stagedAudioUrl
|
||||
});
|
||||
|
||||
// let bot_resp = {
|
||||
// type: 'bot',
|
||||
// message: ref('')
|
||||
// }
|
||||
|
||||
// this.messages.push(bot_resp);
|
||||
|
||||
this.scrollToBottom();
|
||||
|
||||
// images
|
||||
let image_filenames = [];
|
||||
for (let i = 0; i < this.stagedImagesUrl.length; i++) {
|
||||
let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', '');
|
||||
image_filenames.push(img);
|
||||
}
|
||||
|
||||
// audio
|
||||
let audio_filenames = [];
|
||||
if (this.stagedAudioUrl) {
|
||||
let audio = this.stagedAudioUrl.replace('/api/chat/get_file?filename=', '');
|
||||
audio_filenames.push(audio);
|
||||
}
|
||||
|
||||
this.loadingChat = true;
|
||||
|
||||
|
||||
@@ -277,11 +403,17 @@ export default {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ message: this.prompt, conversation_id: this.currCid, image_url: image_filenames }) // 发送请求体
|
||||
body: JSON.stringify({
|
||||
message: this.prompt,
|
||||
conversation_id: this.currCid,
|
||||
image_url: image_filenames,
|
||||
audio_url: audio_filenames
|
||||
}) // 发送请求体
|
||||
})
|
||||
.then(response => {
|
||||
this.prompt = '';
|
||||
this.stagedImagesUrl = [];
|
||||
this.stagedAudioUrl = "";
|
||||
|
||||
this.loadingChat = false;
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||
|
||||
<div style="margin-left: 16px; padding-bottom: 16px">
|
||||
<small>不了解配置?请见 <a
|
||||
href="https://astrbot.soulter.top/docs/%E5%BC%80%E5%A7%8B%E4%B8%8A%E6%89%8B/%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6">官方文档</a>
|
||||
href="https://astrbot.soulter.top/">官方文档</a>
|
||||
或 <a
|
||||
href="https://qm.qq.com/cgi-bin/qm/qr?k=EYGsuUTfe00_iOu9JTXS7_TEpMkXOvwv&jump_from=webapi&authKey=uUEMKCROfsseS+8IzqPjzV3y1tzy4AkykwTib2jNkOFdzezF9s9XknqnIaf3CDft">加群询问</a>。</small>
|
||||
</div>
|
||||
|
||||
@@ -40,8 +40,11 @@ async def check_dashboard_files():
|
||||
if os.path.exists("data/dist"):
|
||||
if os.path.exists("data/dist/assets/version"):
|
||||
with open("data/dist/assets/version", "r") as f:
|
||||
if f.read() != VERSION:
|
||||
v = f.read().strip()
|
||||
if v != f"v{VERSION}":
|
||||
logger.warning("检测到管理面板有更新。可以使用 /dashboard update 命令更新。")
|
||||
else:
|
||||
logger.info("管理面板文件已是最新。")
|
||||
return
|
||||
|
||||
logger.info("开始下载管理面板文件...")
|
||||
|
||||
@@ -45,7 +45,7 @@ class Main(star.Star):
|
||||
/deop <admin_id>: 取消管理员
|
||||
/wl <sid>: 添加会话白名单
|
||||
/dwl <sid>: 删除会话白名单
|
||||
/dashboard update: 更新管理面板
|
||||
/dashboard_update: 更新管理面板
|
||||
|
||||
[大模型]
|
||||
/provider: 查看、切换大模型提供商
|
||||
|
||||
@@ -113,7 +113,7 @@ class Main(star.Star):
|
||||
async def initialize(self):
|
||||
ok = await self.is_docker_available()
|
||||
if not ok:
|
||||
logger.warning("Docker 不可用,代码解释器将无法使用,astrbot-python-interpreter 将自动禁用。")
|
||||
logger.info("Docker 不可用,代码解释器将无法使用,astrbot-python-interpreter 将自动禁用。")
|
||||
await self.context._star_manager.turn_off_plugin("astrbot-python-interpreter")
|
||||
|
||||
async def file_upload(self, file_path: str):
|
||||
@@ -141,7 +141,7 @@ class Main(star.Star):
|
||||
await docker.version()
|
||||
return True
|
||||
except aiodocker.exceptions.DockerError as e:
|
||||
logger.error(f"检查 Docker 可用性时出现问题: {e}")
|
||||
logger.info(f"检查 Docker 可用性: {e}")
|
||||
return False
|
||||
|
||||
async def get_image_name(self) -> str:
|
||||
@@ -150,7 +150,7 @@ class Main(star.Star):
|
||||
return f"{self.config['sandbox']['docker_mirror']}/{self.config['sandbox']['image']}"
|
||||
return self.config["sandbox"]["image"]
|
||||
|
||||
async def _save_config(self):
|
||||
def _save_config(self):
|
||||
with open(PATH, "w") as f:
|
||||
json.dump(self.config, f)
|
||||
|
||||
@@ -207,7 +207,7 @@ class Main(star.Star):
|
||||
""")
|
||||
else:
|
||||
self.config["sandbox"]["docker_mirror"] = url
|
||||
await self._save_config()
|
||||
self._save_config()
|
||||
yield event.plain_result("设置 Docker 镜像地址成功。")
|
||||
|
||||
@pi.command("repull")
|
||||
@@ -363,10 +363,24 @@ class Main(star.Star):
|
||||
logger.warning(f"未从沙箱输出中捕获到合法的输出。沙箱输出日志: {logs}")
|
||||
break
|
||||
else:
|
||||
# 成功了
|
||||
self.user_file_msg_buffer.pop(event.get_session_id())
|
||||
return
|
||||
|
||||
yield event.plain_result("经过多次尝试后,未从沙箱输出中捕获到合法的输出,请更换问法或者查看日志。")
|
||||
|
||||
|
||||
@pi.command("cleanfile")
|
||||
async def pi_cleanfile(self, event: AstrMessageEvent):
|
||||
'''清理用户上传的文件'''
|
||||
for file in self.user_file_msg_buffer[event.get_session_id()]:
|
||||
try:
|
||||
os.remove(file)
|
||||
except BaseException as e:
|
||||
logger.error(f"删除文件 {file} 失败: {e}")
|
||||
|
||||
self.user_file_msg_buffer.pop(event.get_session_id())
|
||||
yield event.plain_result(f"用户 {event.get_session_id()} 上传的文件已清理。")
|
||||
|
||||
|
||||
async def run_container(self, container: aiodocker.docker.DockerContainer, timeout: int = 20) -> list[str]:
|
||||
'''Run the container and get the output'''
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
aiodocker
|
||||
@@ -34,7 +34,7 @@ class Main(star.Star):
|
||||
self.scheduler.add_job(
|
||||
self._reminder_callback,
|
||||
trigger='date',
|
||||
args=[reminder["text"], reminder],
|
||||
args=[group, reminder],
|
||||
run_date=datetime.datetime.strptime(reminder["datetime"], "%Y-%m-%d %H:%M"),
|
||||
misfire_grace_time=60
|
||||
)
|
||||
@@ -42,7 +42,7 @@ class Main(star.Star):
|
||||
self.scheduler.add_job(
|
||||
self._reminder_callback,
|
||||
trigger='cron',
|
||||
args=[reminder["text"], reminder],
|
||||
args=[group, reminder],
|
||||
misfire_grace_time=60,
|
||||
**self._parse_cron_expr(reminder["cron"])
|
||||
)
|
||||
|
||||
+2
-1
@@ -16,4 +16,5 @@ aiocqhttp
|
||||
pyjwt
|
||||
apscheduler
|
||||
docstring_parser
|
||||
aiodocker
|
||||
aiodocker
|
||||
silk-python
|
||||
Reference in New Issue
Block a user