Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23ee5e81c9 | |||
| 483f55e4b1 | |||
| 1bb1bc2553 | |||
| a4e4e36f94 | |||
| 6849415812 | |||
| 86f6cb038e | |||
| 7480a1d6ce | |||
| 3cd10117dd | |||
| 0caf19d390 | |||
| 9c9ab50d1a | |||
| d4bcb8174e | |||
| aca18fab0f | |||
| 691de01b79 | |||
| 3383f15142 | |||
| 84c1593889 | |||
| 3c80fa1e33 | |||
| 06b16a1deb | |||
| 4c4246fb09 | |||
| 364be1e9f6 | |||
| f959ed71aa | |||
| 125fc3a622 | |||
| 6b9e785db3 | |||
| 25d34e9a43 | |||
| 457d4aa1dc | |||
| ff0c0992ff | |||
| d379e012c4 | |||
| 151fff26fd | |||
| 3d0d561215 | |||
| 22d586ed7b | |||
| 6dc19b29e8 | |||
| 50975a87d4 | |||
| ce721d9f0f | |||
| 20510a33f7 | |||
| 3abd9c8763 | |||
| e9eff7420b | |||
| 64c250c9d8 | |||
| 8047f82bfd | |||
| af6467fb3d | |||
| 3ff1664aec | |||
| 34ea2b44b8 | |||
| 6c8d851109 | |||
| d678299a74 | |||
| 7aed0db2b6 | |||
| 0355524345 | |||
| 0a43e4672e | |||
| 71e0ccdfec | |||
| 1df33ac3c8 | |||
| 7334090ac1 | |||
| 6b0f044198 | |||
| ddf54c9cf8 | |||
| 7c64e184e2 | |||
| a904db033c | |||
| b234856b02 | |||
| 89d51d2afc | |||
| 37cb9678e9 | |||
| 0500ff333a | |||
| 08528510ef | |||
| ddbd03dc1e | |||
| ade87f378a | |||
| 4db14b905f | |||
| b669b31451 | |||
| 1cb2b62f81 | |||
| e5828713cf | |||
| d10cb84068 | |||
| 4222f8516f | |||
| 7f998c7611 | |||
| db46000337 | |||
| 1aac8d8041 | |||
| c59c8e05f7 | |||
| 4942d0a629 | |||
| 873b7715f4 | |||
| 98e7ed6920 | |||
| 046f5e645e | |||
| f5e5a7094c | |||
| 154125fee6 | |||
| 9f8e960ebe | |||
| 4179b0be0a | |||
| 28bafa38db | |||
| b07552565e | |||
| c4427471d2 | |||
| 08f81c6784 | |||
| a471e98aca | |||
| 75a8fcc8a0 | |||
| 46ef76c168 | |||
| 66637446c9 | |||
| 21efeb888a | |||
| a4ee8b5322 | |||
| 36519ac47e | |||
| 3f514fceca | |||
| c2249fdfac | |||
| c610719a44 | |||
| 36a6c2461a | |||
| c29f22c39e | |||
| 30d3062944 | |||
| 69ba75abf4 | |||
| e4d486fec5 | |||
| f242144dcf | |||
| 02dee2d664 | |||
| a3dd2c3069 | |||
| a23425e8aa | |||
| be79ddc9a3 | |||
| 7d71015e8c | |||
| ad54549b51 | |||
| 6cf032a164 | |||
| 6390d796ac | |||
| 98b8411905 | |||
| ddf1029afa | |||
| 1effbc5cc9 | |||
| 414b645e9f | |||
| 398c76f496 | |||
| 1bc456dd95 | |||
| 2e8421884e | |||
| 70d9b193ac | |||
| b49c11004a | |||
| 34843eea90 | |||
| 2d6d7f31e8 | |||
| 7a24cbff1c | |||
| 1e7eb2cf1c | |||
| 361256e016 | |||
| 8838dbd003 | |||
| 13a95e1f2b | |||
| 1aaa451a3e | |||
| cbba81e54d | |||
| 370868dfac | |||
| 77f692aae2 | |||
| 9318e205ea | |||
| ebcc717c19 | |||
| 4c16b564ee | |||
| e2283d1453 | |||
| b2c6e12647 | |||
| caffb83780 | |||
| 2e4fef6c66 | |||
| 8585cd8e21 | |||
| 9fa2a7eeea | |||
| 2d1f74228d | |||
| 3d6f7aa0e1 | |||
| 3dea60366a | |||
| d4d9a1df4c | |||
| c095248176 | |||
| 44601c8954 | |||
| c95682a0c7 | |||
| d177b9f7fa | |||
| 9b57615d94 | |||
| 00f5189f58 | |||
| 4a8309ed1f | |||
| 76cfc31a1d | |||
| d9ec434699 |
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎发布插件到插件市场!
|
||||
欢迎发布插件到插件市场!请确保您的插件经过**完整的**测试。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
@@ -22,9 +22,10 @@ body:
|
||||
插件名:
|
||||
插件作者:
|
||||
插件简介:
|
||||
标签: (可选)
|
||||
社交链接: (可选, 将会在插件市场作者名称上作为可点击的链接)
|
||||
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。
|
||||
支持的消息平台:(必填,如 QQ、微信、飞书)
|
||||
标签:(可选)
|
||||
社交链接:(可选, 将会在插件市场作者名称上作为可点击的链接)
|
||||
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。如果您不知道支持哪些消息平台,请填写测试过的消息平台。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
__pycache__
|
||||
botpy.log
|
||||
.vscode
|
||||
.venv*
|
||||
.idea
|
||||
data_v2.db
|
||||
data_v3.db
|
||||
configs/session
|
||||
@@ -26,5 +28,5 @@ venv/*
|
||||
packages/python_interpreter/workplace
|
||||
.venv/*
|
||||
.conda/
|
||||
.idea/
|
||||
.idea
|
||||
pytest.ini
|
||||
|
||||
@@ -7,7 +7,7 @@ ci:
|
||||
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.10
|
||||
rev: v0.11.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -10,14 +10,12 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||
|
||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://github.com/Soulter/AstrBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple"></a>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||
[](https://codecov.io/gh/Soulter/AstrBot)
|
||||
[](https://gitcode.com/Soulter/AstrBot)
|
||||
[](https://github.com/Soulter/AstrBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
@@ -27,14 +25,20 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||
|
||||
AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
|
||||
|
||||
[](https://gitcode.com/Soulter/AstrBot)
|
||||
|
||||
<!-- [](https://codecov.io/gh/Soulter/AstrBot)
|
||||
-->
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
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 工作流。
|
||||
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
|
||||
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
|
||||
6. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合。
|
||||
2. **支持 MCP**。AstrBot 现已支持接入 MCP 服务器。
|
||||
3. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
|
||||
4. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
|
||||
5. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
|
||||
6. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
|
||||
7. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合。
|
||||
|
||||
> [!TIP]
|
||||
> 管理面板在线体验 Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
|
||||
@@ -49,30 +53,25 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
|
||||
#### Windows 一键安装器部署
|
||||
|
||||
需要电脑上安装有 Python(>3.10)。请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
|
||||
请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
|
||||
|
||||
#### Replit 部署
|
||||
#### 宝塔面板部署
|
||||
|
||||
[](https://repl.it/github/Soulter/AstrBot)
|
||||
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。
|
||||
|
||||
#### CasaOS 部署
|
||||
|
||||
社区贡献的部署方式。
|
||||
|
||||
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/casaos.html) 。
|
||||
请参阅官方文档 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html) 。
|
||||
|
||||
#### 手动部署
|
||||
|
||||
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||
|
||||
## 🚀 路线图
|
||||
#### Replit 部署
|
||||
|
||||
### 垂类功能
|
||||
|
||||
1. 更好的上下文管理:限制 token 总数、对话上下文总结
|
||||
3. AstrBot in Minecraft
|
||||
|
||||
### 横功能
|
||||
[](https://repl.it/github/Soulter/AstrBot)
|
||||
|
||||
## ⚡ 消息平台支持情况
|
||||
|
||||
@@ -106,6 +105,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
| Whisper | ✔ | 语音转文本 | 支持 API、本地部署 |
|
||||
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
|
||||
| OpenAI TTS API | ✔ | 文本转语音 | |
|
||||
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
|
||||
| Fishaudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
|
||||
| Edge-TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
|
||||
|
||||
@@ -184,16 +184,5 @@ _✨ 内置 Web Chat,在线与机器人交互 ✨_
|
||||
2. The deployment of WeChat (personal account) utilizes [Gewechat](https://github.com/Devo919/Gewechat) service. AstrBot only guarantees connectivity with Gewechat and recommends using a WeChat account that is not frequently used. In the event of account risk control, the author of this project shall not bear any responsibility.
|
||||
3. Please ensure compliance with local laws and regulations when using this project.
|
||||
|
||||
<!-- ## ✨ 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
|
||||
-->
|
||||
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from astrbot.core.platform import (
|
||||
MessageMember,
|
||||
MessageType,
|
||||
PlatformMetadata,
|
||||
Group,
|
||||
)
|
||||
|
||||
from astrbot.core.platform.register import register_platform_adapter
|
||||
@@ -18,4 +19,5 @@ __all__ = [
|
||||
"MessageType",
|
||||
"PlatformMetadata",
|
||||
"register_platform_adapter",
|
||||
"Group",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
|
||||
"""
|
||||
|
||||
VERSION = "3.4.39"
|
||||
VERSION = "3.5.0"
|
||||
DB_PATH = "data/data_v3.db"
|
||||
|
||||
# 默认配置
|
||||
@@ -49,6 +49,7 @@ DEFAULT_CONFIG = {
|
||||
"datetime_system_prompt": True,
|
||||
"default_personality": "default",
|
||||
"prompt_prefix": "",
|
||||
"max_context_length": -1,
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
@@ -80,6 +81,8 @@ DEFAULT_CONFIG = {
|
||||
"admins_id": ["astrbot"],
|
||||
"t2i": False,
|
||||
"t2i_word_threshold": 150,
|
||||
"t2i_strategy": "remote",
|
||||
"t2i_endpoint": "",
|
||||
"http_proxy": "",
|
||||
"dashboard": {
|
||||
"enable": True,
|
||||
@@ -91,7 +94,6 @@ DEFAULT_CONFIG = {
|
||||
"platform": [],
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
"t2i_endpoint": "",
|
||||
"pip_install_arg": "",
|
||||
"plugin_repo_mirror": "",
|
||||
"knowledge_db": {},
|
||||
@@ -223,7 +225,7 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "启用后,机器人可以接收到频道的私聊消息。",
|
||||
},
|
||||
"ws_reverse_host": {
|
||||
"description": "反向 Websocket 主机地址",
|
||||
"description": "反向 Websocket 主机地址(AstrBot 为服务器端)",
|
||||
"type": "string",
|
||||
"hint": "aiocqhttp 适配器的反向 Websocket 服务器 IP 地址,不包含端口号。",
|
||||
},
|
||||
@@ -345,7 +347,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"obvious_hint": True,
|
||||
"hint": "只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
|
||||
"hint": "只处理填写的 ID 发来的消息事件,为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可使用 /wl 添加白名单",
|
||||
},
|
||||
"id_whitelist_log": {
|
||||
"description": "打印白名单日志",
|
||||
@@ -581,7 +583,7 @@ CONFIG_METADATA_2 = {
|
||||
"dify_api_type": "chat",
|
||||
"dify_api_key": "",
|
||||
"dify_api_base": "https://api.dify.ai/v1",
|
||||
"dify_workflow_output_key": "",
|
||||
"dify_workflow_output_key": "astrbot_wf_output",
|
||||
"dify_query_input_key": "astrbot_text_query",
|
||||
"variables": {},
|
||||
"timeout": 60,
|
||||
@@ -593,6 +595,11 @@ CONFIG_METADATA_2 = {
|
||||
"dashscope_app_type": "agent",
|
||||
"dashscope_api_key": "",
|
||||
"dashscope_app_id": "",
|
||||
"rag_options": {
|
||||
"pipeline_ids": [],
|
||||
"file_ids": [],
|
||||
"output_reference": False,
|
||||
},
|
||||
"variables": {},
|
||||
"timeout": 60,
|
||||
},
|
||||
@@ -665,6 +672,30 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"rag_options": {
|
||||
"description": "RAG 选项",
|
||||
"type": "object",
|
||||
"hint": "检索知识库设置, 非必填。仅 Agent 应用类型支持(智能体应用, 包括 RAG 应用)",
|
||||
"items": {
|
||||
"pipeline_ids": {
|
||||
"description": "知识库 ID 列表",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "对指定知识库内所有文档进行检索, 前往 https://bailian.console.aliyun.com/ 数据应用->知识索引创建和获取 ID。",
|
||||
},
|
||||
"file_ids": {
|
||||
"description": "非结构化文档 ID, 传入该参数将对指定非结构化文档进行检索。",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "对指定非结构化文档进行检索。前往 https://bailian.console.aliyun.com/ 数据管理创建和获取 ID。",
|
||||
},
|
||||
"output_reference": {
|
||||
"description": "是否输出知识库/文档的引用",
|
||||
"type": "bool",
|
||||
"hint": "在每次回答尾部加上引用源。默认为 False。",
|
||||
},
|
||||
},
|
||||
},
|
||||
"sensevoice_hint": {
|
||||
"description": "部署SenseVoice",
|
||||
"type": "string",
|
||||
@@ -681,12 +712,14 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "modelscope 上的模型名称。默认:iic/SenseVoiceSmall。",
|
||||
},
|
||||
# "variables": {
|
||||
# "description": "工作流固定输入变量",
|
||||
# "type": "object",
|
||||
# "obvious_hint": True,
|
||||
# "hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
|
||||
# },
|
||||
"variables": {
|
||||
"description": "工作流固定输入变量",
|
||||
"type": "object",
|
||||
"obvious_hint": True,
|
||||
"items": {},
|
||||
"hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
|
||||
"invisible": True,
|
||||
},
|
||||
# "fastgpt_app_type": {
|
||||
# "description": "应用类型",
|
||||
# "type": "string",
|
||||
@@ -697,7 +730,7 @@ CONFIG_METADATA_2 = {
|
||||
"dashscope_app_type": {
|
||||
"description": "应用类型",
|
||||
"type": "string",
|
||||
"hint": "阿里云百炼应用的应用类型。",
|
||||
"hint": "百炼应用的应用类型。",
|
||||
"options": [
|
||||
"agent",
|
||||
"agent-arrange",
|
||||
@@ -877,6 +910,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "添加之后,会在每次对话的 Prompt 前加上此文本。",
|
||||
},
|
||||
"max_context_length": {
|
||||
"description": "最多携带对话数量(条)",
|
||||
"type": "int",
|
||||
"hint": "超出这个数量时将丢弃最旧的部分,用户和AI的一轮聊天记为 1 条。-1 表示不限制,默认为不限制。",
|
||||
},
|
||||
},
|
||||
},
|
||||
"persona": {
|
||||
@@ -970,10 +1008,10 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "群聊消息最大数量。超过此数量后,会自动清除旧消息。",
|
||||
},
|
||||
"image_caption": {
|
||||
"description": "启用图像转述(需要模型支持)",
|
||||
"description": "群聊图像转述(需模型支持)",
|
||||
"type": "bool",
|
||||
"obvious_hint": True,
|
||||
"hint": "启用后,当接收到图片消息时,会使用模型先将图片转述为文字再进行后续处理。推荐使用 gpt-4o-mini 模型。",
|
||||
"hint": "用模型将群聊中的图片消息转述为文字,推荐 gpt-4o-mini 模型。和机器人的唤醒聊天中的图片消息仍然会直接作为上下文输入。",
|
||||
},
|
||||
"image_caption_provider_id": {
|
||||
"description": "图像转述提供商 ID",
|
||||
@@ -1063,10 +1101,16 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "控制台输出日志的级别。",
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
},
|
||||
"t2i_strategy": {
|
||||
"description": "文本转图像渲染源",
|
||||
"type": "string",
|
||||
"hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。",
|
||||
"options": ["remote", "local"],
|
||||
},
|
||||
"t2i_endpoint": {
|
||||
"description": "文本转图像服务接口",
|
||||
"type": "string",
|
||||
"hint": "为空时使用 AstrBot API 服务",
|
||||
"hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务",
|
||||
},
|
||||
"pip_install_arg": {
|
||||
"description": "pip 安装参数",
|
||||
|
||||
@@ -40,7 +40,6 @@ class AstrBotCoreLifecycle:
|
||||
else:
|
||||
logger.setLevel(self.astrbot_config["log_level"])
|
||||
self.event_queue = Queue()
|
||||
self.event_queue.closed = False
|
||||
|
||||
self.provider_manager = ProviderManager(self.astrbot_config, self.db)
|
||||
|
||||
@@ -81,6 +80,8 @@ class AstrBotCoreLifecycle:
|
||||
await self.platform_manager.initialize()
|
||||
"""根据配置实例化各个平台适配器"""
|
||||
|
||||
self.dashboard_shutdown_event = asyncio.Event()
|
||||
|
||||
def _load(self):
|
||||
event_bus_task = asyncio.create_task(
|
||||
self.event_bus.dispatch(), name="event_bus"
|
||||
@@ -129,11 +130,12 @@ class AstrBotCoreLifecycle:
|
||||
await asyncio.gather(*self.curr_tasks, return_exceptions=True)
|
||||
|
||||
async def stop(self):
|
||||
self.event_queue.closed = True
|
||||
for task in self.curr_tasks:
|
||||
task.cancel()
|
||||
|
||||
await self.provider_manager.terminate()
|
||||
await self.platform_manager.terminate()
|
||||
self.dashboard_shutdown_event.set()
|
||||
|
||||
for task in self.curr_tasks:
|
||||
try:
|
||||
@@ -143,8 +145,10 @@ class AstrBotCoreLifecycle:
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task.get_name()} 发生错误: {e}")
|
||||
|
||||
def restart(self):
|
||||
self.event_queue.closed = True
|
||||
async def restart(self):
|
||||
await self.provider_manager.terminate()
|
||||
await self.platform_manager.terminate()
|
||||
self.dashboard_shutdown_event.set()
|
||||
threading.Thread(
|
||||
target=self.astrbot_updator._reboot, name="restart", daemon=True
|
||||
).start()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation
|
||||
|
||||
|
||||
@@ -117,3 +117,45 @@ class BaseDatabase(abc.ABC):
|
||||
def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str):
|
||||
"""更新 Conversation Persona ID"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_all_conversations(
|
||||
self, page: int = 1, page_size: int = 20
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取所有对话,支持分页
|
||||
|
||||
Args:
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_filtered_conversations(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
platforms: List[str] = None,
|
||||
message_types: List[str] = None,
|
||||
search_query: str = None,
|
||||
exclude_ids: List[str] = None,
|
||||
exclude_platforms: List[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取筛选后的对话列表
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
platforms: 平台筛选列表
|
||||
message_types: 消息类型筛选列表
|
||||
search_query: 搜索关键词
|
||||
exclude_ids: 排除的用户ID列表
|
||||
exclude_platforms: 排除的平台列表
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
+192
-18
@@ -3,7 +3,7 @@ import os
|
||||
import time
|
||||
from astrbot.core.db.po import Platform, Stats, LLMHistory, ATRIVision, Conversation
|
||||
from . import BaseDatabase
|
||||
from typing import Tuple
|
||||
from typing import Tuple, List, Dict, Any
|
||||
|
||||
|
||||
class SQLiteDatabase(BaseDatabase):
|
||||
@@ -128,24 +128,23 @@ class SQLiteDatabase(BaseDatabase):
|
||||
except sqlite3.ProgrammingError:
|
||||
c = self._get_conn(self.db_path).cursor()
|
||||
|
||||
where_clause = ""
|
||||
if session_id or provider_type:
|
||||
where_clause += " WHERE "
|
||||
has = False
|
||||
if session_id:
|
||||
where_clause += f"session_id = '{session_id}'"
|
||||
has = True
|
||||
if provider_type:
|
||||
if has:
|
||||
where_clause += " AND "
|
||||
where_clause += f"provider_type = '{provider_type}'"
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if session_id:
|
||||
conditions.append("session_id = ?")
|
||||
params.append(session_id)
|
||||
|
||||
if provider_type:
|
||||
conditions.append("provider_type = ?")
|
||||
params.append(provider_type)
|
||||
|
||||
sql = "SELECT * FROM llm_history"
|
||||
if conditions:
|
||||
sql += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
c.execute(sql, params)
|
||||
|
||||
c.execute(
|
||||
"""
|
||||
SELECT * FROM llm_history
|
||||
"""
|
||||
+ where_clause
|
||||
)
|
||||
res = c.fetchall()
|
||||
histories = []
|
||||
for row in res:
|
||||
@@ -389,3 +388,178 @@ class SQLiteDatabase(BaseDatabase):
|
||||
if res:
|
||||
return ATRIVision(*res)
|
||||
return None
|
||||
|
||||
def get_all_conversations(
|
||||
self, page: int = 1, page_size: int = 20
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取所有对话,支持分页,按更新时间降序排序"""
|
||||
try:
|
||||
c = self.conn.cursor()
|
||||
except sqlite3.ProgrammingError:
|
||||
c = self._get_conn(self.db_path).cursor()
|
||||
|
||||
try:
|
||||
# 获取总记录数
|
||||
c.execute("""
|
||||
SELECT COUNT(*) FROM webchat_conversation
|
||||
""")
|
||||
total_count = c.fetchone()[0]
|
||||
|
||||
# 计算偏移量
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 获取分页数据,按更新时间降序排序
|
||||
c.execute(
|
||||
"""
|
||||
SELECT user_id, cid, created_at, updated_at, title, persona_id
|
||||
FROM webchat_conversation
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, offset),
|
||||
)
|
||||
|
||||
rows = c.fetchall()
|
||||
|
||||
conversations = []
|
||||
|
||||
for row in rows:
|
||||
user_id, cid, created_at, updated_at, title, persona_id = row
|
||||
# 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值
|
||||
safe_cid = str(cid) if cid else "unknown"
|
||||
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
|
||||
|
||||
conversations.append(
|
||||
{
|
||||
"user_id": user_id or "",
|
||||
"cid": safe_cid,
|
||||
"title": title or f"对话 {display_cid}",
|
||||
"persona_id": persona_id or "",
|
||||
"created_at": created_at or 0,
|
||||
"updated_at": updated_at or 0,
|
||||
}
|
||||
)
|
||||
|
||||
return conversations, total_count
|
||||
|
||||
except Exception as _:
|
||||
# 返回空列表和0,确保即使出错也有有效的返回值
|
||||
return [], 0
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def get_filtered_conversations(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
platforms: List[str] = None,
|
||||
message_types: List[str] = None,
|
||||
search_query: str = None,
|
||||
exclude_ids: List[str] = None,
|
||||
exclude_platforms: List[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取筛选后的对话列表"""
|
||||
try:
|
||||
c = self.conn.cursor()
|
||||
except sqlite3.ProgrammingError:
|
||||
c = self._get_conn(self.db_path).cursor()
|
||||
|
||||
try:
|
||||
# 构建查询条件
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
# 平台筛选
|
||||
if platforms and len(platforms) > 0:
|
||||
platform_conditions = []
|
||||
for platform in platforms:
|
||||
platform_conditions.append("user_id LIKE ?")
|
||||
params.append(f"{platform}:%")
|
||||
|
||||
if platform_conditions:
|
||||
where_clauses.append(f"({' OR '.join(platform_conditions)})")
|
||||
|
||||
# 消息类型筛选
|
||||
if message_types and len(message_types) > 0:
|
||||
message_type_conditions = []
|
||||
for msg_type in message_types:
|
||||
message_type_conditions.append("user_id LIKE ?")
|
||||
params.append(f"%:{msg_type}:%")
|
||||
|
||||
if message_type_conditions:
|
||||
where_clauses.append(f"({' OR '.join(message_type_conditions)})")
|
||||
|
||||
# 搜索关键词
|
||||
if search_query:
|
||||
search_query = search_query.encode("unicode_escape").decode("utf-8")
|
||||
where_clauses.append(
|
||||
"(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)"
|
||||
)
|
||||
search_param = f"%{search_query}%"
|
||||
params.extend([search_param, search_param, search_param, search_param])
|
||||
|
||||
# 排除特定用户ID
|
||||
if exclude_ids and len(exclude_ids) > 0:
|
||||
for exclude_id in exclude_ids:
|
||||
where_clauses.append("user_id NOT LIKE ?")
|
||||
params.append(f"{exclude_id}%")
|
||||
|
||||
# 排除特定平台
|
||||
if exclude_platforms and len(exclude_platforms) > 0:
|
||||
for exclude_platform in exclude_platforms:
|
||||
where_clauses.append("user_id NOT LIKE ?")
|
||||
params.append(f"{exclude_platform}:%")
|
||||
|
||||
# 构建完整的 WHERE 子句
|
||||
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||
|
||||
# 构建计数查询
|
||||
count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}"
|
||||
|
||||
# 获取总记录数
|
||||
c.execute(count_sql, params)
|
||||
total_count = c.fetchone()[0]
|
||||
|
||||
# 计算偏移量
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 构建分页数据查询
|
||||
data_sql = f"""
|
||||
SELECT user_id, cid, created_at, updated_at, title, persona_id
|
||||
FROM webchat_conversation
|
||||
{where_sql}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
query_params = params + [page_size, offset]
|
||||
|
||||
# 获取分页数据
|
||||
c.execute(data_sql, query_params)
|
||||
rows = c.fetchall()
|
||||
|
||||
conversations = []
|
||||
|
||||
for row in rows:
|
||||
user_id, cid, created_at, updated_at, title, persona_id = row
|
||||
# 确保 cid 是字符串类型,否则使用一个默认值
|
||||
safe_cid = str(cid) if cid else "unknown"
|
||||
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
|
||||
|
||||
conversations.append(
|
||||
{
|
||||
"user_id": user_id or "",
|
||||
"cid": safe_cid,
|
||||
"title": title or f"对话 {display_cid}",
|
||||
"persona_id": persona_id or "",
|
||||
"created_at": created_at or 0,
|
||||
"updated_at": updated_at or 0,
|
||||
}
|
||||
)
|
||||
|
||||
return conversations, total_count
|
||||
|
||||
except Exception as _:
|
||||
# 返回空列表和0,确保即使出错也有有效的返回值
|
||||
return [], 0
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
@@ -38,11 +38,13 @@ CREATE TABLE IF NOT EXISTS atri_vision(
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webchat_conversation(
|
||||
user_id TEXT,
|
||||
cid TEXT,
|
||||
user_id TEXT, -- 会话 id
|
||||
cid TEXT, -- 对话 id
|
||||
history TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
title TEXT,
|
||||
persona_id TEXT
|
||||
);
|
||||
);
|
||||
|
||||
PRAGMA encoding = 'UTF-8';
|
||||
@@ -2,17 +2,16 @@ import asyncio
|
||||
import traceback
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from .server import AstrBotDashboard
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
|
||||
|
||||
class AstrBotDashBoardLifecycle:
|
||||
class InitialLoader:
|
||||
def __init__(self, db: BaseDatabase, log_broker: LogBroker):
|
||||
self.db = db
|
||||
self.logger = logger
|
||||
self.log_broker = log_broker
|
||||
self.dashboard_server = None
|
||||
|
||||
async def start(self):
|
||||
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
|
||||
@@ -25,7 +24,9 @@ class AstrBotDashBoardLifecycle:
|
||||
logger.critical(traceback.format_exc())
|
||||
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
|
||||
|
||||
self.dashboard_server = AstrBotDashboard(core_lifecycle, self.db)
|
||||
self.dashboard_server = AstrBotDashboard(
|
||||
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
|
||||
)
|
||||
task = asyncio.gather(core_task, self.dashboard_server.run())
|
||||
|
||||
try:
|
||||
@@ -25,9 +25,11 @@ SOFTWARE.
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import typing as T
|
||||
from enum import Enum
|
||||
from pydantic.v1 import BaseModel
|
||||
from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
|
||||
|
||||
class ComponentType(Enum):
|
||||
@@ -59,6 +61,8 @@ class ComponentType(Enum):
|
||||
TTS = "TTS"
|
||||
Unknown = "Unknown"
|
||||
|
||||
WechatEmoji = "WechatEmoji" # Wechat 下的 emoji 表情包
|
||||
|
||||
|
||||
class BaseMessageComponent(BaseModel):
|
||||
type: ComponentType
|
||||
@@ -146,6 +150,51 @@ class Record(BaseMessageComponent):
|
||||
return Record(file=url, **_)
|
||||
raise Exception("not a valid url")
|
||||
|
||||
async def convert_to_file_path(self) -> str:
|
||||
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
|
||||
|
||||
Returns:
|
||||
str: 语音的本地路径,以绝对路径表示。
|
||||
"""
|
||||
if self.file and self.file.startswith("file:///"):
|
||||
file_path = self.file[8:]
|
||||
return file_path
|
||||
elif self.file and self.file.startswith("http"):
|
||||
file_path = await download_image_by_url(self.file)
|
||||
return os.path.abspath(file_path)
|
||||
elif self.file and self.file.startswith("base64://"):
|
||||
bs64_data = self.file.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
file_path = f"data/temp/{uuid.uuid4()}.jpg"
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(file_path)
|
||||
elif os.path.exists(self.file):
|
||||
file_path = self.file
|
||||
return os.path.abspath(file_path)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
|
||||
async def convert_to_base64(self) -> str:
|
||||
"""将语音统一转换为 base64 编码。这个方法避免了手动判断语音数据类型,直接返回语音数据的 base64 编码。
|
||||
|
||||
Returns:
|
||||
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
||||
"""
|
||||
# convert to base64
|
||||
if self.file and self.file.startswith("file:///"):
|
||||
bs64_data = file_to_base64(self.file[8:])
|
||||
elif self.file and self.file.startswith("http"):
|
||||
file_path = await download_image_by_url(self.file)
|
||||
bs64_data = file_to_base64(file_path)
|
||||
elif self.file and self.file.startswith("base64://"):
|
||||
bs64_data = self.file
|
||||
elif os.path.exists(self.file):
|
||||
bs64_data = file_to_base64(self.file)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
return bs64_data
|
||||
|
||||
|
||||
class Video(BaseMessageComponent):
|
||||
type: ComponentType = "Video"
|
||||
@@ -279,10 +328,6 @@ class Image(BaseMessageComponent):
|
||||
file_unique: T.Optional[str] = "" # 某些平台可能有图片缓存的唯一标识
|
||||
|
||||
def __init__(self, file: T.Optional[str], **_):
|
||||
# for k in _.keys():
|
||||
# if (k == "_type" and _[k] not in ["flash", "show", None]) or \
|
||||
# (k == "c" and _[k] not in [2, 3]):
|
||||
# logger.warn(f"Protocol: {k}={_[k]} doesn't match values")
|
||||
super().__init__(file=file, **_)
|
||||
|
||||
@staticmethod
|
||||
@@ -307,6 +352,53 @@ class Image(BaseMessageComponent):
|
||||
def fromIO(IO):
|
||||
return Image.fromBytes(IO.read())
|
||||
|
||||
async def convert_to_file_path(self) -> str:
|
||||
"""将这个图片统一转换为本地文件路径。这个方法避免了手动判断图片数据类型,直接返回图片数据的本地路径(如果是网络 URL, 则会自动进行下载)。
|
||||
|
||||
Returns:
|
||||
str: 图片的本地路径,以绝对路径表示。
|
||||
"""
|
||||
url = self.url if self.url else self.file
|
||||
if url and url.startswith("file:///"):
|
||||
image_file_path = url[8:]
|
||||
return image_file_path
|
||||
elif url and url.startswith("http"):
|
||||
image_file_path = await download_image_by_url(url)
|
||||
return os.path.abspath(image_file_path)
|
||||
elif url and url.startswith("base64://"):
|
||||
bs64_data = url.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
image_file_path = f"data/temp/{uuid.uuid4()}.jpg"
|
||||
with open(image_file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(image_file_path)
|
||||
elif os.path.exists(url):
|
||||
image_file_path = url
|
||||
return os.path.abspath(image_file_path)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {url}")
|
||||
|
||||
async def convert_to_base64(self) -> str:
|
||||
"""将这个图片统一转换为 base64 编码。这个方法避免了手动判断图片数据类型,直接返回图片数据的 base64 编码。
|
||||
|
||||
Returns:
|
||||
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
||||
"""
|
||||
# convert to base64
|
||||
url = self.url if self.url else self.file
|
||||
if url and url.startswith("file:///"):
|
||||
bs64_data = file_to_base64(url[8:])
|
||||
elif url and url.startswith("http"):
|
||||
image_file_path = await download_image_by_url(url)
|
||||
bs64_data = file_to_base64(image_file_path)
|
||||
elif url and url.startswith("base64://"):
|
||||
bs64_data = url
|
||||
elif os.path.exists(url):
|
||||
bs64_data = file_to_base64(url)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {url}")
|
||||
return bs64_data
|
||||
|
||||
|
||||
class Reply(BaseMessageComponent):
|
||||
type: ComponentType = "Reply"
|
||||
@@ -322,6 +414,8 @@ class Reply(BaseMessageComponent):
|
||||
"""引用的消息发送时间"""
|
||||
message_str: T.Optional[str] = ""
|
||||
"""解析后的纯文本消息字符串"""
|
||||
sender_str: T.Optional[str] = ""
|
||||
"""被引用的消息纯文本"""
|
||||
|
||||
text: T.Optional[str] = ""
|
||||
"""deprecated"""
|
||||
@@ -469,6 +563,16 @@ class File(BaseMessageComponent):
|
||||
super().__init__(name=name, file=file)
|
||||
|
||||
|
||||
class WechatEmoji(BaseMessageComponent):
|
||||
type: ComponentType = "WechatEmoji"
|
||||
md5: T.Optional[str] = ""
|
||||
md5_len: T.Optional[int] = 0
|
||||
cdnurl: T.Optional[str] = ""
|
||||
|
||||
def __init__(self, **_):
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
ComponentTypes = {
|
||||
"plain": Plain,
|
||||
"text": Plain,
|
||||
@@ -497,4 +601,5 @@ ComponentTypes = {
|
||||
"tts": TTS,
|
||||
"unknown": Unknown,
|
||||
"file": File,
|
||||
"WechatEmoji": WechatEmoji,
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ class MessageChain:
|
||||
self.use_t2i_ = use_t2i
|
||||
return self
|
||||
|
||||
def get_plain_text(self) -> str:
|
||||
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
|
||||
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
|
||||
|
||||
|
||||
class EventResultType(enum.Enum):
|
||||
"""用于描述事件处理的结果类型。
|
||||
@@ -147,9 +151,5 @@ class MessageEventResult(MessageChain):
|
||||
"""是否为 LLM 结果。"""
|
||||
return self.result_content_type == ResultContentType.LLM_RESULT
|
||||
|
||||
def get_plain_text(self) -> str:
|
||||
"""获取纯文本消息。这个方法将获取所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
|
||||
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
|
||||
|
||||
|
||||
CommandResult = MessageEventResult
|
||||
|
||||
@@ -16,7 +16,13 @@ from astrbot.core.message.message_event_result import (
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.provider.entites import ProviderRequest, LLMResponse
|
||||
from astrbot.core.provider.entites import (
|
||||
ProviderRequest,
|
||||
LLMResponse,
|
||||
ToolCallMessageSegment,
|
||||
AssistantMessageSegment,
|
||||
ToolCallsResult,
|
||||
)
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
|
||||
@@ -28,6 +34,9 @@ class LLMRequestSubStage(Stage):
|
||||
self.provider_wake_prefix = ctx.astrbot_config["provider_settings"][
|
||||
"wake_prefix"
|
||||
] # str
|
||||
self.max_context_length = ctx.astrbot_config["provider_settings"][
|
||||
"max_context_length"
|
||||
] # int
|
||||
|
||||
for bwp in self.bot_wake_prefixs:
|
||||
if self.provider_wake_prefix.startswith(bwp):
|
||||
@@ -64,21 +73,28 @@ class LLMRequestSubStage(Stage):
|
||||
req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Image):
|
||||
image_url = comp.url if comp.url else comp.file
|
||||
req.image_urls.append(image_url)
|
||||
image_path = await comp.convert_to_file_path()
|
||||
req.image_urls.append(image_path)
|
||||
|
||||
# 获取对话上下文
|
||||
conversation_id = await self.conv_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
req.session_id = event.unified_msg_origin
|
||||
if not conversation_id:
|
||||
conversation_id = await self.conv_manager.new_conversation(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
req.session_id = event.unified_msg_origin
|
||||
conversation = await self.conv_manager.get_conversation(
|
||||
event.unified_msg_origin, conversation_id
|
||||
)
|
||||
if not conversation:
|
||||
conversation_id = await self.conv_manager.new_conversation(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
conversation = await self.conv_manager.get_conversation(
|
||||
event.unified_msg_origin, conversation_id
|
||||
)
|
||||
req.conversation = conversation
|
||||
req.contexts = json.loads(conversation.history)
|
||||
|
||||
@@ -110,33 +126,47 @@ class LLMRequestSubStage(Stage):
|
||||
if isinstance(req.contexts, str):
|
||||
req.contexts = json.loads(req.contexts)
|
||||
|
||||
# max context length
|
||||
if (
|
||||
self.max_context_length != -1 # -1 为不限制
|
||||
and len(req.contexts) // 2 > self.max_context_length
|
||||
):
|
||||
logger.debug("上下文长度超过限制,将截断。")
|
||||
req.contexts = req.contexts[-self.max_context_length * 2 :]
|
||||
|
||||
try:
|
||||
logger.debug(f"提供商请求 Payload: {req}")
|
||||
if _nested:
|
||||
req.func_tool = None # 暂时不支持递归工具调用
|
||||
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
|
||||
need_loop = True
|
||||
while need_loop:
|
||||
need_loop = False
|
||||
logger.debug(f"提供商请求 Payload: {req}")
|
||||
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
|
||||
|
||||
# 执行 LLM 响应后的事件钩子。
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnLLMResponseEvent
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.debug(
|
||||
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||
)
|
||||
await handler.handler(event, llm_response)
|
||||
except BaseException:
|
||||
logger.error(traceback.format_exc())
|
||||
# 执行 LLM 响应后的事件钩子。
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnLLMResponseEvent
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.debug(
|
||||
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||
)
|
||||
await handler.handler(event, llm_response)
|
||||
except BaseException:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
if event.is_stopped():
|
||||
logger.info(
|
||||
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
|
||||
)
|
||||
return
|
||||
if event.is_stopped():
|
||||
logger.info(
|
||||
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
|
||||
)
|
||||
return
|
||||
|
||||
# 保存到历史记录
|
||||
await self._save_to_history(event, req, llm_response)
|
||||
async for result in self._handle_llm_response(event, req, llm_response):
|
||||
if isinstance(result, ProviderRequest):
|
||||
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
|
||||
req = result
|
||||
need_loop = True
|
||||
else:
|
||||
yield
|
||||
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
@@ -146,72 +176,8 @@ class LLMRequestSubStage(Stage):
|
||||
)
|
||||
)
|
||||
|
||||
if llm_response.role == "assistant":
|
||||
# text completion
|
||||
if llm_response.result_chain:
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=llm_response.result_chain.chain
|
||||
).set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(llm_response.completion_text)
|
||||
.set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
elif llm_response.role == "err":
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
|
||||
)
|
||||
)
|
||||
elif llm_response.role == "tool":
|
||||
# function calling
|
||||
function_calling_result = {}
|
||||
logger.info(
|
||||
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
|
||||
)
|
||||
for func_tool_name, func_tool_args in zip(
|
||||
llm_response.tools_call_name, llm_response.tools_call_args
|
||||
):
|
||||
func_tool = req.func_tool.get_func(func_tool_name)
|
||||
logger.info(
|
||||
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
|
||||
)
|
||||
try:
|
||||
# 尝试调用工具函数
|
||||
wrapper = self._call_handler(
|
||||
self.ctx, event, func_tool.handler, **func_tool_args
|
||||
)
|
||||
async for resp in wrapper:
|
||||
if resp is not None: # 有 return 返回
|
||||
function_calling_result[func_tool_name] = resp
|
||||
else:
|
||||
yield # 有生成器返回
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
except BaseException as e:
|
||||
logger.warning(traceback.format_exc())
|
||||
function_calling_result[func_tool_name] = (
|
||||
"When calling the function, an error occurred: " + str(e)
|
||||
)
|
||||
if function_calling_result:
|
||||
# 工具返回 LLM 资源。比如 RAG、网页 得到的相关结果等。
|
||||
# 我们重新执行一遍这个 stage
|
||||
req.func_tool = None # 暂时不支持递归工具调用
|
||||
extra_prompt = "\n\nSystem executed some external tools for this task and here are the results:\n"
|
||||
for tool_name, tool_result in function_calling_result.items():
|
||||
extra_prompt += (
|
||||
f"Tool: {tool_name}\nTool Result: {tool_result}\n"
|
||||
)
|
||||
req.prompt += extra_prompt
|
||||
async for _ in self.process(event, _nested=True):
|
||||
yield
|
||||
else:
|
||||
if llm_response.completion_text:
|
||||
event.set_result(
|
||||
MessageEventResult().message(llm_response.completion_text)
|
||||
)
|
||||
# 保存到历史记录
|
||||
await self._save_to_history(event, req, llm_response)
|
||||
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -222,6 +188,116 @@ class LLMRequestSubStage(Stage):
|
||||
)
|
||||
return
|
||||
|
||||
async def _handle_llm_response(
|
||||
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""处理 LLM 响应。
|
||||
|
||||
Returns:
|
||||
bool: 是否需要继续调用 LLM
|
||||
|
||||
Yields:
|
||||
Iterator[bool]: 将 event 交付给下一个 stage
|
||||
"""
|
||||
if llm_response.role == "assistant":
|
||||
# text completion
|
||||
if llm_response.result_chain:
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=llm_response.result_chain.chain
|
||||
).set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(llm_response.completion_text)
|
||||
.set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
elif llm_response.role == "err":
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
|
||||
)
|
||||
)
|
||||
elif llm_response.role == "tool":
|
||||
# function calling
|
||||
tool_call_result: list[ToolCallMessageSegment] = []
|
||||
logger.info(
|
||||
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
|
||||
)
|
||||
for func_tool_name, func_tool_args, func_tool_id in zip(
|
||||
llm_response.tools_call_name,
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
try:
|
||||
func_tool = req.func_tool.get_func(func_tool_name)
|
||||
if func_tool.origin == "mcp":
|
||||
logger.info(
|
||||
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
|
||||
)
|
||||
client = req.func_tool.mcp_client_dict[
|
||||
func_tool.mcp_server_name
|
||||
]
|
||||
res = await client.session.call_tool(
|
||||
func_tool.name, func_tool_args
|
||||
)
|
||||
if res:
|
||||
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=res.content[0].text,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
|
||||
)
|
||||
# 尝试调用工具函数
|
||||
wrapper = self._call_handler(
|
||||
self.ctx, event, func_tool.handler, **func_tool_args
|
||||
)
|
||||
async for resp in wrapper:
|
||||
if resp is not None: # 有 return 返回
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=resp,
|
||||
)
|
||||
)
|
||||
else:
|
||||
yield # 有生成器返回
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
except BaseException as e:
|
||||
logger.warning(traceback.format_exc())
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=f"error: {str(e)}",
|
||||
)
|
||||
)
|
||||
if tool_call_result:
|
||||
# 函数调用结果
|
||||
req.func_tool = None # 暂时不支持递归工具调用
|
||||
assistant_msg_seg = AssistantMessageSegment(
|
||||
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
|
||||
)
|
||||
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
|
||||
req.tool_calls_result = ToolCallsResult(
|
||||
tool_calls_info=assistant_msg_seg,
|
||||
tool_calls_result=tool_call_result,
|
||||
)
|
||||
yield req # 再次执行 LLM 请求
|
||||
else:
|
||||
if llm_response.completion_text:
|
||||
event.set_result(
|
||||
MessageEventResult().message(llm_response.completion_text)
|
||||
)
|
||||
|
||||
async def _save_to_history(
|
||||
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
|
||||
):
|
||||
@@ -231,8 +307,12 @@ class LLMRequestSubStage(Stage):
|
||||
if llm_response.role == "assistant":
|
||||
# 文本回复
|
||||
contexts = req.contexts
|
||||
new_record = {"role": "user", "content": req.prompt}
|
||||
contexts.append(new_record)
|
||||
contexts.append(await req.assemble_context())
|
||||
|
||||
# tool calls result
|
||||
if req.tool_calls_result:
|
||||
contexts.extend(req.tool_calls_result.to_openai_messages())
|
||||
|
||||
contexts.append(
|
||||
{"role": "assistant", "content": llm_response.completion_text}
|
||||
)
|
||||
|
||||
@@ -103,9 +103,16 @@ class RespondStage(Stage):
|
||||
for comp in result.chain:
|
||||
i = await self._calc_comp_interval(comp)
|
||||
await asyncio.sleep(i)
|
||||
await event.send(MessageChain([*decorated_comps, comp]))
|
||||
try:
|
||||
await event.send(MessageChain([*decorated_comps, comp]))
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
break
|
||||
else:
|
||||
await event.send(result)
|
||||
try:
|
||||
await event.send(result)
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
await event._post_send()
|
||||
logger.info(
|
||||
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
|
||||
|
||||
@@ -31,6 +31,8 @@ class ResultDecorateStage(Stage):
|
||||
self.t2i_word_threshold = 50
|
||||
except BaseException:
|
||||
self.t2i_word_threshold = 150
|
||||
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
|
||||
self.t2i_use_network = self.t2i_strategy == "remote"
|
||||
|
||||
self.forward_threshold = ctx.astrbot_config["platform_settings"][
|
||||
"forward_threshold"
|
||||
@@ -192,7 +194,9 @@ class ResultDecorateStage(Stage):
|
||||
if plain_str and len(plain_str) > self.t2i_word_threshold:
|
||||
render_start = time.time()
|
||||
try:
|
||||
url = await html_renderer.render_t2i(plain_str, return_url=True)
|
||||
url = await html_renderer.render_t2i(
|
||||
plain_str, return_url=True, use_network=self.t2i_use_network
|
||||
)
|
||||
except BaseException:
|
||||
logger.error("文本转图片失败,使用文本发送。")
|
||||
return
|
||||
@@ -201,7 +205,10 @@ class ResultDecorateStage(Stage):
|
||||
"文本转图片耗时超过了 3 秒,如果觉得很慢可以使用 /t2i 关闭文本转图片模式。"
|
||||
)
|
||||
if url:
|
||||
result.chain = [Image.fromURL(url)]
|
||||
if url.startswith("http"):
|
||||
result.chain = [Image.fromURL(url)]
|
||||
else:
|
||||
result.chain = [Image.fromFileSystem(url)]
|
||||
|
||||
# 触发转发消息
|
||||
has_forwarded = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .platform import Platform
|
||||
from .astr_message_event import AstrMessageEvent
|
||||
from .platform_metadata import PlatformMetadata
|
||||
from .astrbot_message import AstrBotMessage, MessageMember, MessageType
|
||||
from .astrbot_message import AstrBotMessage, MessageMember, MessageType, Group
|
||||
|
||||
__all__ = [
|
||||
"Platform",
|
||||
@@ -10,4 +10,5 @@ __all__ = [
|
||||
"AstrBotMessage",
|
||||
"MessageMember",
|
||||
"MessageType",
|
||||
"Group",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import abc
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from .astrbot_message import AstrBotMessage
|
||||
from .platform_metadata import PlatformMetadata
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from typing import List, Union
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.message.components import (
|
||||
Plain,
|
||||
Image,
|
||||
@@ -16,9 +14,12 @@ from astrbot.core.message.components import (
|
||||
Forward,
|
||||
Reply,
|
||||
)
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from .astrbot_message import AstrBotMessage, Group
|
||||
from .platform_metadata import PlatformMetadata
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -201,15 +202,6 @@ class AstrMessageEvent(abc.ABC):
|
||||
"""
|
||||
return self.role == "admin"
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
"""
|
||||
发送消息到消息平台。
|
||||
"""
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def _pre_send(self):
|
||||
"""调度器会在执行 send() 前调用该方法"""
|
||||
|
||||
@@ -371,3 +363,26 @@ class AstrMessageEvent(abc.ABC):
|
||||
system_prompt=system_prompt,
|
||||
conversation=conversation,
|
||||
)
|
||||
|
||||
"""平台适配器"""
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
"""发送消息到消息平台。
|
||||
|
||||
Args:
|
||||
message (MessageChain): 消息链,具体使用方式请参考文档。
|
||||
"""
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def get_group(self, group_id: str = None, **kwargs) -> Optional[Group]:
|
||||
"""获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息,返回当前群聊的数据。
|
||||
|
||||
适配情况:
|
||||
|
||||
- gewechat
|
||||
- aiocqhttp(OneBotv11)
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -10,6 +10,41 @@ class MessageMember:
|
||||
user_id: str # 发送者id
|
||||
nickname: str = None
|
||||
|
||||
def __str__(self):
|
||||
# 使用 f-string 来构建返回的字符串表示形式
|
||||
return (
|
||||
f"User ID: {self.user_id},"
|
||||
f"Nickname: {self.nickname if self.nickname else 'N/A'}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Group:
|
||||
group_id: str
|
||||
"""群号"""
|
||||
group_name: str = None
|
||||
"""群名称"""
|
||||
group_avatar: str = None
|
||||
"""群头像"""
|
||||
group_owner: str = None
|
||||
"""群主 id"""
|
||||
group_admins: List[str] = None
|
||||
"""群管理员 id"""
|
||||
members: List[MessageMember] = None
|
||||
"""所有群成员"""
|
||||
|
||||
def __str__(self):
|
||||
# 使用 f-string 来构建返回的字符串表示形式
|
||||
return (
|
||||
f"Group ID: {self.group_id}\n"
|
||||
f"Name: {self.group_name if self.group_name else 'N/A'}\n"
|
||||
f"Avatar: {self.group_avatar if self.group_avatar else 'N/A'}\n"
|
||||
f"Owner ID: {self.group_owner if self.group_owner else 'N/A'}\n"
|
||||
f"Admin IDs: {self.group_admins if self.group_admins else 'N/A'}\n"
|
||||
f"Members Len: {len(self.members) if self.members else 0}\n"
|
||||
f"First Member: {self.members[0] if self.members else 'N/A'}\n"
|
||||
)
|
||||
|
||||
|
||||
class AstrBotMessage:
|
||||
"""
|
||||
|
||||
@@ -85,14 +85,18 @@ class PlatformManager:
|
||||
)
|
||||
return
|
||||
cls_type = platform_cls_map[platform_config["type"]]
|
||||
inst = cls_type(platform_config, self.settings, self.event_queue)
|
||||
self._inst_map[platform_config["id"]] = inst
|
||||
inst: Platform = cls_type(platform_config, self.settings, self.event_queue)
|
||||
self._inst_map[platform_config["id"]] = {
|
||||
"inst": inst,
|
||||
"client_id": inst.client_self_id,
|
||||
}
|
||||
self.platform_insts.append(inst)
|
||||
|
||||
asyncio.create_task(
|
||||
self._task_wrapper(
|
||||
asyncio.create_task(
|
||||
inst.run(), name=platform_config["id"] + "_platform"
|
||||
inst.run(),
|
||||
name=f"platform_{platform_config['type']}_{platform_config['id']}",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -109,38 +113,42 @@ class PlatformManager:
|
||||
logger.error("-------")
|
||||
|
||||
async def reload(self, platform_config: dict):
|
||||
# 还未实现完成,不要调用此方法
|
||||
|
||||
if platform_config["id"] in self._inst_map:
|
||||
# 正在运行
|
||||
if getattr(self._inst_map[platform_config["id"]], "terminate", None):
|
||||
logger.info(f"正在尝试终止 {platform_config['id']} 平台适配器 ...")
|
||||
await self._inst_map[platform_config["id"]].terminate()
|
||||
logger.info(f"{platform_config['id']} 平台适配器已终止。")
|
||||
del self._inst_map[platform_config["id"]]
|
||||
self.platform_insts.remove(self._inst_map[platform_config["id"]])
|
||||
else:
|
||||
logger.warning(f"可能无法正常终止 {platform_config['id']} 平台适配器。")
|
||||
|
||||
# 再启动新的实例
|
||||
await self.terminate_platform(platform_config["id"])
|
||||
if platform_config["enable"]:
|
||||
await self.load_platform(platform_config)
|
||||
|
||||
else:
|
||||
# 先将 _inst_map 中在 platform_config 中不存在的实例删除
|
||||
config_ids = [platform["id"] for platform in self.platforms_config]
|
||||
for key in list(self._inst_map.keys()):
|
||||
if key not in config_ids:
|
||||
if getattr(self._inst_map[key], "terminate", None):
|
||||
logger.info(f"正在尝试终止 {key} 平台适配器 ...")
|
||||
await self._inst_map[key].terminate()
|
||||
logger.info(f"{key} 平台适配器已终止。")
|
||||
del self._inst_map[key]
|
||||
self.platform_insts.remove(self._inst_map[key])
|
||||
else:
|
||||
logger.warning(f"可能无法正常终止 {key} 平台适配器。")
|
||||
# 和配置文件保持同步
|
||||
config_ids = [provider["id"] for provider in self.platforms_config]
|
||||
for key in list(self._inst_map.keys()):
|
||||
if key not in config_ids:
|
||||
await self.terminate_platform(key)
|
||||
|
||||
# 再启动新的实例
|
||||
await self.load_platform(platform_config)
|
||||
async def terminate_platform(self, platform_id: str):
|
||||
if platform_id in self._inst_map:
|
||||
logger.info(f"正在尝试终止 {platform_id} 平台适配器 ...")
|
||||
|
||||
# client_id = self._inst_map.pop(platform_id, None)
|
||||
info = self._inst_map.pop(platform_id, None)
|
||||
client_id = info["client_id"]
|
||||
inst = info["inst"]
|
||||
try:
|
||||
self.platform_insts.remove(
|
||||
next(
|
||||
inst
|
||||
for inst in self.platform_insts
|
||||
if inst.client_self_id == client_id
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(f"可能未完全移除 {platform_id} 平台适配器")
|
||||
|
||||
if getattr(inst, "terminate", None):
|
||||
await inst.terminate()
|
||||
|
||||
async def terminate(self):
|
||||
for inst in self.platform_insts:
|
||||
if getattr(inst, "terminate", None):
|
||||
await inst.terminate()
|
||||
|
||||
def get_insts(self):
|
||||
return self.platform_insts
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import abc
|
||||
import uuid
|
||||
from typing import Awaitable, Any
|
||||
from asyncio import Queue
|
||||
from .platform_metadata import PlatformMetadata
|
||||
@@ -13,6 +14,7 @@ class Platform(abc.ABC):
|
||||
super().__init__()
|
||||
# 维护了消息平台的事件队列,EventBus 会从这里取出事件并处理。
|
||||
self._event_queue = event_queue
|
||||
self.client_self_id = uuid.uuid4().hex
|
||||
|
||||
@abc.abstractmethod
|
||||
def run(self) -> Awaitable[Any]:
|
||||
@@ -25,7 +27,7 @@ class Platform(abc.ABC):
|
||||
"""
|
||||
终止一个平台的运行实例。
|
||||
"""
|
||||
pass
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def meta(self) -> PlatformMetadata:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
|
||||
import typing
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import Group, MessageMember
|
||||
from astrbot.api.message_components import Plain, Image, Record, At, Node, Nodes
|
||||
from aiocqhttp import CQHttp
|
||||
from astrbot.core.utils.io import file_to_base64, download_image_by_url
|
||||
|
||||
|
||||
class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
@@ -24,18 +24,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
d["data"]["text"] = segment.text.strip()
|
||||
elif isinstance(segment, (Image, Record)):
|
||||
# convert to base64
|
||||
if segment.file and segment.file.startswith("file:///"):
|
||||
bs64_data = file_to_base64(segment.file[8:])
|
||||
image_file_path = segment.file[8:]
|
||||
elif segment.file and segment.file.startswith("http"):
|
||||
image_file_path = await download_image_by_url(segment.file)
|
||||
bs64_data = file_to_base64(image_file_path)
|
||||
elif segment.file and segment.file.startswith("base64://"):
|
||||
bs64_data = segment.file
|
||||
else:
|
||||
bs64_data = file_to_base64(segment.file)
|
||||
bs64 = await segment.convert_to_base64()
|
||||
d["data"] = {
|
||||
"file": bs64_data,
|
||||
"file": bs64,
|
||||
}
|
||||
elif isinstance(segment, At):
|
||||
d["data"] = {
|
||||
@@ -84,3 +75,46 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
await self.bot.send(self.message_obj.raw_message, ret)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def get_group(self, group_id=None, **kwargs):
|
||||
if isinstance(group_id, str) and group_id.isdigit():
|
||||
group_id = int(group_id)
|
||||
elif self.get_group_id():
|
||||
group_id = int(self.get_group_id())
|
||||
else:
|
||||
return None
|
||||
|
||||
info: dict = await self.bot.call_action(
|
||||
"get_group_info",
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
members: typing.List[typing.Dict] = await self.bot.call_action(
|
||||
"get_group_member_list",
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
owner_id = None
|
||||
admin_ids = []
|
||||
for member in members:
|
||||
if member["role"] == "owner":
|
||||
owner_id = member["user_id"]
|
||||
if member["role"] == "admin":
|
||||
admin_ids.append(member["user_id"])
|
||||
|
||||
group = Group(
|
||||
group_id=str(group_id),
|
||||
group_name=info.get("group_name"),
|
||||
group_avatar="",
|
||||
group_admins=admin_ids,
|
||||
group_owner=str(owner_id),
|
||||
members=[
|
||||
MessageMember(
|
||||
user_id=member["user_id"],
|
||||
nickname=member.get("nickname") or member.get("card"),
|
||||
)
|
||||
for member in members
|
||||
],
|
||||
)
|
||||
|
||||
return group
|
||||
|
||||
@@ -43,8 +43,6 @@ class AiocqhttpAdapter(Platform):
|
||||
"适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
|
||||
)
|
||||
|
||||
self.stop = False
|
||||
|
||||
self.bot = CQHttp(
|
||||
use_ws_reverse=True, import_name="aiocqhttp", api_timeout_sec=180
|
||||
)
|
||||
@@ -303,22 +301,19 @@ class AiocqhttpAdapter(Platform):
|
||||
for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
logging.getLogger("aiocqhttp").setLevel(logging.ERROR)
|
||||
|
||||
self.shutdown_event = asyncio.Event()
|
||||
return coro
|
||||
|
||||
async def terminate(self):
|
||||
self.stop = True
|
||||
await asyncio.sleep(1)
|
||||
self.shutdown_event.set()
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
await self.shutdown_event.wait()
|
||||
logger.info("aiocqhttp 适配器已被优雅地关闭")
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
# TODO: use asyncio.Event
|
||||
while not self._event_queue.closed and not self.stop: # noqa: ASYNC110
|
||||
await asyncio.sleep(1)
|
||||
logger.info("aiocqhttp 适配器已关闭。")
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = AiocqhttpMessageEvent(
|
||||
message_str=message.message_str,
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import uuid
|
||||
import aiohttp
|
||||
import dingtalk_stream
|
||||
import threading
|
||||
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
@@ -196,7 +197,31 @@ class DingtalkPlatformAdapter(Platform):
|
||||
self._event_queue.put_nowait(event)
|
||||
|
||||
async def run(self):
|
||||
await self.client_.start()
|
||||
# await self.client_.start()
|
||||
# 钉钉的 SDK 并没有实现真正的异步,start() 里面有堵塞方法。
|
||||
def start_client(loop: asyncio.AbstractEventLoop):
|
||||
try:
|
||||
self._shutdown_event = threading.Event()
|
||||
task = loop.create_task(self.client_.start())
|
||||
self._shutdown_event.wait()
|
||||
if task.done():
|
||||
task.result()
|
||||
except Exception as e:
|
||||
if "Graceful shutdown" in str(e):
|
||||
logger.info("钉钉适配器已被优雅地关闭")
|
||||
return
|
||||
logger.error(f"钉钉机器人启动失败: {e}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, start_client, loop)
|
||||
|
||||
async def terminate(self):
|
||||
def monkey_patch_close():
|
||||
raise Exception("Graceful shutdown")
|
||||
|
||||
self.client_.open_connection = monkey_patch_close
|
||||
await self.client_.websocket.close(code=1000, reason="Graceful shutdown")
|
||||
self._shutdown_event.set()
|
||||
|
||||
def get_client(self):
|
||||
return self.client
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import threading
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import quart
|
||||
import base64
|
||||
import datetime
|
||||
import re
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import aiohttp
|
||||
import anyio
|
||||
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
|
||||
from astrbot.api.message_components import Plain, Image, At, Record
|
||||
import quart
|
||||
|
||||
from astrbot.api import logger, sp
|
||||
from .downloader import GeweDownloader
|
||||
from astrbot.api.message_components import Plain, Image, At, Record, Video
|
||||
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from .downloader import GeweDownloader
|
||||
|
||||
try:
|
||||
from .xml_data_parser import GeweDataParser
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.warning(
|
||||
f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
class SimpleGewechatClient:
|
||||
@@ -51,11 +60,11 @@ class SimpleGewechatClient:
|
||||
|
||||
self.server = quart.Quart(__name__)
|
||||
self.server.add_url_rule(
|
||||
"/astrbot-gewechat/callback", view_func=self.callback, methods=["POST"]
|
||||
"/astrbot-gewechat/callback", view_func=self._callback, methods=["POST"]
|
||||
)
|
||||
self.server.add_url_rule(
|
||||
"/astrbot-gewechat/file/<file_id>",
|
||||
view_func=self.handle_file,
|
||||
view_func=self._handle_file,
|
||||
methods=["GET"],
|
||||
)
|
||||
|
||||
@@ -70,9 +79,10 @@ class SimpleGewechatClient:
|
||||
|
||||
self.userrealnames = {}
|
||||
|
||||
self.stop = False
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
async def get_token_id(self):
|
||||
"""获取 Gewechat Token。"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(f"{self.base_url}/tools/getTokenId") as resp:
|
||||
json_blob = await resp.json()
|
||||
@@ -192,6 +202,11 @@ class SimpleGewechatClient:
|
||||
abm.sender = MessageMember(user_id, user_real_name)
|
||||
abm.raw_message = d
|
||||
abm.message_str = ""
|
||||
|
||||
if user_id == "weixin":
|
||||
# 忽略微信团队消息
|
||||
return
|
||||
|
||||
# 不同消息类型
|
||||
match d["MsgType"]:
|
||||
case 1:
|
||||
@@ -209,15 +224,10 @@ class SimpleGewechatClient:
|
||||
|
||||
case 34:
|
||||
# 语音消息
|
||||
# data = await self.multimedia_downloader.download_voice(
|
||||
# self.appid,
|
||||
# content,
|
||||
# abm.message_id
|
||||
# )
|
||||
# print(data)
|
||||
if "ImgBuf" in d and "buffer" in d["ImgBuf"]:
|
||||
voice_data = base64.b64decode(d["ImgBuf"]["buffer"])
|
||||
file_path = f"data/temp/gewe_voice_{abm.message_id}.silk"
|
||||
|
||||
async with await anyio.open_file(file_path, "wb") as f:
|
||||
await f.write(voice_data)
|
||||
abm.message.append(Record(file=file_path, url=file_path))
|
||||
@@ -228,15 +238,19 @@ class SimpleGewechatClient:
|
||||
case 42: # 名片
|
||||
logger.info("消息类型(42):名片")
|
||||
case 43: # 视频
|
||||
logger.info("消息类型(43):视频")
|
||||
video = Video(file="", cover=content)
|
||||
abm.message.append(video)
|
||||
case 47: # emoji
|
||||
logger.info("消息类型(47):emoji")
|
||||
data_parser = GeweDataParser(content, abm.group_id == "")
|
||||
emoji = data_parser.parse_emoji()
|
||||
abm.message.append(emoji)
|
||||
case 48: # 地理位置
|
||||
logger.info("消息类型(48):地理位置")
|
||||
case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请
|
||||
logger.info(
|
||||
"消息类型(49):公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请"
|
||||
)
|
||||
data_parser = GeweDataParser(content, abm.group_id == "")
|
||||
abm_data = data_parser.parse_mutil_49()
|
||||
if abm_data:
|
||||
abm.message.append(abm_data)
|
||||
case 51: # 帐号消息同步?
|
||||
logger.info("消息类型(51):帐号消息同步?")
|
||||
case 10000: # 被踢出群聊/更换群主/修改群名称
|
||||
@@ -253,7 +267,7 @@ class SimpleGewechatClient:
|
||||
logger.debug(f"abm: {abm}")
|
||||
return abm
|
||||
|
||||
async def callback(self):
|
||||
async def _callback(self):
|
||||
data = await quart.request.json
|
||||
logger.debug(f"收到 gewechat 回调: {data}")
|
||||
|
||||
@@ -275,7 +289,7 @@ class SimpleGewechatClient:
|
||||
|
||||
return quart.jsonify({"r": "AstrBot ACK"})
|
||||
|
||||
async def handle_file(self, file_id):
|
||||
async def _handle_file(self, file_id):
|
||||
file_path = f"data/temp/{file_id}"
|
||||
return await quart.send_file(file_path)
|
||||
|
||||
@@ -301,17 +315,14 @@ class SimpleGewechatClient:
|
||||
await self.server.run_task(
|
||||
host="0.0.0.0",
|
||||
port=self.port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
shutdown_trigger=self.shutdown_trigger,
|
||||
)
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
# TODO: use asyncio.Event
|
||||
while not self.event_queue.closed and not self.stop: # noqa: ASYNC110
|
||||
await asyncio.sleep(1)
|
||||
logger.info("gewechat 适配器已关闭。")
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
async def check_online(self, appid: str):
|
||||
# /login/checkOnline
|
||||
"""检查 APPID 对应的设备是否在线。"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/login/checkOnline",
|
||||
@@ -322,6 +333,7 @@ class SimpleGewechatClient:
|
||||
return json_blob["data"]
|
||||
|
||||
async def logout(self):
|
||||
"""登出 gewechat。"""
|
||||
if self.appid:
|
||||
online = await self.check_online(self.appid)
|
||||
if online:
|
||||
@@ -335,6 +347,7 @@ class SimpleGewechatClient:
|
||||
logger.info(f"登出结果: {json_blob}")
|
||||
|
||||
async def login(self):
|
||||
"""登录 gewechat。一般来说插件用不到这个方法。"""
|
||||
if self.token is None:
|
||||
await self.get_token_id()
|
||||
|
||||
@@ -446,9 +459,18 @@ class SimpleGewechatClient:
|
||||
self.appid = appid
|
||||
logger.info(f"已保存 APPID: {appid}")
|
||||
|
||||
"""API"""
|
||||
"""API 部分。Gewechat 的 API 文档请参考: https://apifox.com/apidoc/shared/69ba62ca-cb7d-437e-85e4-6f3d3df271b1
|
||||
"""
|
||||
|
||||
async def get_chatroom_member_list(self, chatroom_wxid: str):
|
||||
async def get_chatroom_member_list(self, chatroom_wxid: str) -> dict:
|
||||
"""获取群成员列表。
|
||||
|
||||
Args:
|
||||
chatroom_wxid (str): 微信群聊的id。可以通过 event.get_group_id() 获取。
|
||||
|
||||
Returns:
|
||||
dict: 返回群成员列表字典。其中键为 memberList 的值为群成员列表。
|
||||
"""
|
||||
payload = {"appId": self.appid, "chatroomId": chatroom_wxid}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -461,6 +483,7 @@ class SimpleGewechatClient:
|
||||
return json_blob["data"]
|
||||
|
||||
async def post_text(self, to_wxid, content: str, ats: str = ""):
|
||||
"""发送纯文本消息"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
@@ -477,6 +500,7 @@ class SimpleGewechatClient:
|
||||
logger.debug(f"发送消息结果: {json_blob}")
|
||||
|
||||
async def post_image(self, to_wxid, image_url: str):
|
||||
"""发送图片消息"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
@@ -490,7 +514,79 @@ class SimpleGewechatClient:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"发送图片结果: {json_blob}")
|
||||
|
||||
async def post_emoji(self, to_wxid, emoji_md5, emoji_size, cdnurl=""):
|
||||
"""发送emoji消息"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
"emojiMd5": emoji_md5,
|
||||
"emojiSize": emoji_size,
|
||||
}
|
||||
|
||||
# 优先表情包,若拿不到表情包的md5,就用当作图片发
|
||||
try:
|
||||
if emoji_md5 != "" and emoji_size != "":
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/message/postEmoji",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.info(
|
||||
f"发送emoji消息结果: {json_blob.get('msg', '操作失败')}"
|
||||
)
|
||||
else:
|
||||
await self.post_image(to_wxid, cdnurl)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
async def post_video(
|
||||
self, to_wxid, video_url: str, thumb_url: str, video_duration: int
|
||||
):
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
"videoUrl": video_url,
|
||||
"thumbUrl": thumb_url,
|
||||
"videoDuration": video_duration,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/message/postVideo", headers=self.headers, json=payload
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"发送视频结果: {json_blob}")
|
||||
|
||||
async def forward_video(self, to_wxid, cnd_xml: str):
|
||||
"""转发视频
|
||||
|
||||
Args:
|
||||
to_wxid (str): 发送给谁
|
||||
cnd_xml (str): 视频消息的cdn信息
|
||||
"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
"xml": cnd_xml,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/message/forwardVideo",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"转发视频结果: {json_blob}")
|
||||
|
||||
async def post_voice(self, to_wxid, voice_url: str, voice_duration: int):
|
||||
"""发送语音信息
|
||||
|
||||
Args:
|
||||
voice_url (str): 语音文件的网络链接
|
||||
voice_duration (int): 语音时长,毫秒
|
||||
"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
@@ -505,9 +601,16 @@ class SimpleGewechatClient:
|
||||
f"{self.base_url}/message/postVoice", headers=self.headers, json=payload
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"发送语音结果: {json_blob}")
|
||||
logger.info(f"发送语音结果: {json_blob.get('msg', '操作失败')}")
|
||||
|
||||
async def post_file(self, to_wxid, file_url: str, file_name: str):
|
||||
"""发送文件
|
||||
|
||||
Args:
|
||||
to_wxid (string): 微信ID
|
||||
file_url (str): 文件的网络链接
|
||||
file_name (str): 文件名
|
||||
"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"toWxid": to_wxid,
|
||||
@@ -521,3 +624,114 @@ class SimpleGewechatClient:
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"发送文件结果: {json_blob}")
|
||||
|
||||
async def add_friend(self, v3: str, v4: str, content: str):
|
||||
"""申请添加好友"""
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"scene": 3,
|
||||
"content": content,
|
||||
"v4": v4,
|
||||
"v3": v3,
|
||||
"option": 2,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/contacts/addContacts",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"申请添加好友结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
async def get_group(self, group_id: str):
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"chatroomId": group_id,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/group/getChatroomInfo",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取群信息结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
async def get_group_member(self, group_id: str):
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"chatroomId": group_id,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/group/getChatroomMemberList",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取群信息结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
async def accept_group_invite(self, url: str):
|
||||
"""同意进群"""
|
||||
payload = {"appId": self.appid, "url": url}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/group/agreeJoinRoom",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取群信息结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
async def add_group_member_to_friend(
|
||||
self, group_id: str, to_wxid: str, content: str
|
||||
):
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"chatroomId": group_id,
|
||||
"content": content,
|
||||
"memberWxid": to_wxid,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/group/addGroupMemberAsFriend",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取群信息结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
async def get_user_or_group_info(self, *ids):
|
||||
"""
|
||||
获取用户或群组信息。
|
||||
|
||||
:param ids: 可变数量的 wxid 参数
|
||||
"""
|
||||
|
||||
wxids_str = list(ids)
|
||||
|
||||
payload = {
|
||||
"appId": self.appid,
|
||||
"wxids": wxids_str, # 使用逗号分隔的字符串
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/contacts/getDetailInfo",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取群信息结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
@@ -39,3 +39,17 @@ class GeweDownloader:
|
||||
continue
|
||||
|
||||
raise Exception("无法下载图片")
|
||||
|
||||
async def download_emoji_md5(self, app_id, emoji_md5):
|
||||
"""下载emoji"""
|
||||
try:
|
||||
payload = {"appId": app_id, "emojiMd5": emoji_md5}
|
||||
|
||||
# gewe 计划中的接口,暂时没有实现。返回代码404
|
||||
data = await self._post_json(
|
||||
self.base_url, "/message/downloadEmojiMd5", payload
|
||||
)
|
||||
json_blob = json.loads(data)
|
||||
return json_blob
|
||||
except BaseException as e:
|
||||
logger.error(f"gewe download emoji: {e}")
|
||||
|
||||
@@ -2,12 +2,21 @@ import wave
|
||||
import uuid
|
||||
import traceback
|
||||
import os
|
||||
from astrbot.core.utils.io import save_temp_img, download_image_by_url, download_file
|
||||
|
||||
from astrbot.core.utils.io import save_temp_img, download_file
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image, Record, At, File
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, Group, MessageMember
|
||||
from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Image,
|
||||
Record,
|
||||
At,
|
||||
File,
|
||||
Video,
|
||||
WechatEmoji as Emoji,
|
||||
)
|
||||
from .client import SimpleGewechatClient
|
||||
|
||||
|
||||
@@ -70,18 +79,10 @@ class GewechatPlatformEvent(AstrMessageEvent):
|
||||
await client.post_text(**payload)
|
||||
|
||||
elif isinstance(comp, Image):
|
||||
img_url = comp.file
|
||||
img_path = ""
|
||||
if img_url.startswith("file:///"):
|
||||
img_path = img_url[8:]
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
img_path = await download_image_by_url(comp.file)
|
||||
else:
|
||||
img_path = img_url
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
# 检查 record_path 是否在 data/temp 目录中, record_path 可能是绝对路径
|
||||
# 检查 record_path 是否在 data/temp 目录中
|
||||
temp_directory = os.path.abspath("data/temp")
|
||||
img_path = os.path.abspath(img_path)
|
||||
if os.path.commonpath([temp_directory, img_path]) != temp_directory:
|
||||
with open(img_path, "rb") as f:
|
||||
img_path = save_temp_img(f.read())
|
||||
@@ -90,17 +91,65 @@ class GewechatPlatformEvent(AstrMessageEvent):
|
||||
img_url = f"{client.file_server_url}/{file_id}"
|
||||
logger.debug(f"gewe callback img url: {img_url}")
|
||||
await client.post_image(to_wxid, img_url)
|
||||
elif isinstance(comp, Video):
|
||||
if comp.cover != "":
|
||||
await client.forward_video(to_wxid, comp.cover)
|
||||
else:
|
||||
try:
|
||||
from pyffmpeg import FFmpeg
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
logger.error(
|
||||
"需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg"
|
||||
)
|
||||
raise ModuleNotFoundError(
|
||||
"需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg"
|
||||
)
|
||||
|
||||
video_url = comp.file
|
||||
# 根据 url 下载视频
|
||||
video_filename = f"{uuid.uuid4()}.mp4"
|
||||
video_path = f"data/temp/{video_filename}"
|
||||
await download_file(video_url, video_path)
|
||||
|
||||
# 获取视频第一帧
|
||||
thumb_path = f"data/temp/{uuid.uuid4()}.jpg"
|
||||
try:
|
||||
ff = FFmpeg()
|
||||
command = f'-i "{video_path}" -ss 0 -vframes 1 "{thumb_path}"'
|
||||
ff.options(command)
|
||||
thumb_file_id = os.path.basename(thumb_path)
|
||||
thumb_url = f"{client.file_server_url}/{thumb_file_id}"
|
||||
except Exception as e:
|
||||
logger.error(f"获取视频第一帧失败: {e}")
|
||||
# 获取视频时长
|
||||
try:
|
||||
from pyffmpeg import FFprobe
|
||||
|
||||
# 创建 FFprobe 实例
|
||||
ffprobe = FFprobe(video_url)
|
||||
# 获取时长字符串
|
||||
duration_str = ffprobe.duration
|
||||
# 处理时长字符串
|
||||
video_duration = float(duration_str.replace(":", ""))
|
||||
except Exception as e:
|
||||
logger.error(f"获取时长失败: {e}")
|
||||
video_duration = 10
|
||||
|
||||
file_id = os.path.basename(video_path)
|
||||
video_url = f"{client.file_server_url}/{file_id}"
|
||||
await client.post_video(
|
||||
to_wxid, video_url, thumb_url, video_duration
|
||||
)
|
||||
|
||||
# 删除临时视频和缩略图文件
|
||||
if os.path.exists(video_path):
|
||||
os.remove(video_path)
|
||||
if os.path.exists(thumb_path):
|
||||
os.remove(thumb_path)
|
||||
elif isinstance(comp, Record):
|
||||
# 默认已经存在 data/temp 中
|
||||
record_url = comp.file
|
||||
record_path = ""
|
||||
|
||||
if record_url.startswith("file:///"):
|
||||
record_path = record_url[8:]
|
||||
elif record_url.startswith("http"):
|
||||
await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav")
|
||||
else:
|
||||
record_path = record_url
|
||||
record_path = await comp.convert_to_file_path()
|
||||
|
||||
silk_path = f"data/temp/{uuid.uuid4()}.silk"
|
||||
try:
|
||||
@@ -129,6 +178,8 @@ class GewechatPlatformEvent(AstrMessageEvent):
|
||||
file_url = f"{client.file_server_url}/{file_id}"
|
||||
logger.debug(f"gewe callback file url: {file_url}")
|
||||
await client.post_file(to_wxid, file_url, file_id)
|
||||
elif isinstance(comp, Emoji):
|
||||
await client.post_emoji(to_wxid, comp.md5, comp.md5_len, comp.cdnurl)
|
||||
elif isinstance(comp, At):
|
||||
pass
|
||||
else:
|
||||
@@ -138,3 +189,30 @@ class GewechatPlatformEvent(AstrMessageEvent):
|
||||
to_wxid = self.message_obj.raw_message.get("to_wxid", None)
|
||||
await GewechatPlatformEvent.send_with_client(message, to_wxid, self.client)
|
||||
await super().send(message)
|
||||
|
||||
async def get_group(self, group_id=None, **kwargs):
|
||||
# 确定有效的 group_id
|
||||
if group_id is None:
|
||||
group_id = self.get_group_id()
|
||||
|
||||
if not group_id:
|
||||
return None
|
||||
|
||||
res = await self.client.get_group(group_id)
|
||||
data: dict = res["data"]
|
||||
|
||||
if not data["chatroomId"]:
|
||||
return None
|
||||
|
||||
members = [
|
||||
MessageMember(user_id=member["wxid"], nickname=member["nickName"])
|
||||
for member in data.get("memberList", [])
|
||||
]
|
||||
|
||||
return Group(
|
||||
group_id=data["chatroomId"],
|
||||
group_name=data.get("nickName"),
|
||||
group_avatar=data.get("smallHeadImgUrl"),
|
||||
group_owner=data.get("chatRoomOwner"),
|
||||
members=members,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from ...register import register_platform_adapter
|
||||
from .gewechat_event import GewechatPlatformEvent
|
||||
from .client import SimpleGewechatClient
|
||||
from astrbot import logger
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
@@ -64,8 +65,9 @@ class GewechatPlatformAdapter(Platform):
|
||||
)
|
||||
|
||||
async def terminate(self):
|
||||
self.client.stop = True
|
||||
await asyncio.sleep(1)
|
||||
self.client.shutdown_event.set()
|
||||
await self.client.server.shutdown()
|
||||
logger.info("Gewechat 适配器已被优雅地关闭。")
|
||||
|
||||
async def logout(self):
|
||||
await self.client.logout()
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from defusedxml import ElementTree as eT
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.message_components import WechatEmoji as Emoji, Reply, Plain
|
||||
|
||||
|
||||
class GeweDataParser:
|
||||
def __init__(self, data, is_private_chat):
|
||||
self.data = data
|
||||
self.is_private_chat = is_private_chat
|
||||
|
||||
def _format_to_xml(self):
|
||||
return eT.fromstring(self.data)
|
||||
|
||||
def parse_mutil_49(self):
|
||||
appmsg_type = self._format_to_xml().find(".//appmsg/type")
|
||||
if appmsg_type is None:
|
||||
return
|
||||
|
||||
match appmsg_type.text:
|
||||
case "57":
|
||||
return self.parse_reply()
|
||||
|
||||
def parse_emoji(self) -> Emoji | None:
|
||||
try:
|
||||
emoji_element = self._format_to_xml().find(".//emoji")
|
||||
# 提取 md5 和 len 属性
|
||||
if emoji_element is not None:
|
||||
md5_value = emoji_element.get("md5")
|
||||
emoji_size = emoji_element.get("len")
|
||||
cdnurl = emoji_element.get("cdnurl")
|
||||
|
||||
return Emoji(md5=md5_value, md5_len=emoji_size, cdnurl=cdnurl)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"gewechat: parse_emoji failed, {e}")
|
||||
|
||||
def parse_reply(self) -> Reply | None:
|
||||
try:
|
||||
replied_id = -1
|
||||
replied_uid = 0
|
||||
replied_nickname = ""
|
||||
replied_content = ""
|
||||
content = ""
|
||||
|
||||
root = self._format_to_xml()
|
||||
refermsg = root.find(".//refermsg")
|
||||
if refermsg is not None:
|
||||
# 被引用的信息
|
||||
svrid = refermsg.find("svrid")
|
||||
fromusr = refermsg.find("fromusr")
|
||||
displayname = refermsg.find("displayname")
|
||||
refermsg_content = refermsg.find("content")
|
||||
if svrid is not None:
|
||||
replied_id = svrid.text
|
||||
if fromusr is not None:
|
||||
replied_uid = fromusr.text
|
||||
if displayname is not None:
|
||||
replied_nickname = displayname.text
|
||||
if refermsg_content is not None:
|
||||
replied_content = refermsg_content.text
|
||||
|
||||
# 提取引用者说的内容
|
||||
title = root.find(".//appmsg/title")
|
||||
if title is not None:
|
||||
content = title.text
|
||||
|
||||
r = Reply(
|
||||
id=replied_id,
|
||||
chain=[Plain(content)],
|
||||
sender_id=replied_uid,
|
||||
sender_nickname=replied_nickname,
|
||||
sender_str=replied_content,
|
||||
message_str=content,
|
||||
)
|
||||
return r
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"gewechat: parse_reply failed, {e}")
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import astrbot.api.message_components as Comp
|
||||
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
@@ -11,7 +12,6 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import Image, Plain, At
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from .lark_event import LarkMessageEvent
|
||||
from ...register import register_platform_adapter
|
||||
@@ -92,7 +92,7 @@ class LarkPlatformAdapter(Platform):
|
||||
at_list = {}
|
||||
if message.mentions:
|
||||
for m in message.mentions:
|
||||
at_list[m.key] = At(qq=m.id.open_id, name=m.name)
|
||||
at_list[m.key] = Comp.At(qq=m.id.open_id, name=m.name)
|
||||
if m.name == self.bot_name:
|
||||
abm.self_id = m.id.open_id
|
||||
|
||||
@@ -111,7 +111,7 @@ class LarkPlatformAdapter(Platform):
|
||||
if s in at_list:
|
||||
abm.message.append(at_list[s])
|
||||
else:
|
||||
abm.message.append(Plain(parts[i].strip()))
|
||||
abm.message.append(Comp.Plain(parts[i].strip()))
|
||||
elif message.message_type == "post":
|
||||
_ls = []
|
||||
|
||||
@@ -132,7 +132,7 @@ class LarkPlatformAdapter(Platform):
|
||||
if comp["tag"] == "at":
|
||||
abm.message.append(at_list[comp["user_id"]])
|
||||
elif comp["tag"] == "text" and comp["text"].strip():
|
||||
abm.message.append(Plain(comp["text"].strip()))
|
||||
abm.message.append(Comp.Plain(comp["text"].strip()))
|
||||
elif comp["tag"] == "img":
|
||||
image_key = comp["image_key"]
|
||||
request = (
|
||||
@@ -147,10 +147,10 @@ class LarkPlatformAdapter(Platform):
|
||||
logger.error(f"无法下载飞书图片: {image_key}")
|
||||
image_bytes = response.file.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode()
|
||||
abm.message.append(Image.fromBase64(image_base64))
|
||||
abm.message.append(Comp.Image.fromBase64(image_base64))
|
||||
|
||||
for comp in abm.message:
|
||||
if isinstance(comp, Plain):
|
||||
if isinstance(comp, Comp.Plain):
|
||||
abm.message_str += comp.text
|
||||
abm.message_id = message.message_id
|
||||
abm.raw_message = message
|
||||
@@ -185,5 +185,9 @@ class LarkPlatformAdapter(Platform):
|
||||
# self.client.start()
|
||||
await self.client._connect()
|
||||
|
||||
async def terminate(self):
|
||||
await self.client._disconnect()
|
||||
logger.info("飞书(Lark) 适配器已被优雅地关闭")
|
||||
|
||||
def get_client(self) -> lark.Client:
|
||||
return self.client
|
||||
|
||||
@@ -17,6 +17,7 @@ from astrbot.api.platform import (
|
||||
MessageType,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from typing import Union, List
|
||||
from astrbot.api.message_components import Image, Plain, At
|
||||
@@ -204,3 +205,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
|
||||
def get_client(self) -> botClient:
|
||||
return self.client
|
||||
|
||||
async def terminate(self):
|
||||
await self.client.close()
|
||||
logger.info("QQ 官方机器人接口 适配器已被优雅地关闭")
|
||||
|
||||
@@ -13,6 +13,7 @@ from .qo_webhook_event import QQOfficialWebhookMessageEvent
|
||||
from ...register import register_platform_adapter
|
||||
from .qo_webhook_server import QQOfficialWebhook
|
||||
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
|
||||
from astrbot import logger
|
||||
|
||||
# remove logger handler
|
||||
for handler in logging.root.handlers[:]:
|
||||
@@ -111,3 +112,9 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
|
||||
def get_client(self) -> botClient:
|
||||
return self.client
|
||||
|
||||
async def terminate(self):
|
||||
self.webhook_helper.shutdown_event.set()
|
||||
await self.client.close()
|
||||
await self.webhook_helper.server.shutdown()
|
||||
logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭")
|
||||
|
||||
@@ -30,6 +30,7 @@ class QQOfficialWebhook:
|
||||
)
|
||||
self.client = botpy_client
|
||||
self.event_queue = event_queue
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
async def initialize(self):
|
||||
logger.info("正在登录到 QQ 官方机器人...")
|
||||
@@ -102,10 +103,8 @@ class QQOfficialWebhook:
|
||||
await self.server.run_task(
|
||||
host=self.callback_server_host,
|
||||
port=self.port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
shutdown_trigger=self.shutdown_trigger,
|
||||
)
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
while not self.event_queue.closed: # noqa: ASYNC110
|
||||
await asyncio.sleep(1)
|
||||
logger.info("qq_official_webhook 适配器已关闭。")
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
import uuid
|
||||
import asyncio
|
||||
import astrbot.api.message_components as Comp
|
||||
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
@@ -10,15 +11,6 @@ from astrbot.api.platform import (
|
||||
MessageType,
|
||||
)
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Image,
|
||||
Record,
|
||||
File as AstrBotFile,
|
||||
Video,
|
||||
At,
|
||||
Reply,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.api.platform import register_platform_adapter
|
||||
|
||||
@@ -108,7 +100,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
logger.debug(f"Telegram message: {update.message}")
|
||||
abm = await self.convert_message(update, context)
|
||||
await self.handle_msg(abm)
|
||||
if abm:
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def convert_message(
|
||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE, get_reply=True
|
||||
@@ -120,6 +113,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
|
||||
"""
|
||||
message = AstrBotMessage()
|
||||
message.session_id = str(update.message.chat.id)
|
||||
# 获得是群聊还是私聊
|
||||
if update.message.chat.type == ChatType.PRIVATE:
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
@@ -129,9 +123,9 @@ class TelegramPlatformAdapter(Platform):
|
||||
if update.message.message_thread_id:
|
||||
# Topic Group
|
||||
message.group_id += "#" + str(update.message.message_thread_id)
|
||||
message.session_id = message.group_id
|
||||
|
||||
message.message_id = str(update.message.message_id)
|
||||
message.session_id = str(update.message.chat.id)
|
||||
message.sender = MessageMember(
|
||||
str(update.message.from_user.id), update.message.from_user.username
|
||||
)
|
||||
@@ -140,7 +134,11 @@ class TelegramPlatformAdapter(Platform):
|
||||
message.message_str = ""
|
||||
message.message = []
|
||||
|
||||
if update.message.reply_to_message:
|
||||
if update.message.reply_to_message and not (
|
||||
update.message.is_topic_message
|
||||
and update.message.message_thread_id
|
||||
== update.message.reply_to_message.message_id
|
||||
):
|
||||
# 获取回复消息
|
||||
reply_update = Update(
|
||||
update_id=1,
|
||||
@@ -149,7 +147,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
reply_abm = await self.convert_message(reply_update, context, False)
|
||||
|
||||
message.message.append(
|
||||
Reply(
|
||||
Comp.Reply(
|
||||
id=reply_abm.message_id,
|
||||
chain=reply_abm.message,
|
||||
sender_id=reply_abm.sender.user_id,
|
||||
@@ -171,43 +169,60 @@ class TelegramPlatformAdapter(Platform):
|
||||
name = plain_text[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(At(qq=name, name=name))
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
plain_text = (
|
||||
plain_text[: entity.offset]
|
||||
+ plain_text[entity.offset + entity.length :]
|
||||
)
|
||||
|
||||
if plain_text:
|
||||
message.message.append(Plain(plain_text))
|
||||
message.message.append(Comp.Plain(plain_text))
|
||||
message.message_str = plain_text
|
||||
|
||||
if message.message_str == "/start":
|
||||
if message.message_str.strip() == "/start":
|
||||
await self.start(update, context)
|
||||
return
|
||||
|
||||
elif update.message.voice:
|
||||
file = await update.message.voice.get_file()
|
||||
message.message = [
|
||||
Record(file=file.file_path, url=file.file_path),
|
||||
Comp.Record(file=file.file_path, url=file.file_path),
|
||||
]
|
||||
|
||||
elif update.message.photo:
|
||||
photo = update.message.photo[-1] # get the largest photo
|
||||
file = await photo.get_file()
|
||||
message.message.append(Image(file=file.file_path, url=file.file_path))
|
||||
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = message.message_str[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
|
||||
elif update.message.sticker:
|
||||
# 将sticker当作图片处理
|
||||
file = await update.message.sticker.get_file()
|
||||
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
|
||||
if update.message.sticker.emoji:
|
||||
sticker_text = f"Sticker: {update.message.sticker.emoji}"
|
||||
message.message_str = sticker_text
|
||||
message.message.append(Comp.Plain(sticker_text))
|
||||
|
||||
elif update.message.document:
|
||||
file = await update.message.document.get_file()
|
||||
message.message = [
|
||||
AstrBotFile(
|
||||
file=file.file_path, name=update.message.document.file_name
|
||||
),
|
||||
Comp.File(file=file.file_path, name=update.message.document.file_name),
|
||||
]
|
||||
|
||||
elif update.message.video:
|
||||
file = await update.message.video.get_file()
|
||||
message.message = [
|
||||
Video(file=file.file_path, path=file.file_path),
|
||||
Comp.Video(file=file.file_path, path=file.file_path),
|
||||
]
|
||||
|
||||
return message
|
||||
@@ -224,3 +239,15 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
def get_client(self) -> ExtBot:
|
||||
return self.client
|
||||
|
||||
async def terminate(self):
|
||||
try:
|
||||
await self.application.stop()
|
||||
|
||||
# 保险起见先判断是否存在updater对象
|
||||
if self.application.updater is not None:
|
||||
await self.application.updater.stop()
|
||||
|
||||
logger.info("Telegram 适配器已被优雅地关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram 适配器关闭时出错: {e}")
|
||||
|
||||
@@ -43,7 +43,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if has_reply:
|
||||
payload["reply_to_message_id"] = reply_message_id
|
||||
if message_thread_id:
|
||||
payload["reply_to_message_id"] = message_thread_id
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
||||
if isinstance(i, Plain):
|
||||
if at_user_id and not at_flag:
|
||||
@@ -51,19 +51,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
at_flag = True
|
||||
await client.send_message(text=i.text, **payload)
|
||||
elif isinstance(i, Image):
|
||||
if i.path:
|
||||
image_path = i.path
|
||||
else:
|
||||
image_path = i.file
|
||||
|
||||
if image_path.startswith("base64://"):
|
||||
import base64
|
||||
|
||||
base64_data = image_path[9:]
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
await client.send_photo(photo=image_bytes, **payload)
|
||||
else:
|
||||
await client.send_photo(photo=image_path, **payload)
|
||||
image_path = await i.convert_to_file_path()
|
||||
await client.send_photo(photo=image_path, **payload)
|
||||
elif isinstance(i, File):
|
||||
if i.file.startswith("https://"):
|
||||
path = "data/temp/" + i.name
|
||||
@@ -72,7 +61,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
await client.send_document(document=i.file, filename=i.name, **payload)
|
||||
elif isinstance(i, Record):
|
||||
await client.send_voice(voice=i.file, **payload)
|
||||
path = await i.convert_to_file_path()
|
||||
await client.send_voice(voice=path, **payload)
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
|
||||
@@ -119,3 +119,7 @@ class WebChatAdapter(Platform):
|
||||
)
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
async def terminate(self):
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
@@ -50,6 +50,7 @@ class WecomServer:
|
||||
)
|
||||
|
||||
self.callback = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
async def verify(self):
|
||||
logger.info(f"验证请求有效性: {quart.request.args}")
|
||||
@@ -93,13 +94,11 @@ class WecomServer:
|
||||
await self.server.run_task(
|
||||
host=self.callback_server_host,
|
||||
port=self.port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
shutdown_trigger=self.shutdown_trigger,
|
||||
)
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
while not self.event_queue.closed: # noqa: ASYNC110
|
||||
await asyncio.sleep(1)
|
||||
logger.info("企业微信 适配器已关闭。")
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
|
||||
@register_platform_adapter("wecom", "wecom 适配器")
|
||||
@@ -235,3 +234,8 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
def get_client(self) -> WeChatClient:
|
||||
return self.client
|
||||
|
||||
async def terminate(self):
|
||||
self.server.shutdown_event.set()
|
||||
await self.server.server.shutdown()
|
||||
logger.info("企业微信 适配器已被优雅地关闭")
|
||||
|
||||
@@ -3,7 +3,6 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
from astrbot.core.utils.io import download_image_by_url, download_file
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
@@ -43,14 +42,7 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
message_obj.self_id, message_obj.session_id, comp.text
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
img_url = comp.file
|
||||
img_path = ""
|
||||
if img_url.startswith("file:///"):
|
||||
img_path = img_url[8:]
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
img_path = await download_image_by_url(comp.file)
|
||||
else:
|
||||
img_path = img_url
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
with open(img_path, "rb") as f:
|
||||
try:
|
||||
@@ -68,16 +60,7 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
response["media_id"],
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
record_url = comp.file
|
||||
record_path = ""
|
||||
|
||||
if record_url.startswith("file:///"):
|
||||
record_path = record_url[8:]
|
||||
elif record_url.startswith("http"):
|
||||
await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav")
|
||||
else:
|
||||
record_path = record_url
|
||||
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import enum
|
||||
import base64
|
||||
import json
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot import logger
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Type
|
||||
from .func_tool_manager import FuncCall
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
)
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
import astrbot.core.message.components as Comp
|
||||
|
||||
|
||||
class ProviderType(enum.Enum):
|
||||
@@ -28,6 +36,58 @@ class ProviderMetaData:
|
||||
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallMessageSegment:
|
||||
"""OpenAI 格式的上下文中 role 为 tool 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
|
||||
|
||||
tool_call_id: str
|
||||
content: str
|
||||
role: str = "tool"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"content": self.content,
|
||||
"role": self.role,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistantMessageSegment:
|
||||
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
|
||||
|
||||
content: str = None
|
||||
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
|
||||
role: str = "assistant"
|
||||
|
||||
def to_dict(self):
|
||||
ret = {
|
||||
"role": self.role,
|
||||
}
|
||||
if self.content:
|
||||
ret["content"] = self.content
|
||||
elif self.tool_calls:
|
||||
ret["tool_calls"] = self.tool_calls
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallsResult:
|
||||
"""工具调用结果"""
|
||||
|
||||
tool_calls_info: AssistantMessageSegment
|
||||
"""函数调用的信息"""
|
||||
tool_calls_result: List[ToolCallMessageSegment]
|
||||
"""函数调用的结果"""
|
||||
|
||||
def to_openai_messages(self) -> List[Dict]:
|
||||
ret = [
|
||||
self.tool_calls_info.to_dict(),
|
||||
*[item.to_dict() for item in self.tool_calls_result],
|
||||
]
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderRequest:
|
||||
prompt: str
|
||||
@@ -37,7 +97,7 @@ class ProviderRequest:
|
||||
image_urls: List[str] = None
|
||||
"""图片 URL 列表"""
|
||||
func_tool: FuncCall = None
|
||||
"""工具"""
|
||||
"""可用的函数工具"""
|
||||
contexts: List = None
|
||||
"""上下文。格式与 openai 的上下文格式一致:
|
||||
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
|
||||
@@ -46,12 +106,85 @@ class ProviderRequest:
|
||||
"""系统提示词"""
|
||||
conversation: Conversation = None
|
||||
|
||||
tool_calls_result: ToolCallsResult = None
|
||||
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self.contexts}, system_prompt={self.system_prompt.strip()})"
|
||||
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
def _print_friendly_context(self):
|
||||
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
|
||||
if not self.contexts:
|
||||
return f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}"
|
||||
|
||||
result_parts = []
|
||||
|
||||
for ctx in self.contexts:
|
||||
role = ctx.get("role", "unknown")
|
||||
content = ctx.get("content", "")
|
||||
|
||||
if isinstance(content, str):
|
||||
result_parts.append(f"{role}: {content}")
|
||||
elif isinstance(content, list):
|
||||
msg_parts = []
|
||||
image_count = 0
|
||||
|
||||
for item in content:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
if item_type == "text":
|
||||
msg_parts.append(item.get("text", ""))
|
||||
elif item_type == "image_url":
|
||||
image_count += 1
|
||||
|
||||
if image_count > 0:
|
||||
if msg_parts:
|
||||
msg_parts.append(f"[+{image_count} images]")
|
||||
else:
|
||||
msg_parts.append(f"[{image_count} images]")
|
||||
|
||||
result_parts.append(f"{role}: {''.join(msg_parts)}")
|
||||
|
||||
return result_parts
|
||||
|
||||
async def assemble_context(self) -> Dict:
|
||||
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
|
||||
if self.image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": self.prompt}],
|
||||
}
|
||||
for image_url in self.image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self._encode_image_bs64(image_path)
|
||||
elif image_url.startswith("file:///"):
|
||||
image_path = image_url.replace("file:///", "")
|
||||
image_data = await self._encode_image_bs64(image_path)
|
||||
else:
|
||||
image_data = await self._encode_image_bs64(image_url)
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
user_content["content"].append(
|
||||
{"type": "image_url", "image_url": {"url": image_data}}
|
||||
)
|
||||
return user_content
|
||||
else:
|
||||
return {"role": "user", "content": self.prompt}
|
||||
|
||||
async def _encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
if image_url.startswith("base64://"):
|
||||
return image_url.replace("base64://", "data:image/jpeg;base64,")
|
||||
with open(image_url, "rb") as f:
|
||||
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
return "data:image/jpeg;base64," + image_bs64
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
@@ -59,12 +192,78 @@ class LLMResponse:
|
||||
"""角色, assistant, tool, err"""
|
||||
result_chain: MessageChain = None
|
||||
"""返回的消息链"""
|
||||
completion_text: str = ""
|
||||
"""LLM 返回的文本, 已经废弃但仍然兼容。使用 result_chain 替代"""
|
||||
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
|
||||
"""工具调用参数"""
|
||||
tools_call_name: List[str] = field(default_factory=list)
|
||||
"""工具调用名称"""
|
||||
tools_call_ids: List[str] = field(default_factory=list)
|
||||
"""工具调用 ID"""
|
||||
|
||||
raw_completion: ChatCompletion = None
|
||||
_new_record: Dict[str, any] = None
|
||||
|
||||
_completion_text: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
completion_text: str = "",
|
||||
result_chain: MessageChain = None,
|
||||
tools_call_args: List[Dict[str, any]] = [],
|
||||
tools_call_name: List[str] = [],
|
||||
tools_call_ids: List[str] = [],
|
||||
raw_completion: ChatCompletion = None,
|
||||
_new_record: Dict[str, any] = None,
|
||||
):
|
||||
"""初始化 LLMResponse
|
||||
|
||||
Args:
|
||||
role (str): 角色, assistant, tool, err
|
||||
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
|
||||
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
|
||||
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
|
||||
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
|
||||
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
|
||||
"""
|
||||
self.role = role
|
||||
self.completion_text = completion_text
|
||||
self.result_chain = result_chain
|
||||
self.tools_call_args = tools_call_args
|
||||
self.tools_call_name = tools_call_name
|
||||
self.tools_call_ids = tools_call_ids
|
||||
self.raw_completion = raw_completion
|
||||
self._new_record = _new_record
|
||||
|
||||
@property
|
||||
def completion_text(self):
|
||||
if self.result_chain:
|
||||
return self.result_chain.get_plain_text()
|
||||
return self._completion_text
|
||||
|
||||
@completion_text.setter
|
||||
def completion_text(self, value):
|
||||
if self.result_chain:
|
||||
self.result_chain.chain = [
|
||||
comp
|
||||
for comp in self.result_chain.chain
|
||||
if not isinstance(comp, Comp.Plain)
|
||||
] # 清空 Plain 组件
|
||||
self.result_chain.chain.insert(0, Comp.Plain(value))
|
||||
else:
|
||||
self._completion_text = value
|
||||
|
||||
def to_openai_tool_calls(self) -> List[Dict]:
|
||||
"""将工具调用信息转换为 OpenAI 格式"""
|
||||
ret = []
|
||||
for idx, tool_call_arg in enumerate(self.tools_call_args):
|
||||
ret.append(
|
||||
{
|
||||
"id": self.tools_call_ids[idx],
|
||||
"function": {
|
||||
"name": self.tools_call_name[idx],
|
||||
"arguments": json.dumps(tool_call_arg),
|
||||
},
|
||||
"type": "function",
|
||||
}
|
||||
)
|
||||
return ret
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import textwrap
|
||||
from typing import Dict, List, Awaitable
|
||||
import os
|
||||
import asyncio
|
||||
import copy
|
||||
|
||||
from typing import Dict, List, Awaitable, Literal, Any
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from contextlib import AsyncExitStack
|
||||
from astrbot import logger
|
||||
|
||||
try:
|
||||
import mcp
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
"object",
|
||||
"array",
|
||||
"boolean",
|
||||
] # json schema 支持的数据类型
|
||||
|
||||
|
||||
@dataclass
|
||||
class FuncTool:
|
||||
@@ -14,28 +36,101 @@ class FuncTool:
|
||||
name: str
|
||||
parameters: Dict
|
||||
description: str
|
||||
handler: Awaitable
|
||||
handler_module_path: str = None # 必须要保留这个,handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
|
||||
handler: Awaitable = None
|
||||
"""处理函数, 当 origin 为 mcp 时,这个为空"""
|
||||
handler_module_path: str = None
|
||||
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
|
||||
|
||||
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
|
||||
"""
|
||||
active: bool = True
|
||||
"""是否激活"""
|
||||
|
||||
origin: Literal["local", "mcp"] = "local"
|
||||
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
|
||||
|
||||
# MCP 相关字段
|
||||
mcp_server_name: str = None
|
||||
"""MCP 服务名称,当 origin 为 mcp 时有效"""
|
||||
mcp_client: MCPClient = None
|
||||
"""MCP 客户端,当 origin 为 mcp 时有效"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}), active={self.active})"
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
|
||||
|
||||
async def execute(self, **args) -> Any:
|
||||
"""执行函数调用"""
|
||||
if self.origin == "local":
|
||||
if not self.handler:
|
||||
raise Exception(f"Local function {self.name} has no handler")
|
||||
return await self.handler(**args)
|
||||
elif self.origin == "mcp":
|
||||
if not self.mcp_client or not self.mcp_client.session:
|
||||
raise Exception(f"MCP client for {self.name} is not available")
|
||||
# 使用name属性而不是额外的mcp_tool_name
|
||||
if ":" in self.name:
|
||||
# 如果名字是格式为 mcp:server:tool_name,提取实际的工具名
|
||||
actual_tool_name = self.name.split(":")[-1]
|
||||
return await self.mcp_client.session.call_tool(actual_tool_name, args)
|
||||
else:
|
||||
return await self.mcp_client.session.call_tool(self.name, args)
|
||||
else:
|
||||
raise Exception(f"Unknown function origin: {self.origin}")
|
||||
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
"object",
|
||||
"array",
|
||||
"boolean",
|
||||
] # json schema 支持的数据类型
|
||||
class MCPClient:
|
||||
def __init__(self):
|
||||
# Initialize session and client objects
|
||||
self.session: Optional[mcp.ClientSession] = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
self.name = None
|
||||
self.active: bool = True
|
||||
self.tools: List[mcp.Tool] = []
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict):
|
||||
"""Connect to an MCP server
|
||||
|
||||
Args:
|
||||
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
|
||||
"""
|
||||
cfg = mcp_server_config.copy()
|
||||
cfg.pop("active", None)
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(server_params)
|
||||
)
|
||||
self.stdio, self.write = stdio_transport
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(self.stdio, self.write)
|
||||
)
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
async def list_tools_and_save(self) -> mcp.ListToolsResult:
|
||||
"""List all tools from the server and save them to self.tools"""
|
||||
response = await self.session.list_tools()
|
||||
logger.debug(f"MCP server {self.name} list tools response: {response}")
|
||||
self.tools = response.tools
|
||||
return response
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up resources"""
|
||||
await self.exit_stack.aclose()
|
||||
|
||||
|
||||
class FuncCall:
|
||||
def __init__(self) -> None:
|
||||
self.func_list: List[FuncTool] = []
|
||||
"""内部加载的 func tools"""
|
||||
self.mcp_client_dict: Dict[str, MCPClient] = {}
|
||||
"""MCP 服务列表"""
|
||||
self.mcp_service_queue = asyncio.Queue()
|
||||
"""用于外部控制 MCP 服务的启停"""
|
||||
self.mcp_client_event: Dict[str, asyncio.Event] = {}
|
||||
|
||||
def empty(self) -> bool:
|
||||
return len(self.func_list) == 0
|
||||
@@ -90,11 +185,166 @@ class FuncCall:
|
||||
return f
|
||||
return None
|
||||
|
||||
async def _init_mcp_clients(self) -> None:
|
||||
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
|
||||
```
|
||||
{
|
||||
"mcpServers": {
|
||||
"weather": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather",
|
||||
"run",
|
||||
"weather.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
"""
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
|
||||
|
||||
mcp_json_file = os.path.join(data_dir, "mcp_server.json")
|
||||
if not os.path.exists(mcp_json_file):
|
||||
# 配置文件不存在错误处理
|
||||
with open(mcp_json_file, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
|
||||
return
|
||||
|
||||
mcp_server_json_obj: Dict[str, Dict] = json.load(
|
||||
open(mcp_json_file, "r", encoding="utf-8")
|
||||
)["mcpServers"]
|
||||
|
||||
for name in mcp_server_json_obj.keys():
|
||||
cfg = mcp_server_json_obj[name]
|
||||
if cfg.get("active", True):
|
||||
event = asyncio.Event()
|
||||
asyncio.create_task(
|
||||
self._init_mcp_client_task_wrapper(name, cfg, event)
|
||||
)
|
||||
self.mcp_client_event[name] = event
|
||||
|
||||
async def mcp_service_selector(self):
|
||||
"""为了避免在不同异步任务中控制 MCP 服务导致的报错,整个项目统一通过这个 Task 来控制
|
||||
|
||||
使用 self.mcp_service_queue.put_nowait() 来控制 MCP 服务的启停,数据格式如下:
|
||||
|
||||
{"type": "init"} 初始化所有MCP客户端
|
||||
|
||||
{"type": "init", "name": "mcp_server_name", "cfg": {...}} 初始化指定的MCP客户端
|
||||
|
||||
{"type": "terminate"} 终止所有MCP客户端
|
||||
|
||||
{"type": "terminate", "name": "mcp_server_name"} 终止指定的MCP客户端
|
||||
"""
|
||||
while True:
|
||||
data = await self.mcp_service_queue.get()
|
||||
if data["type"] == "init":
|
||||
if "name" in data:
|
||||
event = asyncio.Event()
|
||||
asyncio.create_task(
|
||||
self._init_mcp_client_task_wrapper(
|
||||
data["name"], data["cfg"], event
|
||||
)
|
||||
)
|
||||
self.mcp_client_event[data["name"]] = event
|
||||
else:
|
||||
await self._init_mcp_clients()
|
||||
elif data["type"] == "terminate":
|
||||
if "name" in data:
|
||||
# await self._terminate_mcp_client(data["name"])
|
||||
if data["name"] in self.mcp_client_event:
|
||||
self.mcp_client_event[data["name"]].set()
|
||||
self.mcp_client_event.pop(data["name"], None)
|
||||
else:
|
||||
for name in self.mcp_client_dict.keys():
|
||||
# await self._terminate_mcp_client(name)
|
||||
# self.mcp_client_event[name].set()
|
||||
if name in self.mcp_client_event:
|
||||
self.mcp_client_event[name].set()
|
||||
self.mcp_client_event.pop(name, None)
|
||||
|
||||
async def _init_mcp_client_task_wrapper(
|
||||
self, name: str, cfg: dict, event: asyncio.Event
|
||||
) -> None:
|
||||
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
|
||||
try:
|
||||
await self._init_mcp_client(name, cfg)
|
||||
await event.wait()
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
await self._terminate_mcp_client(name)
|
||||
except Exception as e:
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> None:
|
||||
"""初始化单个MCP客户端"""
|
||||
try:
|
||||
# 先清理之前的客户端,如果存在
|
||||
if name in self.mcp_client_dict:
|
||||
await self._terminate_mcp_client(name)
|
||||
|
||||
mcp_client = MCPClient()
|
||||
mcp_client.name = name
|
||||
await mcp_client.connect_to_server(config)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
tool_names = [tool.name for tool in tools_res.tools]
|
||||
self.mcp_client_dict[name] = mcp_client
|
||||
|
||||
# 移除该MCP服务之前的工具(如有)
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (f.origin == "mcp" and f.mcp_server_name == name)
|
||||
]
|
||||
|
||||
# 将 MCP 工具转换为 FuncTool 并添加到 func_list
|
||||
for tool in mcp_client.tools:
|
||||
func_tool = FuncTool(
|
||||
name=tool.name,
|
||||
parameters=tool.inputSchema,
|
||||
description=tool.description,
|
||||
origin="mcp",
|
||||
mcp_server_name=name,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
|
||||
# 发生错误时确保客户端被清理
|
||||
if name in self.mcp_client_dict:
|
||||
await self._terminate_mcp_client(name)
|
||||
return False
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
"""关闭并清理MCP客户端"""
|
||||
if name in self.mcp_client_dict:
|
||||
try:
|
||||
# 关闭MCP连接
|
||||
await self.mcp_client_dict[name].cleanup()
|
||||
del self.mcp_client_dict[name]
|
||||
except Exception as e:
|
||||
logger.info(f"清空 MCP 客户端资源 {name}: {e}。")
|
||||
# 移除关联的FuncTool
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (f.origin == "mcp" and f.mcp_server_name == name)
|
||||
]
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
|
||||
def get_func_desc_openai_style(self) -> list:
|
||||
"""
|
||||
获得 OpenAI API 风格的**已经激活**的工具描述
|
||||
"""
|
||||
_l = []
|
||||
# 处理所有工具(包括本地和MCP工具)
|
||||
for f in self.func_list:
|
||||
if not f.active:
|
||||
continue
|
||||
@@ -144,7 +394,13 @@ class FuncCall:
|
||||
|
||||
# 检查并添加非空的properties参数
|
||||
params = f.parameters if isinstance(f.parameters, dict) else {}
|
||||
params = copy.deepcopy(params)
|
||||
if params.get("properties", {}):
|
||||
properties = params["properties"]
|
||||
for key, value in properties.items():
|
||||
if "default" in value:
|
||||
del value["default"]
|
||||
params["properties"] = properties
|
||||
func_declaration["parameters"] = params
|
||||
|
||||
tools.append(func_declaration)
|
||||
@@ -160,9 +416,9 @@ class FuncCall:
|
||||
continue
|
||||
_l.append(
|
||||
{
|
||||
"name": f["name"],
|
||||
"parameters": f["parameters"],
|
||||
"description": f["description"],
|
||||
"name": f.name,
|
||||
"parameters": f.parameters,
|
||||
"description": f.description,
|
||||
}
|
||||
)
|
||||
func_definition = json.dumps(_l, ensure_ascii=False)
|
||||
@@ -212,14 +468,11 @@ class FuncCall:
|
||||
func_name = tool["name"]
|
||||
args = tool["args"]
|
||||
# 调用函数
|
||||
tool_callable = None
|
||||
for func in self.func_list:
|
||||
if func.name == func_name:
|
||||
tool_callable = func.star_handler_metadata.handler
|
||||
break
|
||||
if not tool_callable:
|
||||
func_tool = self.get_func(func_name)
|
||||
if not func_tool:
|
||||
raise Exception(f"Request function {func_name} not found.")
|
||||
ret = await tool_callable(**args)
|
||||
|
||||
ret = await func_tool.execute(**args)
|
||||
if ret:
|
||||
tool_call_result.append(str(ret))
|
||||
return tool_call_result, True
|
||||
@@ -229,3 +482,8 @@ class FuncCall:
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.func_list)
|
||||
|
||||
async def terminate(self):
|
||||
for name in self.mcp_client_dict.keys():
|
||||
await self._terminate_mcp_client(name)
|
||||
logger.debug(f"清理 MCP 客户端 {name} 资源")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import traceback
|
||||
import asyncio
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from .provider import Provider, STTProvider, TTSProvider, Personality
|
||||
from .entites import ProviderType
|
||||
@@ -127,6 +128,12 @@ class ProviderManager:
|
||||
if self.tts_enabled and not self.curr_tts_provider_inst:
|
||||
logger.warning("未启用任何用于 文本转语音 的提供商适配器。")
|
||||
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(
|
||||
self.llm_tools.mcp_service_selector(), name="mcp-service-handler"
|
||||
)
|
||||
self.llm_tools.mcp_service_queue.put_nowait({"type": "init"})
|
||||
|
||||
async def load_provider(self, provider_config: dict):
|
||||
if not provider_config["enable"]:
|
||||
return
|
||||
@@ -339,3 +346,5 @@ class ProviderManager:
|
||||
for provider_inst in self.provider_insts:
|
||||
if hasattr(provider_inst, "terminate"):
|
||||
await provider_inst.terminate()
|
||||
# 清理 MCP Client 连接
|
||||
await self.llm_tools.mcp_service_queue.put({"type": "terminate"})
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from typing import TypedDict
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from astrbot.core.provider.entites import LLMResponse
|
||||
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class Provider(AbstractProvider):
|
||||
func_tool: FuncCall = None,
|
||||
contexts: List = None,
|
||||
system_prompt: str = None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
|
||||
@@ -100,6 +101,7 @@ class Provider(AbstractProvider):
|
||||
image_urls: 图片 URL 列表
|
||||
tools: Function-calling 工具
|
||||
contexts: 上下文
|
||||
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
||||
kwargs: 其他参数
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -10,7 +10,7 @@ from astrbot.api.provider import Provider, Personality
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.provider.entites import LLMResponse
|
||||
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@@ -79,11 +79,14 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
# tools call (function calling)
|
||||
args_ls = []
|
||||
func_name_ls = []
|
||||
tool_use_ids = []
|
||||
func_name_ls.append(content.name)
|
||||
args_ls.append(content.input)
|
||||
tool_use_ids.append(content.id)
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args = args_ls
|
||||
llm_response.tools_call_name = func_name_ls
|
||||
llm_response.tools_call_ids = tool_use_ids
|
||||
|
||||
if not llm_response.completion_text and not llm_response.tools_call_args:
|
||||
logger.error(f"API 返回的 completion 无法解析:{completion}。")
|
||||
@@ -101,6 +104,7 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if not prompt:
|
||||
@@ -113,6 +117,10 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
if "_no_save" in part:
|
||||
del part["_no_save"]
|
||||
|
||||
if tool_calls_result:
|
||||
# 暂时这样写。
|
||||
prompt += f"Here are the related results via using tools: {str(tool_calls_result.tool_calls_result)}"
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import asyncio
|
||||
import functools
|
||||
from typing import List
|
||||
@@ -40,11 +41,24 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
raise Exception("阿里云百炼 APP 类型不能为空。")
|
||||
self.model_name = "dashscope"
|
||||
self.variables: dict = provider_config.get("variables", {})
|
||||
self.rag_options: dict = provider_config.get("rag_options", {})
|
||||
self.output_reference = self.rag_options.get("output_reference", False)
|
||||
self.rag_options = self.rag_options.copy()
|
||||
self.rag_options.pop("output_reference", None)
|
||||
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
|
||||
def has_rag_options(self):
|
||||
if (
|
||||
self.rag_options
|
||||
and self.rag_options.get("pipeline_ids", None)
|
||||
and self.rag_options.get("file_ids", None)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def text_chat(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -62,7 +76,10 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
session_var = session_vars.get(session_id, {})
|
||||
payload_vars.update(session_var)
|
||||
|
||||
if self.dashscope_app_type in ["agent", "dialog-workflow"]:
|
||||
if (
|
||||
self.dashscope_app_type in ["agent", "dialog-workflow"]
|
||||
and self.has_rag_options()
|
||||
):
|
||||
# 支持多轮对话的
|
||||
new_record = {"role": "user", "content": prompt}
|
||||
if image_urls:
|
||||
@@ -86,12 +103,17 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
else:
|
||||
# 不支持多轮对话的
|
||||
# 调用阿里云百炼 API
|
||||
payload = {
|
||||
"app_id": self.app_id,
|
||||
"prompt": prompt,
|
||||
"api_key": self.api_key,
|
||||
"biz_params": payload_vars or None,
|
||||
}
|
||||
if self.rag_options:
|
||||
payload["rag_options"] = self.rag_options
|
||||
partial = functools.partial(
|
||||
Application.call,
|
||||
app_id=self.app_id,
|
||||
promtp=prompt,
|
||||
api_key=self.api_key,
|
||||
biz_params=payload_vars or None,
|
||||
**payload,
|
||||
)
|
||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
||||
|
||||
@@ -107,6 +129,14 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
)
|
||||
|
||||
output_text = response.output.get("text", "")
|
||||
# RAG 引用脚标格式化
|
||||
output_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", output_text)
|
||||
if self.output_reference and response.output.get("doc_references", None):
|
||||
ref_str = ""
|
||||
for ref in response.output.get("doc_references", []):
|
||||
ref_str += f"{ref['index_id']}. {ref['title']}\n"
|
||||
output_text += f"\n\n回答来源:\n{ref_str}"
|
||||
|
||||
return LLMResponse(role="assistant", completion_text=output_text)
|
||||
|
||||
async def forget(self, session_id):
|
||||
|
||||
@@ -33,7 +33,6 @@ class ProviderDify(Provider):
|
||||
if not self.api_key:
|
||||
raise Exception("Dify API Key 不能为空。")
|
||||
api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
|
||||
self.api_client = DifyAPIClient(self.api_key, api_base)
|
||||
self.api_type = provider_config.get("dify_api_type", "")
|
||||
if not self.api_type:
|
||||
raise Exception("Dify API 类型不能为空。")
|
||||
@@ -44,15 +43,19 @@ class ProviderDify(Provider):
|
||||
self.dify_query_input_key = provider_config.get(
|
||||
"dify_query_input_key", "astrbot_text_query"
|
||||
)
|
||||
self.variables: dict = provider_config.get("variables", {})
|
||||
if not self.dify_query_input_key:
|
||||
self.dify_query_input_key = "astrbot_text_query"
|
||||
if not self.workflow_output_key:
|
||||
self.workflow_output_key = "astrbot_wf_output"
|
||||
self.variables: dict = provider_config.get("variables", {})
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.conversation_ids = {}
|
||||
"""记录当前 session id 的对话 ID"""
|
||||
|
||||
self.api_client = DifyAPIClient(self.api_key, api_base)
|
||||
|
||||
async def text_chat(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -68,26 +71,27 @@ class ProviderDify(Provider):
|
||||
|
||||
files_payload = []
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
file_response = await self.api_client.file_upload(
|
||||
image_path, user=session_id
|
||||
image_path = (
|
||||
await download_image_by_url(image_url)
|
||||
if image_url.startswith("http")
|
||||
else image_url
|
||||
)
|
||||
file_response = await self.api_client.file_upload(
|
||||
image_path, user=session_id
|
||||
)
|
||||
logger.debug(f"Dify 上传图片响应:{file_response}")
|
||||
if "id" not in file_response:
|
||||
logger.warning(
|
||||
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
|
||||
)
|
||||
if "id" not in file_response:
|
||||
logger.warning(
|
||||
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
|
||||
)
|
||||
continue
|
||||
files_payload.append(
|
||||
{
|
||||
"type": "image",
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": file_response["id"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
# TODO: 处理更多情况
|
||||
logger.warning(f"未知的图片链接:{image_url},图片将忽略。")
|
||||
continue
|
||||
files_payload.append(
|
||||
{
|
||||
"type": "image",
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": file_response["id"],
|
||||
}
|
||||
)
|
||||
|
||||
# 获得会话变量
|
||||
payload_vars = self.variables.copy()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import aiohttp
|
||||
import json
|
||||
import random
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.db import BaseDatabase
|
||||
@@ -146,11 +147,41 @@ class ProviderGoogleGenAI(Provider):
|
||||
google_genai_conversation.append({"role": "user", "parts": parts})
|
||||
|
||||
elif message["role"] == "assistant":
|
||||
if not message["content"]:
|
||||
message["content"] = "<empty_content>"
|
||||
google_genai_conversation.append(
|
||||
{"role": "model", "parts": [{"text": message["content"]}]}
|
||||
if "content" in message:
|
||||
if not message["content"]:
|
||||
message["content"] = "<empty_content>"
|
||||
google_genai_conversation.append(
|
||||
{"role": "model", "parts": [{"text": message["content"]}]}
|
||||
)
|
||||
elif "tool_calls" in message:
|
||||
# tool calls in the last turn
|
||||
parts = []
|
||||
for tool_call in message["tool_calls"]:
|
||||
parts.append(
|
||||
{
|
||||
"functionCall": {
|
||||
"name": tool_call["function"]["name"],
|
||||
"args": json.loads(
|
||||
tool_call["function"]["arguments"]
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
google_genai_conversation.append({"role": "model", "parts": parts})
|
||||
elif message["role"] == "tool":
|
||||
parts = []
|
||||
parts.append(
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": message["tool_call_id"],
|
||||
"response": {
|
||||
"name": message["tool_call_id"],
|
||||
"content": message["content"],
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
google_genai_conversation.append({"role": "user", "parts": parts})
|
||||
|
||||
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
|
||||
|
||||
@@ -174,6 +205,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args.append(candidate["functionCall"]["args"])
|
||||
llm_response.tools_call_name.append(candidate["functionCall"]["name"])
|
||||
llm_response.tools_call_ids.append(
|
||||
candidate["functionCall"]["name"]
|
||||
) # 没有 tool id
|
||||
|
||||
llm_response.completion_text = llm_response.completion_text.strip()
|
||||
return llm_response
|
||||
@@ -186,6 +220,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
@@ -198,6 +233,10 @@ class ProviderGoogleGenAI(Provider):
|
||||
if "_no_save" in part:
|
||||
del part["_no_save"]
|
||||
|
||||
# tool calls result
|
||||
if tool_calls_result:
|
||||
context_query.extend(tool_calls_result.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = self.get_model()
|
||||
|
||||
|
||||
@@ -120,15 +120,18 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# tools call (function calling)
|
||||
args_ls = []
|
||||
func_name_ls = []
|
||||
tool_call_ids = []
|
||||
for tool_call in choice.message.tool_calls:
|
||||
for tool in tools.func_list:
|
||||
if tool.name == tool_call.function.name:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
args_ls.append(args)
|
||||
func_name_ls.append(tool_call.function.name)
|
||||
tool_call_ids.append(tool_call.id)
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args = args_ls
|
||||
llm_response.tools_call_name = func_name_ls
|
||||
llm_response.tools_call_ids = tool_call_ids
|
||||
|
||||
if choice.finish_reason == "content_filter":
|
||||
raise Exception(
|
||||
@@ -151,6 +154,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
@@ -162,10 +166,15 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if "_no_save" in part:
|
||||
del part["_no_save"]
|
||||
|
||||
# tool calls result
|
||||
if tool_calls_result:
|
||||
context_query.extend(tool_calls_result.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
|
||||
llm_response = None
|
||||
try:
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
@@ -275,10 +284,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
def set_key(self, key):
|
||||
self.client.api_key = key
|
||||
|
||||
async def assemble_context(self, text: str, image_urls: List[str] = None):
|
||||
"""
|
||||
组装上下文。
|
||||
"""
|
||||
async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict:
|
||||
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
|
||||
if image_urls:
|
||||
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
|
||||
for image_url in image_urls:
|
||||
|
||||
@@ -332,7 +332,10 @@ class PluginManager:
|
||||
)
|
||||
# 绑定 llm_tool handler
|
||||
for func_tool in llm_tools.func_list:
|
||||
if func_tool.handler.__module__ == metadata.module_path:
|
||||
if (
|
||||
func_tool.handler
|
||||
and func_tool.handler.__module__ == metadata.module_path
|
||||
):
|
||||
func_tool.handler_module_path = metadata.module_path
|
||||
func_tool.handler = functools.partial(
|
||||
func_tool.handler, metadata.star_cls
|
||||
@@ -471,9 +474,11 @@ class PluginManager:
|
||||
# 从 star_registry 和 star_map 中删除
|
||||
await self._unbind_plugin(plugin_name, plugin.module_path)
|
||||
|
||||
if not remove_dir(os.path.join(ppath, root_dir_name)):
|
||||
try:
|
||||
remove_dir(os.path.join(ppath, root_dir_name))
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
"移除插件成功,但是删除插件文件夹失败。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
|
||||
f"移除插件成功,但是删除插件文件夹失败: {str(e)}。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
|
||||
)
|
||||
|
||||
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str):
|
||||
@@ -601,4 +606,4 @@ class PluginManager:
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {str(e)}")
|
||||
# await self.reload()
|
||||
await self.load(desti_dir)
|
||||
await self.load(specified_dir_name=dir_name)
|
||||
|
||||
@@ -8,6 +8,9 @@ import base64
|
||||
import zipfile
|
||||
import uuid
|
||||
import psutil
|
||||
|
||||
import certifi
|
||||
|
||||
from typing import Union
|
||||
|
||||
from PIL import Image
|
||||
@@ -17,24 +20,20 @@ def on_error(func, path, exc_info):
|
||||
"""
|
||||
a callback of the rmtree function.
|
||||
"""
|
||||
print(f"remove {path} failed.")
|
||||
import stat
|
||||
|
||||
if not os.access(path, os.W_OK):
|
||||
os.chmod(path, stat.S_IWUSR)
|
||||
func(path)
|
||||
else:
|
||||
raise
|
||||
raise exc_info[1]
|
||||
|
||||
|
||||
def remove_dir(file_path) -> bool:
|
||||
if not os.path.exists(file_path):
|
||||
return True
|
||||
try:
|
||||
shutil.rmtree(file_path, onerror=on_error)
|
||||
return True
|
||||
except BaseException:
|
||||
return False
|
||||
shutil.rmtree(file_path, onerror=on_error)
|
||||
return True
|
||||
|
||||
|
||||
def port_checker(port: int, host: str = "localhost"):
|
||||
@@ -81,7 +80,13 @@ async def download_image_by_url(
|
||||
下载图片, 返回 path
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
ssl_context = ssl.create_default_context(
|
||||
cafile=certifi.where()
|
||||
) # 使用 certifi 提供的 CA 证书
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context) # 使用 certifi 的根证书
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, connector=connector
|
||||
) as session:
|
||||
if post:
|
||||
async with session.post(url, json=post_data) as resp:
|
||||
if not path:
|
||||
@@ -118,7 +123,13 @@ async def download_file(url: str, path: str, show_progress: bool = False):
|
||||
从指定 url 下载文件到指定路径 path
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
ssl_context = ssl.create_default_context(
|
||||
cafile=certifi.where()
|
||||
) # 使用 certifi 提供的 CA 证书
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, connector=connector
|
||||
) as session:
|
||||
async with session.get(url, timeout=1800) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"下载文件失败: {resp.status}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import aiohttp
|
||||
import os
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
from . import RenderStrategy
|
||||
from astrbot.core.config import VERSION
|
||||
@@ -46,7 +48,11 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
},
|
||||
}
|
||||
if return_url:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, connector=connector
|
||||
) as session:
|
||||
async with session.post(
|
||||
f"{self.BASE_RENDER_URL}/generate", json=post_data
|
||||
) as resp:
|
||||
|
||||
@@ -2,6 +2,10 @@ import aiohttp
|
||||
import os
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
from astrbot.core.utils.io import on_error, download_file
|
||||
from astrbot.core import logger
|
||||
|
||||
@@ -19,7 +23,7 @@ class ReleaseInfo:
|
||||
self.body = body
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"新版本: {self.version}, 发布于: {self.published_at}, 详细内容: {self.body}"
|
||||
return f"\n{self.body}\n\n版本: {self.version} | 发布于: {self.published_at}"
|
||||
|
||||
|
||||
class RepoZipUpdator:
|
||||
@@ -33,8 +37,23 @@ class RepoZipUpdator:
|
||||
返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
ssl_context = ssl.create_default_context(
|
||||
cafile=certifi.where()
|
||||
) # 新增:创建基于 certifi 的 SSL 上下文
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=ssl_context
|
||||
) # 新增:使用 TCPConnector 指定 SSL 上下文
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, connector=connector
|
||||
) as session:
|
||||
async with session.get(url) as response:
|
||||
# 检查 HTTP 状态码
|
||||
if response.status != 200:
|
||||
text = await response.text()
|
||||
logger.error(
|
||||
f"请求 {url} 失败,状态码: {response.status}, 内容: {text}"
|
||||
)
|
||||
raise Exception(f"请求失败,状态码: {response.status}")
|
||||
result = await response.json()
|
||||
if not result:
|
||||
return []
|
||||
@@ -53,7 +72,8 @@ class RepoZipUpdator:
|
||||
"zipball_url": release["zipball_url"],
|
||||
}
|
||||
)
|
||||
except BaseException:
|
||||
except Exception as e:
|
||||
logger.error(f"解析版本信息时发生异常: {e}")
|
||||
raise Exception("解析版本信息失败")
|
||||
return ret
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .dashboard_lifecycle import AstrBotDashBoardLifecycle
|
||||
|
||||
__all__ = ["AstrBotDashBoardLifecycle"]
|
||||
@@ -6,6 +6,8 @@ from .stat import StatRoute
|
||||
from .log import LogRoute
|
||||
from .static_file import StaticFileRoute
|
||||
from .chat import ChatRoute
|
||||
from .tools import ToolsRoute # 导入新的ToolsRoute
|
||||
from .conversation import ConversationRoute
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -17,4 +19,6 @@ __all__ = [
|
||||
"LogRoute",
|
||||
"StaticFileRoute",
|
||||
"ChatRoute",
|
||||
"ToolsRoute", # 添加新的ToolsRoute
|
||||
"ConversationRoute",
|
||||
]
|
||||
|
||||
@@ -31,7 +31,6 @@ def validate_config(
|
||||
|
||||
def validate(data: dict, metadata: dict = schema, path=""):
|
||||
for key, value in data.items():
|
||||
print(key, value)
|
||||
if key not in metadata:
|
||||
# 无 schema 的配置项,执行类型猜测
|
||||
if isinstance(value, str):
|
||||
@@ -97,7 +96,6 @@ def validate_config(
|
||||
errors.append(
|
||||
f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}"
|
||||
)
|
||||
validate(value, meta["items"], path=f"{path}{key}.")
|
||||
|
||||
if is_core:
|
||||
for key, group in schema.items():
|
||||
@@ -147,6 +145,7 @@ class ConfigRoute(Route):
|
||||
"/config/provider/new": ("POST", self.post_new_provider),
|
||||
"/config/provider/update": ("POST", self.post_update_provider),
|
||||
"/config/provider/delete": ("POST", self.post_delete_provider),
|
||||
"/config/llmtools": ("GET", self.get_llm_tools),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@@ -220,7 +219,8 @@ class ConfigRoute(Route):
|
||||
return Response().error("未找到对应平台").__dict__
|
||||
|
||||
try:
|
||||
await self._save_astrbot_configs(self.config)
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.platform_manager.reload(new_config)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "更新平台配置成功~").__dict__
|
||||
@@ -256,7 +256,8 @@ class ConfigRoute(Route):
|
||||
else:
|
||||
return Response().error("未找到对应平台").__dict__
|
||||
try:
|
||||
await self._save_astrbot_configs(self.config)
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.platform_manager.terminate_platform(platform_id)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "删除平台配置成功~").__dict__
|
||||
@@ -277,6 +278,12 @@ class ConfigRoute(Route):
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "删除成功,已经实时生效~").__dict__
|
||||
|
||||
async def get_llm_tools(self):
|
||||
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
|
||||
tool_mgr = self.core_lifecycle.provider_manager.llm_tools
|
||||
tools = tool_mgr.get_func_desc_openai_style()
|
||||
return Response().ok(tools).__dict__
|
||||
|
||||
async def _get_astrbot_config(self):
|
||||
config = self.config
|
||||
|
||||
@@ -322,7 +329,7 @@ class ConfigRoute(Route):
|
||||
async def _save_astrbot_configs(self, post_configs: dict):
|
||||
try:
|
||||
save_config(post_configs, self.config, is_core=True)
|
||||
self.core_lifecycle.restart()
|
||||
await self.core_lifecycle.restart()
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import traceback
|
||||
import json
|
||||
from .route import Route, Response, RouteContext
|
||||
from astrbot.core import logger
|
||||
from quart import request
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
|
||||
|
||||
class ConversationRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
context: RouteContext,
|
||||
db_helper: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/conversation/list": ("GET", self.list_conversations),
|
||||
"/conversation/detail": (
|
||||
"POST",
|
||||
self.get_conv_detail,
|
||||
),
|
||||
"/conversation/update": ("POST", self.upd_conv),
|
||||
"/conversation/delete": ("POST", self.del_conv),
|
||||
"/conversation/update_history": (
|
||||
"POST",
|
||||
self.update_history,
|
||||
),
|
||||
}
|
||||
self.db_helper = db_helper
|
||||
self.register_routes()
|
||||
|
||||
async def list_conversations(self):
|
||||
"""获取对话列表,支持分页、排序和筛选"""
|
||||
try:
|
||||
# 获取分页参数
|
||||
page = request.args.get("page", 1, type=int)
|
||||
page_size = request.args.get("page_size", 20, type=int)
|
||||
|
||||
# 获取筛选参数
|
||||
platforms = request.args.get("platforms", "")
|
||||
message_types = request.args.get("message_types", "")
|
||||
search_query = request.args.get("search", "")
|
||||
exclude_ids = request.args.get("exclude_ids", "")
|
||||
exclude_platforms = request.args.get("exclude_platforms", "")
|
||||
|
||||
# 转换为列表
|
||||
platform_list = platforms.split(",") if platforms else []
|
||||
message_type_list = message_types.split(",") if message_types else []
|
||||
exclude_id_list = exclude_ids.split(",") if exclude_ids else []
|
||||
exclude_platform_list = (
|
||||
exclude_platforms.split(",") if exclude_platforms else []
|
||||
)
|
||||
|
||||
# 限制页面大小,防止请求过大数据
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1:
|
||||
page_size = 20
|
||||
if page_size > 100:
|
||||
page_size = 100
|
||||
|
||||
# 使用数据库的分页方法获取会话列表和总数,传入筛选条件
|
||||
try:
|
||||
conversations, total_count = self.db_helper.get_filtered_conversations(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
platforms=platform_list,
|
||||
message_types=message_type_list,
|
||||
search_query=search_query,
|
||||
exclude_ids=exclude_id_list,
|
||||
exclude_platforms=exclude_platform_list,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"数据库查询出错: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"数据库查询出错: {str(e)}").__dict__
|
||||
|
||||
# 计算总页数
|
||||
total_pages = (
|
||||
(total_count + page_size - 1) // page_size if total_count > 0 else 1
|
||||
)
|
||||
|
||||
result = {
|
||||
"conversations": conversations,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total_count,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
return Response().ok(result).__dict__
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"获取对话列表失败: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(error_msg)
|
||||
return Response().error(f"获取对话列表失败: {str(e)}").__dict__
|
||||
|
||||
async def get_conv_detail(self):
|
||||
"""获取指定对话详情(通过POST请求)"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"cid": cid,
|
||||
"title": conversation.title,
|
||||
"persona_id": conversation.persona_id,
|
||||
"history": conversation.history,
|
||||
"created_at": conversation.created_at,
|
||||
"updated_at": conversation.updated_at,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取对话详情失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"获取对话详情失败: {str(e)}").__dict__
|
||||
|
||||
async def upd_conv(self):
|
||||
"""更新对话信息(标题和角色ID)"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
title = data.get("title")
|
||||
persona_id = data.get("persona_id", "")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
if title is not None:
|
||||
self.db_helper.update_conversation_title(user_id, cid, title)
|
||||
if persona_id is not None:
|
||||
self.db_helper.update_conversation_persona_id(user_id, cid, persona_id)
|
||||
|
||||
return Response().ok({"message": "对话信息更新成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新对话信息失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"更新对话信息失败: {str(e)}").__dict__
|
||||
|
||||
async def del_conv(self):
|
||||
"""删除对话"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
self.db_helper.delete_conversation(user_id, cid)
|
||||
|
||||
return Response().ok({"message": "对话删除成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除对话失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"删除对话失败: {str(e)}").__dict__
|
||||
|
||||
async def update_history(self):
|
||||
"""更新对话历史内容"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
history = data.get("history")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
|
||||
if history is None:
|
||||
return Response().error("缺少必要参数: history").__dict__
|
||||
|
||||
# 历史记录必须是合法的 JSON 字符串
|
||||
try:
|
||||
if isinstance(history, list):
|
||||
history = json.dumps(history)
|
||||
else:
|
||||
# 验证是否为有效的 JSON 字符串
|
||||
json.loads(history)
|
||||
except json.JSONDecodeError:
|
||||
return (
|
||||
Response().error("history 必须是有效的 JSON 字符串或数组").__dict__
|
||||
)
|
||||
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
self.db_helper.update_conversation(user_id, cid, history)
|
||||
|
||||
return Response().ok({"message": "对话历史更新成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新对话历史失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"更新对话历史失败: {str(e)}").__dict__
|
||||
@@ -1,5 +1,9 @@
|
||||
import traceback
|
||||
import aiohttp
|
||||
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
from .route import Route, Response, RouteContext
|
||||
from astrbot.core import logger
|
||||
from quart import request
|
||||
@@ -65,9 +69,14 @@ class PluginRoute(Route):
|
||||
else:
|
||||
urls = ["https://api.soulter.top/astrbot/plugins"]
|
||||
|
||||
# 新增:创建 SSL 上下文,使用 certifi 提供的根证书
|
||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
for url in urls:
|
||||
try:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, connector=connector
|
||||
) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import traceback
|
||||
import psutil
|
||||
import time
|
||||
import threading
|
||||
from .route import Route, Response, RouteContext
|
||||
from astrbot.core import logger
|
||||
from quart import request
|
||||
@@ -28,7 +29,7 @@ class StatRoute(Route):
|
||||
self.core_lifecycle = core_lifecycle
|
||||
|
||||
async def restart_core(self):
|
||||
self.core_lifecycle.restart()
|
||||
await self.core_lifecycle.restart()
|
||||
return Response().ok().__dict__
|
||||
|
||||
def format_sec(self, sec: int):
|
||||
@@ -64,6 +65,20 @@ class StatRoute(Route):
|
||||
|
||||
stat_dict = stat.__dict__
|
||||
|
||||
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||
thread_count = threading.active_count()
|
||||
|
||||
# 获取插件信息
|
||||
plugins = self.core_lifecycle.star_context.get_all_stars()
|
||||
plugin_info = []
|
||||
for plugin in plugins:
|
||||
info = {
|
||||
"name": getattr(plugin, "name", plugin.__class__.__name__),
|
||||
"version": getattr(plugin, "version", "1.0.0"),
|
||||
"is_enabled": True,
|
||||
}
|
||||
plugin_info.append(info)
|
||||
|
||||
stat_dict.update(
|
||||
{
|
||||
"platform": self.db_helper.get_grouped_base_stats(
|
||||
@@ -73,9 +88,8 @@ class StatRoute(Route):
|
||||
"platform_count": len(
|
||||
self.core_lifecycle.platform_manager.get_insts()
|
||||
),
|
||||
"plugin_count": len(
|
||||
self.core_lifecycle.star_context.get_all_stars()
|
||||
),
|
||||
"plugin_count": len(plugins),
|
||||
"plugins": plugin_info,
|
||||
"message_time_series": message_time_based_stats,
|
||||
"running": self.format_sec(
|
||||
int(time.time()) - self.core_lifecycle.start_time
|
||||
@@ -84,6 +98,9 @@ class StatRoute(Route):
|
||||
"process": psutil.Process().memory_info().rss >> 20,
|
||||
"system": psutil.virtual_memory().total >> 20,
|
||||
},
|
||||
"cpu_percent": round(cpu_percent, 1),
|
||||
"thread_count": thread_count,
|
||||
"start_time": self.core_lifecycle.start_time,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
from .route import Route, Response, RouteContext
|
||||
from quart import request
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core import logger
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
|
||||
class ToolsRoute(Route):
|
||||
def __init__(
|
||||
self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.routes = {
|
||||
"/tools/mcp/servers": ("GET", self.get_mcp_servers),
|
||||
"/tools/mcp/add": ("POST", self.add_mcp_server),
|
||||
"/tools/mcp/update": ("POST", self.update_mcp_server),
|
||||
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
|
||||
}
|
||||
self.register_routes()
|
||||
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
|
||||
|
||||
@property
|
||||
def mcp_config_path(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
|
||||
return os.path.join(data_dir, "mcp_server.json")
|
||||
|
||||
def load_mcp_config(self):
|
||||
if not os.path.exists(self.mcp_config_path):
|
||||
# 配置文件不存在,创建默认配置
|
||||
os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True)
|
||||
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||
return DEFAULT_MCP_CONFIG
|
||||
|
||||
try:
|
||||
with open(self.mcp_config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载 MCP 配置失败: {e}")
|
||||
return DEFAULT_MCP_CONFIG
|
||||
|
||||
def save_mcp_config(self, config):
|
||||
try:
|
||||
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=4)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"保存 MCP 配置失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_mcp_servers(self):
|
||||
try:
|
||||
config = self.load_mcp_config()
|
||||
servers = []
|
||||
|
||||
# 获取所有服务器并添加它们的工具列表
|
||||
for name, server_config in config["mcpServers"].items():
|
||||
server_info = {
|
||||
"name": name,
|
||||
"active": server_config.get("active", True),
|
||||
}
|
||||
|
||||
# 复制所有配置字段
|
||||
for key, value in server_config.items():
|
||||
if key != "active": # active 已经处理
|
||||
server_info[key] = value
|
||||
|
||||
# 如果MCP客户端已初始化,从客户端获取工具名称
|
||||
for (
|
||||
name_key,
|
||||
mcp_client,
|
||||
) in self.tool_mgr.mcp_client_dict.items():
|
||||
if name_key == name:
|
||||
server_info["tools"] = [tool.name for tool in mcp_client.tools]
|
||||
break
|
||||
else:
|
||||
server_info["tools"] = []
|
||||
|
||||
servers.append(server_info)
|
||||
|
||||
return Response().ok(servers).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取 MCP 服务器列表失败: {str(e)}").__dict__
|
||||
|
||||
async def add_mcp_server(self):
|
||||
try:
|
||||
server_data = await request.json
|
||||
|
||||
name = server_data.get("name", "")
|
||||
|
||||
# 检查必填字段
|
||||
if not name:
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
|
||||
# 移除特殊字段并检查配置是否有效
|
||||
has_valid_config = False
|
||||
server_config = {"active": server_data.get("active", True)}
|
||||
|
||||
# 复制所有配置字段
|
||||
for key, value in server_data.items():
|
||||
if key not in ["name", "active", "tools"]: # 排除特殊字段
|
||||
server_config[key] = value
|
||||
has_valid_config = True
|
||||
|
||||
if not has_valid_config:
|
||||
return Response().error("必须提供有效的服务器配置").__dict__
|
||||
|
||||
config = self.load_mcp_config()
|
||||
|
||||
if name in config["mcpServers"]:
|
||||
return Response().error(f"服务器 {name} 已存在").__dict__
|
||||
|
||||
config["mcpServers"][name] = server_config
|
||||
|
||||
if self.save_mcp_config(config):
|
||||
# 动态初始化新MCP客户端
|
||||
self.tool_mgr.mcp_service_queue.put_nowait(
|
||||
{
|
||||
"type": "init",
|
||||
"name": name,
|
||||
"cfg": config["mcpServers"][name],
|
||||
}
|
||||
)
|
||||
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
|
||||
else:
|
||||
return Response().error("保存配置失败").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"添加 MCP 服务器失败: {str(e)}").__dict__
|
||||
|
||||
async def update_mcp_server(self):
|
||||
try:
|
||||
server_data = await request.json
|
||||
|
||||
name = server_data.get("name", "")
|
||||
|
||||
if not name:
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
|
||||
config = self.load_mcp_config()
|
||||
|
||||
if name not in config["mcpServers"]:
|
||||
return Response().error(f"服务器 {name} 不存在").__dict__
|
||||
|
||||
# 获取活动状态
|
||||
active = server_data.get(
|
||||
"active", config["mcpServers"][name].get("active", True)
|
||||
)
|
||||
|
||||
# 创建新的配置对象
|
||||
server_config = {"active": active}
|
||||
|
||||
# 仅更新活动状态的特殊处理
|
||||
only_update_active = True
|
||||
|
||||
# 复制所有配置字段
|
||||
for key, value in server_data.items():
|
||||
if key not in ["name", "active", "tools"]: # 排除特殊字段
|
||||
server_config[key] = value
|
||||
only_update_active = False
|
||||
|
||||
# 如果只更新活动状态,保留原始配置
|
||||
if only_update_active:
|
||||
for key, value in config["mcpServers"][name].items():
|
||||
if key != "active": # 除了active之外的所有字段都保留
|
||||
server_config[key] = value
|
||||
|
||||
config["mcpServers"][name] = server_config
|
||||
|
||||
if self.save_mcp_config(config):
|
||||
# 处理MCP客户端状态变化
|
||||
if active:
|
||||
# 如果要激活服务器或者配置已更改
|
||||
if name in self.tool_mgr.mcp_client_dict or not only_update_active:
|
||||
await self.tool_mgr.mcp_service_queue.put(
|
||||
{
|
||||
"type": "terminate",
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
await self.tool_mgr.mcp_service_queue.put(
|
||||
{
|
||||
"type": "init",
|
||||
"name": name,
|
||||
"cfg": config["mcpServers"][name],
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 客户端不存在,初始化
|
||||
self.tool_mgr.mcp_service_queue.put_nowait(
|
||||
{
|
||||
"type": "init",
|
||||
"name": name,
|
||||
"cfg": config["mcpServers"][name],
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 如果要停用服务器
|
||||
if name in self.tool_mgr.mcp_client_dict:
|
||||
self.tool_mgr.mcp_service_queue.put_nowait(
|
||||
{
|
||||
"type": "terminate",
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
|
||||
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
|
||||
else:
|
||||
return Response().error("保存配置失败").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"更新 MCP 服务器失败: {str(e)}").__dict__
|
||||
|
||||
async def delete_mcp_server(self):
|
||||
try:
|
||||
server_data = await request.json
|
||||
name = server_data.get("name", "")
|
||||
|
||||
if not name:
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
|
||||
config = self.load_mcp_config()
|
||||
|
||||
if name not in config["mcpServers"]:
|
||||
return Response().error(f"服务器 {name} 不存在").__dict__
|
||||
|
||||
# 删除服务器配置
|
||||
del config["mcpServers"][name]
|
||||
|
||||
if self.save_mcp_config(config):
|
||||
# 关闭并删除MCP客户端
|
||||
if name in self.tool_mgr.mcp_client_dict:
|
||||
self.tool_mgr.mcp_service_queue.put_nowait(
|
||||
{
|
||||
"type": "terminate",
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
|
||||
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
|
||||
else:
|
||||
return Response().error("保存配置失败").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
|
||||
@@ -95,8 +95,7 @@ class UpdateRoute(Route):
|
||||
logger.error(f"更新依赖失败: {e}")
|
||||
|
||||
if reboot:
|
||||
# threading.Thread(target=self.astrbot_updator._reboot, args=(2, )).start()
|
||||
self.core_lifecycle.restart()
|
||||
await self.core_lifecycle.restart()
|
||||
return (
|
||||
Response()
|
||||
.ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。")
|
||||
|
||||
@@ -20,7 +20,12 @@ DATAPATH = os.path.abspath(
|
||||
|
||||
|
||||
class AstrBotDashboard:
|
||||
def __init__(self, core_lifecycle: AstrBotCoreLifecycle, db: BaseDatabase) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
db: BaseDatabase,
|
||||
shutdown_event: asyncio.Event,
|
||||
) -> None:
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.data_path = os.path.abspath(os.path.join(DATAPATH, "dist"))
|
||||
@@ -45,6 +50,10 @@ class AstrBotDashboard:
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
||||
|
||||
self.shutdown_event = shutdown_event
|
||||
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
@@ -73,11 +82,6 @@ class AstrBotDashboard:
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
while not self.core_lifecycle.event_queue.closed: # noqa: ASYNC110
|
||||
await asyncio.sleep(1)
|
||||
logger.info("管理面板已关闭。")
|
||||
|
||||
def check_port_in_use(self, port: int) -> bool:
|
||||
"""
|
||||
跨平台检测端口是否被占用
|
||||
@@ -166,7 +170,9 @@ class AstrBotDashboard:
|
||||
logger.info(display)
|
||||
|
||||
return self.app.run_task(
|
||||
host=host,
|
||||
port=port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
host=host, port=port, shutdown_trigger=self.shutdown_trigger
|
||||
)
|
||||
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
logger.info("AstrBot WebUI 已经被优雅地关闭")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 AstrBot 上架宝塔面板 Docker 应用商店了!
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. ‼️ 新增支持接入 MCP 服务器 @Soulter @AraragiEro
|
||||
1. ‼️ 新增支持本地渲染 Markdown,并支持自定义字体,详见 -> [#957](https://github.com/Soulter/AstrBot/issues/957#issuecomment-2749981802)
|
||||
2. 新增支持在 WebUI 管理所有与大模型的对话
|
||||
3. 适配完整的 function-calling 流程。[#804](https://github.com/Soulter/AstrBot/issues/804) [#566](https://github.com/Soulter/AstrBot/issues/566)
|
||||
4. 新增支持消息平台热重载,不再需要重启 AstrBot
|
||||
5. 新增支持阿里云百炼应用的 RAG 应用 [#878](https://github.com/Soulter/AstrBot/issues/878)
|
||||
6. 新增 `/plugin get` OP 指令下载插件。如 `/plugin get Raven95676/astrbot_plugin_wordle`
|
||||
7. 新增 `/newgroup` OP 指令,支持私聊 bot 给指定群聊创建新的对话。by @LunarMeal
|
||||
8. Gewechat 下支持 `添加好友`, `接收/发送视频`, `获取群信息`, `接收/发送表情包` by @Moyuyanli @Soulter @XuYingJie-cmd @NiceAir
|
||||
9. Telegram 下支持接收和处理表情包(Sticker) @Raven95676
|
||||
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
0. 更加美观的 WebUI 设计,降低疲劳程度。
|
||||
1. 微信下,忽略 `微信团队` 的消息 [#859](https://github.com/Soulter/AstrBot/issues/859)
|
||||
2. 完善 Dify 的图片输入功能 [#893](https://github.com/Soulter/AstrBot/issues/893)
|
||||
3. 消息平台和配置提供商配置页中,自动更新旧的配置项
|
||||
4. 优化钉钉在配置错误之后堵塞整个线程的问题 [#885](https://github.com/Soulter/AstrBot/issues/885)
|
||||
5. WebUI 删除插件时提供二次确认避免误删 @zhx8702
|
||||
6. WebUI 优化新版本时的信息显示
|
||||
7. 发送消息失败时的报错回显优化
|
||||
8. 改善所有消息平台的优雅退出逻辑
|
||||
9. 空 @ 时调用 LLM 获得更加富有人格的回复 by @advent259141
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. 修复图片没有被存储到聊天上下文历史记录
|
||||
2. 修复 Telegram 下无法识别图片描述(Caption) [#910](https://github.com/Soulter/AstrBot/issues/910)
|
||||
3. 修复 Telegram Topic 群组下引用消息来源错误的问题 [#908](https://github.com/Soulter/AstrBot/issues/908)
|
||||
4. 修复 Telegram 下 `/start` 指令的一些问题 [#751](https://github.com/Soulter/AstrBot/issues/751)
|
||||
5. WebUI 插件市场卡片显示风格的过滤问题。[#927](https://github.com/Soulter/AstrBot/issues/927)
|
||||
6. 统一 SSL 证书验证逻辑,修复 `SSLCertVerificationError` 的问题。by @IGCrystal [#950](https://github.com/Soulter/AstrBot/issues/950)
|
||||
7. 修复可能形成 SQL 注入的风险
|
||||
8. 修复本地上传插件时无法重载插件的问题 [#995](https://github.com/Soulter/AstrBot/issues/995) by @zhx8702
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
1. astrbot_plugin_majsoul-master - 雀魂多功能插件 - by @kterna
|
||||
2. astrbot_plugin_server - 可视化服务器状态卡片,/status 或 /状态查询 查看 - by @yanfd @Meguminlove
|
||||
3. astrbot_plugin_Getcwm - 刺猬猫小说数据获取与画图插件 - by @Li-shi-ling
|
||||
4. astrbot_plugin_anti_withdrawal - 防撤回插件,目前只支持微信私聊群聊的文本消息,将撤回的消息记录并发送给设定的人 - by @NiceAir
|
||||
5. astrbot_plugin_hello77 - 游戏梗自动回复插件 - by @ttq7
|
||||
6. astrbot_plugin_push_lite - Webhook 轻量级推送插件 - @Raven95676
|
||||
7. astrbot_plugin_pokecheck - 检测“戳”关键词的插件 - @huanyan434
|
||||
8. astrbot_plugin_MultiAI_PollPad - 轮询调用配置的大语言模型输出多个结果。同时将 AI 结果拷贝至在线文本编辑器 - by @Ynkcc
|
||||
9. astrbot_plugin_box - / - by @Zhalslar
|
||||
10. astrbot_plugin_Translation - 通过调用百度翻译 API 实现翻译文本 - by @zengweis
|
||||
11. astrbot_plugin_wordle_2 - Wordle 游戏插件 - by @Raven95676 @whzcc
|
||||
12. astrbot_plugin_mai_sgin - 舞萌出勤与退勤签到插件 - by @Rinyin
|
||||
13. astrbot_plugin_Lolicon - Lolicon API 随机动漫图片插件 - by @ttq7
|
||||
14. astrbot_plugin_aiocensor - 综合内容安全+群管插件 - by @Raven95676
|
||||
+11
-6
@@ -1,16 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
||||
|
||||
services:
|
||||
astrbot:
|
||||
image: soulter/astrbot:latest
|
||||
container_name: astrbot
|
||||
restart: always
|
||||
ports: # mappings description: https://github.com/Soulter/AstrBot/issues/497
|
||||
- "6185:6185"
|
||||
- "6195:6195" # optional, wecom default port
|
||||
- "6199:6199" # optional, aiocqhttp default port
|
||||
- "6196:6196" # optional, qq official webhook default port
|
||||
- "11451:11451" # optional, gewechat default port
|
||||
- "6185:6185" # 必选,AstrBot WebUI 端口
|
||||
- "6195:6195" # 可选, 企业微信 Webhook 端口
|
||||
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
|
||||
- "6196:6196" # 可选, QQ 官方接口 Webhook 端口
|
||||
- "11451:11451" # 可选, 微信个人号 Webhook 端口
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
- ./data:/AstrBot/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
# - /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<v-dialog v-model="isOpen" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">{{ title }}</v-card-title>
|
||||
<v-card-text>{{ message }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="gray" @click="handleCancel">取消</v-btn>
|
||||
<v-btn color="red" @click="handleConfirm">确定</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
const title = ref("");
|
||||
const message = ref("");
|
||||
let resolvePromise = null; // ✅ 确保 Promise 句柄可用
|
||||
|
||||
const open = (options) => {
|
||||
title.value = options.title || "确认操作";
|
||||
message.value = options.message || "你确定要执行此操作吗?";
|
||||
isOpen.value = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve; // ✅ 赋值 Promise 解析方法
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
isOpen.value = false;
|
||||
if (resolvePromise) resolvePromise(true); // ✅ 解析 Promise
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
isOpen.value = false;
|
||||
if (resolvePromise) resolvePromise(false); // ✅ 解析 Promise
|
||||
};
|
||||
|
||||
defineExpose({ open }); // ✅ 确保 `confirmPlugin.ts` 可以访问 `open`
|
||||
</script>
|
||||
@@ -1,154 +1,374 @@
|
||||
<template>
|
||||
<div style="margin-bottom: 6px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
|
||||
<v-list-item-title style="font-weight: bold;">
|
||||
{{ metadata[metadataKey]?.description }} ({{ metadataKey }})
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle style="font-size: 12px;">
|
||||
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
|
||||
style="opacity: 1.0;">‼️</span>
|
||||
{{ metadata[metadataKey]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
|
||||
<v-list-item-title class="config-title">
|
||||
{{ metadata[metadataKey]?.description }} <span class="metadata-key">({{ metadataKey }})</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="config-hint">
|
||||
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint">‼️</span>
|
||||
{{ metadata[metadataKey]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
|
||||
<v-card-text class="px-0 py-1">
|
||||
<!-- Object Type Configuration -->
|
||||
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
|
||||
<div v-for="(val, key, index) in iterable" :key="key" class="config-item">
|
||||
<!-- Nested Object -->
|
||||
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" class="nested-object">
|
||||
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible" class="nested-container">
|
||||
<v-expand-transition>
|
||||
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey="key">
|
||||
</AstrBotConfig>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular Property -->
|
||||
<template v-else>
|
||||
<v-row v-if="!metadata[metadataKey].items[key]?.invisible" class="config-row">
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
<span v-if="metadata[metadataKey].items[key]?.description">
|
||||
{{ metadata[metadataKey].items[key]?.description }}
|
||||
<span class="property-key">({{ key }})</span>
|
||||
</span>
|
||||
<span v-else>{{ key }}</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
class="important-hint">‼️</span>
|
||||
{{ metadata[metadataKey].items[key]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
|
||||
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
|
||||
color="primary"
|
||||
label
|
||||
size="x-small"
|
||||
variant="flat">
|
||||
{{ metadata[metadataKey].items[key]?.type || 'string' }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="5" class="config-input">
|
||||
<div v-if="metadata[metadataKey].items[key]" class="w-100">
|
||||
<!-- Select input -->
|
||||
<v-select
|
||||
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
:items="metadata[metadataKey].items[key]?.options"
|
||||
:disabled="metadata[metadataKey].items[key]?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-select>
|
||||
|
||||
<!-- String input -->
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<!-- Numeric input -->
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<!-- Text area -->
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="3"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-textarea>
|
||||
|
||||
<!-- Boolean switch -->
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
color="primary"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<!-- List item -->
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
||||
:value="iterable[key]"
|
||||
class="config-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fallback for unknown metadata -->
|
||||
<div v-else class="w-100">
|
||||
<v-text-field
|
||||
v-model="iterable[key]"
|
||||
:label="key"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider
|
||||
v-if="index !== Object.keys(iterable).length - 1 && !metadata[metadataKey].items[key]?.invisible"
|
||||
class="config-divider"
|
||||
></v-divider>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Value Configuration -->
|
||||
<div v-else class="simple-config">
|
||||
<v-row class="config-row">
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
{{ metadata[metadataKey]?.description }}
|
||||
<span class="property-key">({{ metadataKey }})</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-card-text style="padding: 0px;">
|
||||
<div v-for="(val, key, index) in iterable" :key="key" style="margin-bottom: 0.5px;"
|
||||
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint">‼️</span>
|
||||
{{ metadata[metadataKey]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" style="padding-left: 16px;">
|
||||
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible"
|
||||
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px; margin-top: 16px">
|
||||
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey=key>
|
||||
</AstrBotConfig>
|
||||
</div>
|
||||
</div>
|
||||
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
|
||||
<v-chip v-if="!metadata[metadataKey]?.invisible"
|
||||
color="primary"
|
||||
label
|
||||
size="x-small"
|
||||
variant="flat">
|
||||
{{ metadata[metadataKey]?.type }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-row v-else style="margin: 0; align-items: center;">
|
||||
<v-col cols="6" style="padding: 0px;">
|
||||
<v-list-item>
|
||||
<v-list-item-title style="font-size: 14px; font-weight: bold;">
|
||||
{{ metadata[metadataKey].items[key]?.description + '(' + key + ')' }}
|
||||
</v-list-item-title>
|
||||
<v-col cols="12" sm="5" class="config-input">
|
||||
<div class="w-100">
|
||||
<!-- Select input -->
|
||||
<v-select
|
||||
v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:items="metadata[metadataKey]?.options"
|
||||
:disabled="metadata[metadataKey]?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-select>
|
||||
|
||||
<!-- String input -->
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<!-- Numeric input -->
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<!-- Text area -->
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="3"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-textarea>
|
||||
|
||||
<!-- Boolean switch -->
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
color="primary"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<!-- List item -->
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
||||
:value="iterable[metadataKey]"
|
||||
class="config-field"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-list-item-subtitle style="font-size: 12px;">
|
||||
<span
|
||||
v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
style="opacity: 1.0;">‼️</span>
|
||||
{{ metadata[metadataKey].items[key]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="1">
|
||||
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary" label size="x-small"
|
||||
class="mb-1">{{
|
||||
metadata[metadataKey].items[key]?.type }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="5">
|
||||
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
|
||||
<v-select
|
||||
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined"
|
||||
:items="metadata[metadataKey].items[key]?.options" dense
|
||||
:disabled="metadata[metadataKey].items[key]?.readonly" density="compact" flat hide-details
|
||||
single-line></v-select>
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" dense flat hide-details single-line></v-textarea>
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" color="primary" hide-details></v-switch>
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
||||
:value="iterable[key]" />
|
||||
</div>
|
||||
<div style="width: 100%;" v-else>
|
||||
<!-- 在 metadata 中没有 key -->
|
||||
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
<v-divider style="border-color: #ccc;" v-if="index !== Object.keys(iterable).length - 1"></v-divider>
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
<v-row style="margin: 0; align-items: center;">
|
||||
<v-col cols="6" style="padding: 0px;">
|
||||
<v-list-item>
|
||||
<v-list-item-title style="font-size: 14px; font-weight: bold">
|
||||
{{ metadata[metadataKey]?.description + '(' + metadataKey + ')' }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle style="font-size: 12px;">
|
||||
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint">‼️</span>
|
||||
{{ metadata[metadataKey]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="1">
|
||||
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary" label size="x-small"
|
||||
class="mb-1">{{
|
||||
metadata[metadataKey]?.type }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="5">
|
||||
<div style="width: 100%;">
|
||||
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
|
||||
dense :disabled="metadata[metadataKey]?.readonly" density="compact" flat hide-details
|
||||
single-line></v-select>
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-textarea>
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" color="primary" hide-details></v-switch>
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
||||
:value="iterable[metadataKey]" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider style="border-color: #ddd;"></v-divider>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-divider class="my-2 config-divider"></v-divider>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListConfigItem from './ListConfigItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ListConfigItem
|
||||
name: 'AstrBotConfig',
|
||||
components: {
|
||||
ListConfigItem
|
||||
},
|
||||
props: {
|
||||
metadata: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
props: {
|
||||
metadata: Object,
|
||||
iterable: Object,
|
||||
metadataKey: String
|
||||
iterable: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
metadataKey: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--v-primary-darken1);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.metadata-key, .property-key {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.important-hint {
|
||||
opacity: 1;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.object-config, .simple-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nested-object {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.nested-container {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.config-row {
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.property-info {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.property-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.property-hint {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.type-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-divider {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nested-object {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.property-info, .type-indicator, .config-input {
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, inject } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -46,8 +46,21 @@ const reloadExtension = () => {
|
||||
emit('reload', props.extension);
|
||||
};
|
||||
|
||||
const uninstallExtension = () => {
|
||||
emit('uninstall', props.extension);
|
||||
const $confirm = inject("$confirm");
|
||||
const uninstallExtension = async () => {
|
||||
if (typeof $confirm !== "function") {
|
||||
console.error("$confirm 未正确注册");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await $confirm({
|
||||
title: "删除确认",
|
||||
message: "你确定要删除当前插件吗?",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
emit("uninstall", props.extension);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActivation = () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row v-if="items.length === 0">
|
||||
<v-col cols="12" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">{{ emptyIcon }}</v-icon>
|
||||
<p class="text-grey mt-4">{{ emptyText }}</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col v-for="(item, index) in items" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||
<v-card class="item-card hover-elevation" :color="getItemEnabled(item) ? '' : 'grey-lighten-4'">
|
||||
<div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div>
|
||||
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
|
||||
<span class="text-h4 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-switch
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:model-value="getItemEnabled(item)"
|
||||
v-bind="props"
|
||||
@update:model-value="toggleEnabled(item)"
|
||||
></v-switch>
|
||||
</template>
|
||||
<span>{{ getItemEnabled(item) ? '已启用' : '已禁用' }}</span>
|
||||
</v-tooltip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<slot name="item-details" :item="item"></slot>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-2">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
删除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
color="primary"
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="$emit('edit', item)"
|
||||
>
|
||||
编辑
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ItemCardGrid',
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
titleField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
enabledField: {
|
||||
type: String,
|
||||
default: 'enable'
|
||||
},
|
||||
emptyIcon: {
|
||||
type: String,
|
||||
default: 'mdi-alert-circle-outline'
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无数据'
|
||||
}
|
||||
},
|
||||
emits: ['toggle-enabled', 'delete', 'edit'],
|
||||
methods: {
|
||||
getItemTitle(item) {
|
||||
return item[this.titleField];
|
||||
},
|
||||
getItemEnabled(item) {
|
||||
return item[this.enabledField];
|
||||
},
|
||||
toggleEnabled(item) {
|
||||
this.$emit('toggle-enabled', item);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-card {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
min-height: 220px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item-status-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.item-status-indicator.active {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.hover-elevation:hover {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import axios from 'axios';
|
||||
import { md5 } from 'js-md5';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
let dialog = ref(false);
|
||||
@@ -15,6 +16,7 @@ let newPassword = ref('');
|
||||
let newUsername = ref('');
|
||||
let status = ref('');
|
||||
let updateStatus = ref('')
|
||||
let releaseMessage = ref('');
|
||||
let hasNewVersion = ref(false);
|
||||
let botCurrVersion = ref('');
|
||||
let dashboardHasNewVersion = ref(false);
|
||||
@@ -81,7 +83,13 @@ function checkUpdate() {
|
||||
axios.get('/api/update/check')
|
||||
.then((res) => {
|
||||
hasNewVersion.value = res.data.data.has_new_version;
|
||||
updateStatus.value = res.data.message;
|
||||
|
||||
if (res.data.data.has_new_version) {
|
||||
releaseMessage.value = res.data.message;
|
||||
updateStatus.value = '有新版本!';
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
botCurrVersion.value = res.data.data.version;
|
||||
dashboardCurrentVersion.value = res.data.data.dashboard_version;
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
@@ -226,15 +234,23 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-progress-linear v-show="installLoading" class="mb-4" indeterminate color="primary"></v-progress-linear>
|
||||
|
||||
|
||||
<div>
|
||||
<h1 style="display:inline-block;">{{ botCurrVersion }}</h1>
|
||||
<small style="margin-left: 4px;">{{ updateStatus }}</small>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
|
||||
v-html="marked(releaseMessage)" class="markdown-content">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-4">
|
||||
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件,这可能会造成部分数据显示错误。您可在 <a href="https://github.com/Soulter/AstrBot/releases">此处</a>
|
||||
找到对应的面板文件 dist.zip,解压后替换 data/dist 文件夹即可。当然,前端源代码在 dashboard 目录下,你也可以自己使用 npm install 和 npm build 构建。</small>
|
||||
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件,这可能会造成部分数据显示错误。您可在 <a
|
||||
href="https://github.com/Soulter/AstrBot/releases">此处</a>
|
||||
找到对应的面板文件 dist.zip,解压后替换 data/dist 文件夹即可。当然,前端源代码在 dashboard 目录下,你也可以自己使用 npm install 和 npm build
|
||||
构建。</small>
|
||||
</div>
|
||||
|
||||
<v-tabs v-model="tab">
|
||||
@@ -269,7 +285,7 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-tabs-window-item>
|
||||
|
||||
|
||||
<!-- 开发版 -->
|
||||
<v-tabs-window-item key="1" v-show="tab == 1">
|
||||
<div style="margin-top: 16px;">
|
||||
@@ -319,7 +335,8 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()" :disabled="!dashboardHasNewVersion">
|
||||
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
|
||||
:disabled="!dashboardHasNewVersion">
|
||||
下载并更新
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -379,4 +396,24 @@ if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('cha
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.markdown-content h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.markdown-content ol {
|
||||
padding-left: 24px;
|
||||
/* Adds indentation to ordered lists */
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown-content ul {
|
||||
padding-left: 24px;
|
||||
/* Adds indentation to unordered lists */
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({ item: Object, level: Number });
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title style="font-size: 15px;">{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-title style="font-size: 14px;">{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
|
||||
{{ item.subCaption }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
@@ -31,7 +31,12 @@ const sidebarItem: menu[] = [
|
||||
to: '/providers',
|
||||
},
|
||||
{
|
||||
title: '配置',
|
||||
title: 'MCP',
|
||||
icon: 'mdi-function-variant',
|
||||
to: '/tool-use'
|
||||
},
|
||||
{
|
||||
title: '配置文件',
|
||||
icon: 'mdi-cog',
|
||||
to: '/config',
|
||||
},
|
||||
@@ -50,6 +55,11 @@ const sidebarItem: menu[] = [
|
||||
icon: 'mdi-chat',
|
||||
to: '/chat'
|
||||
},
|
||||
{
|
||||
title: '对话数据库',
|
||||
icon: 'mdi-database',
|
||||
to: '/conversation'
|
||||
},
|
||||
{
|
||||
title: '控制台',
|
||||
icon: 'mdi-console',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import vuetify from './plugins/vuetify';
|
||||
import confirmPlugin from './plugins/confirmPlugin';
|
||||
import '@/scss/style.scss';
|
||||
import VueApexCharts from 'vue3-apexcharts';
|
||||
|
||||
@@ -15,7 +16,10 @@ app.use(router);
|
||||
app.use(createPinia());
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify).mount('#app');
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
app.mount('#app');
|
||||
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { App } from "vue";
|
||||
import { h, render } from "vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
const mountNode = document.createElement("div");
|
||||
document.body.appendChild(mountNode);
|
||||
|
||||
const vNode = h(ConfirmDialog);
|
||||
vNode.appContext = app._context;
|
||||
render(vNode, mountNode);
|
||||
|
||||
const confirm = (options: { title?: string; message?: string }) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
vNode.component?.exposed?.open(options).then(resolve); // ✅ 确保返回 `Promise<boolean>`
|
||||
});
|
||||
};
|
||||
|
||||
app.config.globalProperties.$confirm = confirm;
|
||||
app.provide("$confirm", confirm);
|
||||
},
|
||||
};
|
||||
@@ -31,6 +31,11 @@ const MainRoutes = {
|
||||
path: '/providers',
|
||||
component: () => import('@/views/ProviderPage.vue')
|
||||
},
|
||||
{
|
||||
name: 'ToolUsePage',
|
||||
path: '/tool-use',
|
||||
component: () => import('@/views/ToolUsePage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Configs',
|
||||
path: '/config',
|
||||
@@ -41,6 +46,11 @@ const MainRoutes = {
|
||||
path: '/dashboard/default',
|
||||
component: () => import('@/views/dashboards/default/DefaultDashboard.vue')
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
path: '/conversation',
|
||||
component: () => import('@/views/ConversationPage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Console',
|
||||
path: '/console',
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
color: rgb(var(--v-theme-secondary));
|
||||
}
|
||||
}
|
||||
.v-list-item--density-default.v-list-item--one-line {
|
||||
min-height: 42px;
|
||||
}
|
||||
.leftPadding {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
+519
-127
@@ -8,164 +8,181 @@ marked.setOptions({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="chat-page-card">
|
||||
<v-card-text class="chat-page-container">
|
||||
<div class="chat-layout">
|
||||
<!-- 左侧对话列表面板 -->
|
||||
<div class="sidebar-panel">
|
||||
<v-btn variant="tonal" rounded="xl" class="new-chat-btn" @click="newC"
|
||||
:disabled="!currCid">
|
||||
<v-icon class="mr-2">mdi-plus</v-icon>创建对话
|
||||
</v-btn>
|
||||
|
||||
<v-card style="margin-bottom: 16px; width: 100%; background-color: #fff; height: 100%;">
|
||||
<v-card-text style="width: 100%; height: calc(100vh - 120px);">
|
||||
<div style="height: 100%; display: flex; gap: 16px;">
|
||||
<div style="max-width: 200px;">
|
||||
<!-- conversation -->
|
||||
<v-btn variant="tonal" rounded="xl" style="margin-bottom: 16px; min-width: 200px;" @click="newC"
|
||||
:disabled="!currCid">+ 创建对话</v-btn>
|
||||
|
||||
<v-card class="mx-auto" min-width="200">
|
||||
<v-list dense nav v-if="conversations.length > 0" style="max-height: 500px; overflow-y: auto;"
|
||||
@update:selected="getConversationMessages">
|
||||
<v-card class="conversation-list-card" v-if="conversations.length > 0">
|
||||
<v-list density="compact" nav class="conversation-list" @update:selected="getConversationMessages">
|
||||
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
|
||||
color="primary" rounded="xl">
|
||||
color="primary" rounded="xl" class="conversation-item">
|
||||
<v-list-item-title>新对话</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
|
||||
|
||||
<v-list-item-subtitle class="timestamp">{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
|
||||
<div>
|
||||
|
||||
<v-chip class="mt-4" color="primary" :append-icon="status?.llm_enabled ? 'mdi-check' : 'mdi-close'">
|
||||
<div class="status-chips">
|
||||
<v-chip class="status-chip" 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 class="status-chip" 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>
|
||||
<v-btn variant="tonal" rounded="xl" class="delete-chat-btn" v-if="currCid"
|
||||
@click="deleteConversation(currCid)" color="error">
|
||||
<v-icon class="mr-2">mdi-delete</v-icon>删除此对话
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div style="height: 100%; width: 100%;">
|
||||
<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>
|
||||
<span style="font-size: 28px;">Hello, I'm</span>
|
||||
<span style="font-weight: 1000; font-size: 28px; margin-left: 8px;">AstrBot ⭐</span>
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
<div class="messages-container" ref="messageContainer">
|
||||
<!-- 空聊天欢迎页 -->
|
||||
<div class="welcome-container fade-in" v-if="messages.length == 0">
|
||||
<div class="welcome-title">
|
||||
<span>Hello, I'm</span>
|
||||
<span class="bot-name">AstrBot ⭐</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #aaa;">
|
||||
<div class="welcome-hint">
|
||||
<span>输入</span>
|
||||
<span
|
||||
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">help</span>
|
||||
<code>help</code>
|
||||
<span>获取帮助 😊</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #aaa;">
|
||||
<div class="welcome-hint">
|
||||
<span>长按</span>
|
||||
<span
|
||||
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">Ctrl</span>
|
||||
<code>Ctrl</code>
|
||||
<span>录制语音 🎤</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #aaa;">
|
||||
<div class="welcome-hint">
|
||||
<span>按</span>
|
||||
<span
|
||||
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">Ctrl + V</span>
|
||||
<code>Ctrl + V</code>
|
||||
<span>粘贴图片 🏞️</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else style="max-height: 100%; padding: 16px; max-width: 700px; margin: 0 auto;">
|
||||
<div class="fade-in" v-for="(msg, index) in messages" :key="index"
|
||||
style="margin-bottom: 16px;">
|
||||
<div v-if="msg.type == 'user'" style="display: flex; justify-content: flex-end;">
|
||||
<div
|
||||
style="padding: 12px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15)">
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<div v-else class="message-list">
|
||||
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.type == 'user'" class="user-message">
|
||||
<div class="message-bubble user-bubble">
|
||||
<span>{{ msg.message }}</span>
|
||||
<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 class="image-attachments" v-if="msg.image_url && msg.image_url.length > 0">
|
||||
<div v-for="(img, index) in msg.image_url" :key="index" class="image-attachment">
|
||||
<img :src="img" class="attached-image" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- audio -->
|
||||
<div>
|
||||
<audio controls v-if="msg.audio_url && msg.audio_url.length > 0">
|
||||
|
||||
<!-- 音频附件 -->
|
||||
<div class="audio-attachment" v-if="msg.audio_url && msg.audio_url.length > 0">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="msg.audio_url" type="audio/wav">
|
||||
Your browser does not support the audio element.
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
<v-avatar class="user-avatar" color="deep-purple-lighten-3" size="36">
|
||||
<v-icon icon="mdi-account" />
|
||||
</v-avatar>
|
||||
</div>
|
||||
<div v-else style="display: flex; justify-content: flex-start; gap: 16px;">
|
||||
<span style="font-size: 32px;">✨</span>
|
||||
<div v-html="marked(msg.message)" class="mc" style="font-family: inherit;"></div>
|
||||
|
||||
<!-- 机器人消息 -->
|
||||
<div v-else class="bot-message">
|
||||
<v-avatar class="bot-avatar" color="deep-purple" size="36">
|
||||
<span class="text-h6">✨</span>
|
||||
</v-avatar>
|
||||
<div class="message-bubble bot-bubble">
|
||||
<div v-html="marked(msg.message)" class="markdown-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fade-in" style="bottom: 16px; width: 100%; padding: 8px; ">
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area fade-in">
|
||||
<v-text-field
|
||||
id="input-field"
|
||||
variant="outlined"
|
||||
v-model="prompt"
|
||||
:label="inputFieldLabel"
|
||||
placeholder="开始输入..."
|
||||
:loading="loadingChat"
|
||||
clear-icon="mdi-close-circle"
|
||||
clearable
|
||||
@click:clear="clearMessage"
|
||||
class="message-input"
|
||||
@keydown="handleInputKeyDown"
|
||||
hide-details
|
||||
>
|
||||
<template v-slot:loader>
|
||||
<v-progress-linear :active="loadingChat" height="3" color="deep-purple" indeterminate></v-progress-linear>
|
||||
</template>
|
||||
|
||||
<div
|
||||
style="width: 100%; justify-content: center; align-items: center; display: flex; flex-direction: column; margin-top: 8px;">
|
||||
<template v-slot:append>
|
||||
<v-tooltip text="发送">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
@click="sendMessage"
|
||||
class="send-btn"
|
||||
icon="mdi-send"
|
||||
variant="text"
|
||||
color="deep-purple"
|
||||
:disabled="!prompt && stagedImagesUrl.length === 0 && !stagedAudioUrl"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<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" style="width: 100%; max-width: 850px;"
|
||||
@keydown="handleInputKeyDown">
|
||||
<template v-slot:loader>
|
||||
<v-progress-linear :active="loadingChat" height="6"
|
||||
indeterminate></v-progress-linear>
|
||||
</template>
|
||||
<v-tooltip text="语音输入">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
@click="isRecording ? stopRecording() : startRecording()"
|
||||
class="record-btn"
|
||||
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'"
|
||||
variant="text"
|
||||
:color="isRecording ? 'error' : 'deep-purple'"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<template v-slot:append>
|
||||
<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 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"
|
||||
style="width: 50px; height: 50px; border-radius: 8px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);" />
|
||||
<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 class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
|
||||
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
|
||||
<img :src="img" class="preview-image" />
|
||||
<v-btn @click="removeImage(index)" class="remove-attachment-btn" icon="mdi-close" size="small" color="error" variant="text" />
|
||||
</div>
|
||||
|
||||
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||
<v-chip color="deep-purple-lighten-4" class="audio-chip">
|
||||
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||
新录音
|
||||
</v-chip>
|
||||
<v-btn @click="removeAudio" class="remove-attachment-btn" icon="mdi-close" size="small" color="error" variant="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ChatPage',
|
||||
@@ -192,7 +209,7 @@ export default {
|
||||
|
||||
eventSource: null,
|
||||
|
||||
// 添加Ctrl键长按相关变量
|
||||
// Ctrl键长按相关变量
|
||||
ctrlKeyDown: false,
|
||||
ctrlKeyTimer: null,
|
||||
ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒
|
||||
@@ -574,40 +591,415 @@ export default {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 基础动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.mc h1,
|
||||
.mc h2,
|
||||
.mc h3,
|
||||
.mc h4,
|
||||
.mc h5,
|
||||
.mc h6 {
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(20px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 聊天页面布局 */
|
||||
.chat-page-card {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.chat-page-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 120px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.sidebar-panel {
|
||||
max-width: 240px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 8px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
margin-bottom: 16px;
|
||||
min-width: 200px;
|
||||
background-color: #f5f0ff !important;
|
||||
color: #673ab7 !important;
|
||||
font-weight: 500;
|
||||
box-shadow: none !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
background-color: #ede7f6 !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.conversation-list-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #f0f0f0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: #f5f0ff;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-chips {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.delete-chat-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
min-width: 200px;
|
||||
background-color: #feecec !important;
|
||||
color: #d32f2f !important;
|
||||
font-weight: 500;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.delete-chat-btn:hover {
|
||||
background-color: #ffebee !important;
|
||||
}
|
||||
|
||||
/* 聊天内容区域 */
|
||||
.chat-content-panel {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
height: calc(100% - 80px);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 欢迎页样式 */
|
||||
.welcome-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
color: #673ab7;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.welcome-hint code {
|
||||
background-color: #f5f0ff;
|
||||
padding: 2px 6px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
color: #673ab7;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 消息列表样式 */
|
||||
.message-list {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
margin-bottom: 24px;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
max-width: 80%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background-color: #f5f0ff;
|
||||
color: #333;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bot-bubble {
|
||||
background-color: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: #333;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.user-avatar, .bot-avatar {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* 附件样式 */
|
||||
.image-attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-attachment {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.attached-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.attached-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.audio-attachment {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* 输入区域样式 */
|
||||
.input-area {
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
border-radius: 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.send-btn, .record-btn {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 附件预览区 */
|
||||
.attachments-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
max-width: 900px;
|
||||
margin: 8px auto 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-preview, .audio-preview {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.audio-chip {
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.remove-attachment-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.remove-attachment-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Markdown内容样式 */
|
||||
.markdown-content {
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mc li {
|
||||
.markdown-content h1 {
|
||||
font-size: 1.8em;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-left: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.mc p {
|
||||
.markdown-content p {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: #f8f8f8;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f5f0ff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #673ab7;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #673ab7;
|
||||
padding-left: 16px;
|
||||
color: #666;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #eee;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f5f0ff;
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,7 @@ import { useCommonStore } from '@/stores/common';
|
||||
<v-col cols="12" md="12" style="padding: 0px;">
|
||||
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
|
||||
:loading="loading_" v-model:search="marketSearch"
|
||||
:filter-keys="['name', 'desc', 'author']">
|
||||
:filter-keys="filterKeys">
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<img v-if="item.logo" :src="item.logo"
|
||||
@@ -221,7 +221,9 @@ export default {
|
||||
],
|
||||
marketSearch: "",
|
||||
|
||||
commonStore: useCommonStore()
|
||||
commonStore: useCommonStore(),
|
||||
|
||||
filterKeys: ['name', 'desc', 'author']
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -231,8 +233,9 @@ export default {
|
||||
}
|
||||
const search = this.marketSearch.toLowerCase();
|
||||
return this.pluginMarketData.filter(plugin =>
|
||||
plugin.name.toLowerCase().includes(search)
|
||||
);
|
||||
this.filterKeys.some(key =>
|
||||
plugin[key]?.toLowerCase().includes(search)
|
||||
));
|
||||
},
|
||||
pinnedPlugins() {
|
||||
return this.pluginMarketData.filter(plugin => plugin?.pinned);
|
||||
@@ -354,6 +357,7 @@ export default {
|
||||
this.upload_file = "";
|
||||
this.onLoadingDialogResult(1, res.data.message);
|
||||
this.dialog = false;
|
||||
this.getExtensions();
|
||||
// this.$refs.wfr.check();
|
||||
}).catch((err) => {
|
||||
this.loading_ = false;
|
||||
@@ -377,6 +381,7 @@ export default {
|
||||
this.extension_url = "";
|
||||
this.onLoadingDialogResult(1, res.data.message);
|
||||
this.dialog = false;
|
||||
this.getExtensions();
|
||||
// this.$refs.wfr.check();
|
||||
}).catch((err) => {
|
||||
this.loading_ = false;
|
||||
|
||||
@@ -46,7 +46,7 @@ const filteredExtensions = computed(() => {
|
||||
if (showReserved.value) {
|
||||
return extension_data.data;
|
||||
}
|
||||
return extension_data.data.filter(ext => !ext.reserved);
|
||||
return extension_data?.data?.filter(ext => !ext.reserved);
|
||||
});
|
||||
|
||||
// 方法
|
||||
|
||||
@@ -1,267 +1,305 @@
|
||||
<template>
|
||||
<v-card style="height: 100%;">
|
||||
<v-card-text style="padding: 32px; height: 100%;">
|
||||
<div class="platform-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- 页面标题 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 font-weight-bold mb-2">
|
||||
<v-icon size="x-large" color="primary" class="me-2">mdi-connection</v-icon>平台适配器管理
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
管理机器人的平台适配器,连接到不同的聊天平台
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn class="flex-grow-1" variant="tonal" @click="new_platform_dialog = true" size="large"
|
||||
rounded="lg" v-bind="props" color="primary">
|
||||
<template v-slot:default>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
新增平台适配器
|
||||
</template>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
|
||||
<v-list-item
|
||||
v-for="(item, index) in metadata['platform_group']['metadata']['platform'].config_template"
|
||||
:key="index" rounded="xl" :value="index">
|
||||
<v-list-item-title>{{ index }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-row style="margin-top: 16px;">
|
||||
<v-col v-for="(platform, index) in config_data['platform']" :key="index" cols="12" md="6" lg="3">
|
||||
<v-card class="fade-in"
|
||||
style="margin-bottom: 16px; min-height: 250px; max-height: 250px; display: flex; justify-content: space-between; flex-direction: column;">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h4">{{ platform.id }}</span>
|
||||
<v-switch color="primary" hide-details density="compact" v-model="platform['enable']"
|
||||
@update:modelValue="platformStatusChange(platform)"></v-switch>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div>
|
||||
<span style="font-size:12px">适配器类型: </span>
|
||||
<v-chip size="small" color="primary" text>{{ platform.type }}</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end">
|
||||
<v-btn color="error" text @click="deletePlatform(platform.id);">
|
||||
删除
|
||||
</v-btn>
|
||||
<v-btn color="blue-darken-1" text
|
||||
@click="updatingMode = true; showPlatformCfg = true; newSelectedPlatformConfig = platform; newSelectedPlatformName = platform.id">
|
||||
配置
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-dialog v-model="showPlatformCfg">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="text-h4">{{ newSelectedPlatformName }} 配置</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<AstrBotConfig :iterable="newSelectedPlatformConfig"
|
||||
:metadata="metadata['platform_group']['metadata']" metadataKey="platform" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
刷新
|
||||
</v-btn>
|
||||
<iframe v-show="!iframeLoading"
|
||||
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
|
||||
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%;">
|
||||
</iframe>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<!-- 平台适配器部分 -->
|
||||
<v-card class="mb-6" elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-apps</v-icon>
|
||||
<span class="text-h6">平台适配器</span>
|
||||
<v-chip color="info" size="small" class="ml-2">{{ config_data.platform?.length || 0 }}</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
|
||||
新增适配器
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
|
||||
<v-list-item
|
||||
v-for="(item, index) in metadata['platform_group']?.metadata?.platform?.config_template || {}"
|
||||
:key="index"
|
||||
rounded="xl"
|
||||
:value="index"
|
||||
>
|
||||
<v-list-item-title>{{ index }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-title>
|
||||
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="newPlatform" :loading="loading">
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
|
||||
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray"
|
||||
@click="showConsole = !showConsole">
|
||||
<template v-slot:default>
|
||||
<v-icon>mdi-console-line</v-icon>
|
||||
{{ showConsole ? '隐藏' : '显示' }}日志
|
||||
</template>
|
||||
</v-btn>
|
||||
|
||||
<div v-if="showConsole" style="margin-top: 32px">
|
||||
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="px-4 py-3">
|
||||
<item-card-grid
|
||||
:items="config_data.platform || []"
|
||||
title-field="id"
|
||||
enabled-field="enable"
|
||||
empty-icon="mdi-connection"
|
||||
empty-text="暂无平台适配器,点击 新增适配器 添加"
|
||||
@toggle-enabled="platformStatusChange"
|
||||
@delete="deletePlatform"
|
||||
@edit="editPlatform"
|
||||
>
|
||||
<template v-slot:item-details="{ item }">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
适配器类型:
|
||||
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.token" class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">Token: ••••••••</span>
|
||||
</div>
|
||||
<div v-if="item.description" class="d-flex align-center">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-information-outline</v-icon>
|
||||
<span class="text-caption text-medium-emphasis text-truncate">{{ item.description }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</item-card-grid>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
|
||||
{{ save_message }}
|
||||
<!-- 日志部分 -->
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
|
||||
<span class="text-h6">平台日志</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
|
||||
{{ showConsole ? '收起' : '展开' }}
|
||||
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card-text class="pa-0" v-if="showConsole">
|
||||
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="showPlatformCfg" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3">
|
||||
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
|
||||
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedPlatformName }} 平台适配器</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<v-row>
|
||||
<v-col cols="12" md="8">
|
||||
<AstrBotConfig :iterable="newSelectedPlatformConfig"
|
||||
:metadata="metadata['platform_group']?.metadata"
|
||||
metadataKey="platform" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
刷新
|
||||
</v-btn>
|
||||
<iframe v-show="!iframeLoading"
|
||||
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
|
||||
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%; min-height: 400px;">
|
||||
</iframe>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showPlatformCfg = false" :disabled="loading">
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="newPlatform" :loading="loading">
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
|
||||
export default {
|
||||
name: 'PlatformPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config_data: {},
|
||||
fetched: false,
|
||||
metadata: {},
|
||||
showPlatformCfg: false,
|
||||
name: 'PlatformPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer,
|
||||
ItemCardGrid
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config_data: {},
|
||||
fetched: false,
|
||||
metadata: {},
|
||||
showPlatformCfg: false,
|
||||
|
||||
newSelectedPlatformName: '',
|
||||
newSelectedPlatformConfig: {},
|
||||
updatingMode: false,
|
||||
newSelectedPlatformName: '',
|
||||
newSelectedPlatformConfig: {},
|
||||
updatingMode: false,
|
||||
|
||||
loading: false,
|
||||
loading: false,
|
||||
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "",
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "success",
|
||||
|
||||
showConsole: false,
|
||||
iframeLoading: true,
|
||||
|
||||
store: useCommonStore()
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshIframe() {
|
||||
this.iframeLoading = true;
|
||||
const iframe = document.querySelector('iframe');
|
||||
console.log(iframe.src);
|
||||
iframe.src = iframe.src + '?t=' + new Date().getTime();
|
||||
},
|
||||
getConfig() {
|
||||
// 获取配置
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
},
|
||||
|
||||
addFromDefaultConfigTmpl(index) {
|
||||
// 从默认配置模板中添加
|
||||
console.log(index);
|
||||
this.newSelectedPlatformName = index[0];
|
||||
this.showPlatformCfg = true;
|
||||
this.updatingMode = false;
|
||||
this.newSelectedPlatformConfig = this.metadata['platform_group']['metadata']['platform'].config_template[index[0]];
|
||||
},
|
||||
|
||||
newPlatform() {
|
||||
// 新建或者更新平台
|
||||
this.loading = true;
|
||||
if (this.updatingMode) {
|
||||
axios.post('/api/config/platform/update', {
|
||||
id: this.newSelectedPlatformName,
|
||||
config: this.newSelectedPlatformConfig
|
||||
}).then((res) => {
|
||||
this.loading = false;
|
||||
this.showPlatformCfg = false;
|
||||
this.getConfig();
|
||||
this.$refs.wfr.check();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
this.updatingMode = false;
|
||||
} else {
|
||||
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
|
||||
this.loading = false;
|
||||
this.showPlatformCfg = false;
|
||||
this.getConfig();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deletePlatform(platform_id) {
|
||||
// 删除平台
|
||||
axios.post('/api/config/platform/delete', { id: platform_id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.$refs.wfr.check();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
},
|
||||
|
||||
platformStatusChange(platform) {
|
||||
// 平台状态改变
|
||||
axios.post('/api/config/platform/update', {
|
||||
id: platform.id,
|
||||
config: platform
|
||||
}).then((res) => {
|
||||
this.getConfig();
|
||||
this.$refs.wfr.check();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
}
|
||||
showConsole: false,
|
||||
iframeLoading: true,
|
||||
|
||||
store: useCommonStore()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshIframe() {
|
||||
this.iframeLoading = true;
|
||||
const iframe = document.querySelector('iframe');
|
||||
iframe.src = iframe.src + '?t=' + new Date().getTime();
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
this.showError(err);
|
||||
});
|
||||
},
|
||||
|
||||
addFromDefaultConfigTmpl(index) {
|
||||
this.newSelectedPlatformName = index[0];
|
||||
this.showPlatformCfg = true;
|
||||
this.updatingMode = false;
|
||||
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(
|
||||
this.metadata['platform_group']?.metadata?.platform?.config_template[index[0]] || {}
|
||||
));
|
||||
},
|
||||
|
||||
editPlatform(platform) {
|
||||
this.newSelectedPlatformName = platform.id;
|
||||
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(platform));
|
||||
this.updatingMode = true;
|
||||
this.showPlatformCfg = true;
|
||||
},
|
||||
|
||||
newPlatform() {
|
||||
this.loading = true;
|
||||
if (this.updatingMode) {
|
||||
axios.post('/api/config/platform/update', {
|
||||
id: this.newSelectedPlatformName,
|
||||
config: this.newSelectedPlatformConfig
|
||||
}).then((res) => {
|
||||
this.loading = false;
|
||||
this.showPlatformCfg = false;
|
||||
this.getConfig();
|
||||
this.$refs.wfr.check();
|
||||
this.showSuccess(res.data.message || "更新成功!");
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
this.updatingMode = false;
|
||||
} else {
|
||||
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
|
||||
this.loading = false;
|
||||
this.showPlatformCfg = false;
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || "添加成功!");
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deletePlatform(platform) {
|
||||
if (confirm(`确定要删除平台适配器 ${platform.id} 吗?`)) {
|
||||
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.$refs.wfr.check();
|
||||
this.showSuccess(res.data.message || "删除成功!");
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
platformStatusChange(platform) {
|
||||
platform.enable = !platform.enable; // 切换状态
|
||||
|
||||
axios.post('/api/config/platform/update', {
|
||||
id: platform.id,
|
||||
config: platform
|
||||
}).then((res) => {
|
||||
this.getConfig();
|
||||
this.$refs.wfr.check();
|
||||
this.showSuccess(res.data.message || "状态更新成功!");
|
||||
}).catch((err) => {
|
||||
platform.enable = !platform.enable; // 发生错误时回滚状态
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
<style scoped>
|
||||
.platform-page {
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,238 +1,329 @@
|
||||
<template>
|
||||
<v-card style="height: 100%;">
|
||||
<v-card-text style="padding: 32px; height: 100%;">
|
||||
<div class="provider-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- 页面标题 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 font-weight-bold mb-2">
|
||||
<v-icon size="x-large" color="primary" class="me-2">mdi-creation</v-icon>服务提供商管理
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
管理AI服务提供商,连接到不同的大语言模型
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn class="flex-grow-1" variant="tonal" @click="new_provider_dialog = true" size="large"
|
||||
rounded="lg" v-bind="props" color="primary">
|
||||
<template v-slot:default>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
新增服务提供商
|
||||
</template>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
|
||||
<v-list-item
|
||||
v-for="(item, index) in metadata['provider_group']['metadata']['provider'].config_template"
|
||||
:key="index" rounded="xl" :value="index">
|
||||
<v-list-item-title>{{ index }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-row style="margin-top: 16px;">
|
||||
<v-col v-for="(provider, index) in config_data['provider']" :key="index" cols="12" md="6" lg="3">
|
||||
<v-card class="fade-in" style="margin-bottom: 16px; min-height: 250px; max-height: 250px; display: flex; justify-content: space-between; flex-direction: column;">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h4">{{ provider.id }}</span>
|
||||
<v-switch color="primary" hide-details density="compact" v-model="provider['enable']"
|
||||
@update:modelValue="providerStatusChange(provider)"></v-switch>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div>
|
||||
<span style="font-size:12px">适配器类型: </span> <v-chip size="small" color="primary" text>{{ provider.type }}</v-chip>
|
||||
</div>
|
||||
<div v-if="provider?.api_base" style="margin-top: 8px;">
|
||||
<span style="font-size:12px">API Base: </span> <v-chip size="small" color="primary" text>{{ provider?.api_base }}</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end">
|
||||
<v-btn color="error" text @click="deleteprovider(provider.id);">
|
||||
删除
|
||||
</v-btn>
|
||||
<v-btn color="blue-darken-1" text
|
||||
@click="updatingMode = true; showproviderCfg = true; newSelectedproviderConfig = provider; newSelectedproviderName = provider.id">
|
||||
配置
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-dialog v-model="showproviderCfg" width="700">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="text-h4">{{ newSelectedproviderName }} 配置</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<AstrBotConfig :iterable="newSelectedproviderConfig"
|
||||
:metadata="metadata['provider_group']['metadata']" metadataKey="provider" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="newprovider" :loading="loading">
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<!-- 服务提供商部分 -->
|
||||
<v-card class="mb-6" elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-api</v-icon>
|
||||
<span class="text-h6">服务提供商</span>
|
||||
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
|
||||
新增服务提供商
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
|
||||
<v-list-item
|
||||
v-for="(item, index) in metadata['provider_group']?.metadata?.provider?.config_template || {}"
|
||||
:key="index"
|
||||
rounded="xl"
|
||||
:value="index"
|
||||
>
|
||||
<v-list-item-title>{{ index }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-title>
|
||||
|
||||
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray" @click="showConsole = !showConsole">
|
||||
<template v-slot:default>
|
||||
<v-icon>mdi-console-line</v-icon>
|
||||
{{ showConsole ? '隐藏' : '显示' }}日志
|
||||
</template>
|
||||
</v-btn>
|
||||
|
||||
<div v-if="showConsole" style="margin-top: 32px">
|
||||
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="px-4 py-3">
|
||||
<item-card-grid
|
||||
:items="config_data.provider || []"
|
||||
title-field="id"
|
||||
enabled-field="enable"
|
||||
empty-icon="mdi-api-off"
|
||||
empty-text="暂无服务提供商,点击 新增服务提供商 添加"
|
||||
@toggle-enabled="providerStatusChange"
|
||||
@delete="deleteProvider"
|
||||
@edit="configExistingProvider"
|
||||
>
|
||||
<template v-slot:item-details="{ item }">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
提供商类型:
|
||||
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.api_base" class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-web</v-icon>
|
||||
<span class="text-caption text-medium-emphasis text-truncate" :title="item.api_base">
|
||||
API Base: {{ item.api_base }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.api_key" class="d-flex align-center">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">API Key: ••••••••</span>
|
||||
</div>
|
||||
</template>
|
||||
</item-card-grid>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
|
||||
{{ save_message }}
|
||||
<!-- 日志部分 -->
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
|
||||
<span class="text-h6">服务日志</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
|
||||
{{ showConsole ? '收起' : '展开' }}
|
||||
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card-text class="pa-0" v-if="showConsole">
|
||||
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="showProviderCfg" width="900" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3">
|
||||
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
|
||||
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<AstrBotConfig
|
||||
:iterable="newSelectedProviderConfig"
|
||||
:metadata="metadata['provider_group']?.metadata"
|
||||
metadataKey="provider"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="newProvider" :loading="loading">
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
|
||||
|
||||
export default {
|
||||
name: 'ProviderPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config_data: {},
|
||||
fetched: false,
|
||||
metadata: {},
|
||||
showproviderCfg: false,
|
||||
name: 'ProviderPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer,
|
||||
ItemCardGrid
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config_data: {},
|
||||
fetched: false,
|
||||
metadata: {},
|
||||
showProviderCfg: false,
|
||||
|
||||
newSelectedproviderName: '',
|
||||
newSelectedproviderConfig: {},
|
||||
updatingMode: false,
|
||||
newSelectedProviderName: '',
|
||||
newSelectedProviderConfig: {},
|
||||
updatingMode: false,
|
||||
|
||||
loading: false,
|
||||
loading: false,
|
||||
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "",
|
||||
|
||||
showConsole: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getConfig() {
|
||||
// 获取配置
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
save_message = err;
|
||||
save_message_snack = true;
|
||||
save_message_success = "error";
|
||||
});
|
||||
},
|
||||
|
||||
addFromDefaultConfigTmpl(index) {
|
||||
// 从默认配置模板中添加
|
||||
console.log(index);
|
||||
this.newSelectedproviderName = index[0];
|
||||
this.showproviderCfg = true;
|
||||
this.updatingMode = false;
|
||||
this.newSelectedproviderConfig = this.metadata['provider_group']['metadata']['provider'].config_template[index[0]];
|
||||
},
|
||||
|
||||
newprovider() {
|
||||
// 新建或者更新平台
|
||||
this.loading = true;
|
||||
if (this.updatingMode) {
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: this.newSelectedproviderName,
|
||||
config: this.newSelectedproviderConfig
|
||||
}).then((res) => {
|
||||
this.loading = false;
|
||||
this.showproviderCfg = false;
|
||||
this.getConfig();
|
||||
// this.$refs.wfr.check();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
this.updatingMode = false;
|
||||
} else {
|
||||
axios.post('/api/config/provider/new', this.newSelectedproviderConfig).then((res) => {
|
||||
this.loading = false;
|
||||
this.showproviderCfg = false;
|
||||
this.getConfig();
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteprovider(provider_id) {
|
||||
// 删除平台
|
||||
axios.post('/api/config/provider/delete', { id: provider_id }).then((res) => {
|
||||
this.getConfig();
|
||||
// this.$refs.wfr.check();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
},
|
||||
|
||||
providerStatusChange(provider) {
|
||||
// 平台状态改变
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: provider.id,
|
||||
config: provider
|
||||
}).then((res) => {
|
||||
this.getConfig();
|
||||
// this.$refs.wfr.check();
|
||||
this.save_message = res.data.message;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "success";
|
||||
}).catch((err) => {
|
||||
this.save_message = err;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
}
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "success",
|
||||
|
||||
showConsole: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getConfig() {
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
addFromDefaultConfigTmpl(index) {
|
||||
this.newSelectedProviderName = index[0];
|
||||
this.showProviderCfg = true;
|
||||
this.updatingMode = false;
|
||||
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
|
||||
this.metadata['provider_group']?.metadata?.provider?.config_template[index[0]] || {}
|
||||
));
|
||||
},
|
||||
|
||||
configExistingProvider(provider) {
|
||||
this.newSelectedProviderName = provider.id;
|
||||
this.newSelectedProviderConfig = {};
|
||||
|
||||
// 比对默认配置模版,看看是否有更新
|
||||
let templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
let defaultConfig = {};
|
||||
for (let key in templates) {
|
||||
if (templates[key]?.type === provider.type) {
|
||||
defaultConfig = templates[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const mergeConfigWithOrder = (target, source, reference) => {
|
||||
// 首先复制所有source中的属性到target
|
||||
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
||||
for (let key in source) {
|
||||
if (source.hasOwnProperty(key)) {
|
||||
if (typeof source[key] === 'object' && source[key] !== null) {
|
||||
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 然后根据reference的结构添加或覆盖属性
|
||||
for (let key in reference) {
|
||||
if (typeof reference[key] === 'object' && reference[key] !== null) {
|
||||
if (!(key in target)) {
|
||||
target[key] = Array.isArray(reference[key]) ? [] : {};
|
||||
}
|
||||
mergeConfigWithOrder(
|
||||
target[key],
|
||||
source && source[key] ? source[key] : {},
|
||||
reference[key]
|
||||
);
|
||||
} else if (!(key in target)) {
|
||||
// 只有当target中不存在该键时才从reference复制
|
||||
target[key] = reference[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (defaultConfig) {
|
||||
mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig);
|
||||
}
|
||||
|
||||
this.showProviderCfg = true;
|
||||
this.updatingMode = true;
|
||||
},
|
||||
|
||||
newProvider() {
|
||||
this.loading = true;
|
||||
if (this.updatingMode) {
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: this.newSelectedProviderName,
|
||||
config: this.newSelectedProviderConfig
|
||||
}).then((res) => {
|
||||
this.loading = false;
|
||||
this.showProviderCfg = false;
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || "更新成功!");
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
this.updatingMode = false;
|
||||
} else {
|
||||
axios.post('/api/config/provider/new', this.newSelectedProviderConfig).then((res) => {
|
||||
this.loading = false;
|
||||
this.showProviderCfg = false;
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || "添加成功!");
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider(provider) {
|
||||
if (confirm(`确定要删除服务提供商 ${provider.id} 吗?`)) {
|
||||
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || "删除成功!");
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
providerStatusChange(provider) {
|
||||
provider.enable = !provider.enable; // 切换状态
|
||||
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: provider.id,
|
||||
config: provider
|
||||
}).then((res) => {
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || "状态更新成功!");
|
||||
}).catch((err) => {
|
||||
provider.enable = !provider.enable; // 发生错误时回滚状态
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
<style scoped>
|
||||
.provider-page {
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
<v-list lines="two">
|
||||
<v-list-subheader>网络</v-list-subheader>
|
||||
|
||||
<v-list-item subtitle="设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效" title="GitHub 加速地址">
|
||||
<v-list-item subtitle="设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。" title="GitHub 加速地址">
|
||||
|
||||
<v-combobox variant="outlined" style="width: 100%; margin-top: 16px;" v-model="selectedGitHubProxy" :items="githubProxies"
|
||||
label="选择 GitHub 加速地址">
|
||||
@@ -41,11 +41,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
githubProxies: [
|
||||
"https://ghproxy.cn",
|
||||
"https://gh.llkk.cc",
|
||||
"https://ghproxy.net",
|
||||
"https://gitproxy.click",
|
||||
"https://github.tbedu.top"
|
||||
],
|
||||
selectedGitHubProxy: "",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
<template>
|
||||
<div class="tools-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- 页面标题 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 font-weight-bold mb-2">
|
||||
<v-icon size="x-large" color="primary" class="me-2">mdi-function-variant</v-icon>函数工具管理
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
|
||||
管理 MCP 服务器和查看可用的函数工具
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon
|
||||
v-bind="props"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="ms-1 cursor-pointer"
|
||||
@click="openurl('https://astrbot.app/use/function-calling.html')"
|
||||
>
|
||||
mdi-information
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>函数调用和 MCP 是什么?</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- MCP 服务器部分 -->
|
||||
<v-card class="mb-6" elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-server</v-icon>
|
||||
<span class="text-h6">MCP 服务器</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showMcpServerDialog = true">
|
||||
新增服务器
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="px-4 py-3">
|
||||
<v-row v-if="mcpServers.length === 0">
|
||||
<v-col cols="12" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
|
||||
<p class="text-grey mt-4">暂无 MCP 服务器,点击"新增服务器"添加</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col v-for="(server, index) in mcpServers" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||
<v-card class="server-card hover-elevation" :color="server.active ? '' : 'grey-lighten-4'">
|
||||
<div class="server-status-indicator" :class="{'active': server.active}"></div>
|
||||
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
|
||||
<span class="text-h6 text-truncate" :title="server.name">{{ server.name }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-switch color="primary" hide-details density="compact" v-model="server.active"
|
||||
v-bind="props" @update:modelValue="updateServerStatus(server)"></v-switch>
|
||||
</template>
|
||||
<span>{{ server.active ? '已启用' : '已禁用' }}</span>
|
||||
</v-tooltip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
|
||||
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(server)">
|
||||
{{ getServerConfigSummary(server) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="server.tools && server.tools.length > 0">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">可用工具 ({{ server.tools.length }})</span>
|
||||
</div>
|
||||
<v-chip-group class="tool-chips">
|
||||
<v-chip v-for="(tool, idx) in server.tools" :key="idx" size="x-small"
|
||||
density="compact" color="info" class="text-caption">
|
||||
{{ tool }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
<div v-else class="text-caption text-medium-emphasis mt-2">
|
||||
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
|
||||
无可用工具
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-2">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" size="small" color="error" prepend-icon="mdi-delete"
|
||||
@click="deleteServer(server.name)">
|
||||
删除
|
||||
</v-btn>
|
||||
<v-btn variant="text" size="small" color="primary" prepend-icon="mdi-pencil"
|
||||
@click="editServer(server)">
|
||||
编辑
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 函数工具部分 -->
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-function</v-icon>
|
||||
<span class="text-h6">函数工具</span>
|
||||
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="primary" @click="showTools = !showTools">
|
||||
{{ showTools ? '收起' : '展开' }}
|
||||
<v-icon>{{ showTools ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card-text class="pa-3" v-if="showTools">
|
||||
<div v-if="tools.length === 0" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-4">没有可用的函数工具</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<v-text-field
|
||||
v-model="toolSearch"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="搜索函数工具"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
hide-details
|
||||
clearable
|
||||
></v-text-field>
|
||||
|
||||
<v-expansion-panels v-model="openedPanel" multiple>
|
||||
<v-expansion-panel
|
||||
v-for="(tool, index) in filteredTools"
|
||||
:key="index"
|
||||
:value="index"
|
||||
class="mb-2 tool-panel"
|
||||
rounded="lg"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<v-row no-gutters align="center">
|
||||
<v-col cols="3">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="me-2" size="small">
|
||||
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
|
||||
</v-icon>
|
||||
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
|
||||
:title="tool.function.name">
|
||||
{{ formatToolName(tool.function.name) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="9" class="text-grey">
|
||||
{{ tool.function.description }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<p class="text-body-1 font-weight-medium mb-3">
|
||||
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
|
||||
功能描述
|
||||
</p>
|
||||
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
|
||||
|
||||
<template v-if="tool.function.parameters && tool.function.parameters.properties">
|
||||
<p class="text-body-1 font-weight-medium mb-3">
|
||||
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
|
||||
参数列表
|
||||
</p>
|
||||
|
||||
<v-table density="compact" class="params-table mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>描述</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(param, paramName) in tool.function.parameters.properties" :key="paramName">
|
||||
<td class="font-weight-medium">{{ paramName }}</td>
|
||||
<td>
|
||||
<v-chip size="x-small" color="primary" text class="text-caption">
|
||||
{{ param.type }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ param.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</template>
|
||||
<div v-else class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
|
||||
<p>此工具没有参数</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- 添加/编辑 MCP 服务器对话框 -->
|
||||
<v-dialog v-model="showMcpServerDialog" max-width="750px" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3">
|
||||
<v-icon color="white" class="me-2">{{ isEditMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
|
||||
<span>{{ isEditMode ? '编辑' : '新增' }} MCP 服务器</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<v-form @submit.prevent="saveServer" ref="form">
|
||||
<v-text-field
|
||||
v-model="currentServer.name"
|
||||
label="服务器名称"
|
||||
variant="outlined"
|
||||
:rules="[v => !!v || '名称是必填项']"
|
||||
required
|
||||
class="mb-3"
|
||||
></v-text-field>
|
||||
|
||||
<v-switch
|
||||
v-model="currentServer.active"
|
||||
label="启用服务器"
|
||||
color="primary"
|
||||
hide-details
|
||||
class="mb-3"
|
||||
></v-switch>
|
||||
|
||||
<div class="mb-2 d-flex align-center">
|
||||
<span class="text-subtitle-1">服务器配置</span>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" class="ms-2" size="small" color="primary">mdi-information</v-icon>
|
||||
</template>
|
||||
<div>
|
||||
<p class="mb-1">MCP 服务器(stdio)配置支持以下字段:</p>
|
||||
<p class="mb-1"><code>command</code>: 命令名称 (例如 python 或 uv)</p>
|
||||
<p class="mb-1"><code>args</code>: 命令参数数组 (例如 ["run", "server.py"])</p>
|
||||
<p class="mb-1"><code>env</code>: 环境变量对象 (例如 {"api_key": "abc"})</p>
|
||||
<p class="mb-1"><code>cwd</code>: 工作目录路径 (例如 /path/to/server)</p>
|
||||
<p class="mb-1"><code>encoding</code>: 输出编码 (默认 utf-8)</p>
|
||||
<p class="mb-1"><code>encoding_error_handler</code>: The text encoding error handler. Defaults to strict.</p>
|
||||
<p class="mb-1">其他字段请参考 MCP 文档</p>
|
||||
<p class="mb-1">⚠️ 如果您使用 Docker 部署 AstrBot, 请务必将 MCP 服务器装在 AstrBot 挂载好的 data 目录下</p>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="info"
|
||||
variant="text"
|
||||
@click="setConfigTemplate"
|
||||
class="me-1"
|
||||
>
|
||||
使用模板
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="monaco-container">
|
||||
<VueMonacoEditor
|
||||
v-model:value="serverConfigJson"
|
||||
theme="vs-dark"
|
||||
language="json"
|
||||
:options="{
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: true,
|
||||
tabSize: 2
|
||||
}"
|
||||
@change="validateJson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="jsonError" class="mt-2 text-error">
|
||||
<v-icon color="error" size="small" class="me-1">mdi-alert-circle</v-icon>
|
||||
<span>{{ jsonError }}</span>
|
||||
</div>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="closeServerDialog" :disabled="loading">
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveServer"
|
||||
:loading="loading"
|
||||
:disabled="!isServerFormValid"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
|
||||
|
||||
export default {
|
||||
name: 'ToolUsePage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
VueMonacoEditor
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mcpServers: [],
|
||||
tools: [],
|
||||
showMcpServerDialog: false,
|
||||
showTools: true,
|
||||
loading: false,
|
||||
isEditMode: false,
|
||||
serverConfigJson: '',
|
||||
jsonError: null,
|
||||
currentServer: {
|
||||
name: '',
|
||||
active: true,
|
||||
tools: []
|
||||
},
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "success",
|
||||
toolSearch: '',
|
||||
openedPanel: [], // 存储打开的面板索引
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredTools() {
|
||||
if (!this.toolSearch) return this.tools;
|
||||
|
||||
const searchTerm = this.toolSearch.toLowerCase();
|
||||
return this.tools.filter(tool =>
|
||||
tool.function.name.toLowerCase().includes(searchTerm) ||
|
||||
tool.function.description.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
},
|
||||
|
||||
isServerFormValid() {
|
||||
return !!this.currentServer.name && !this.jsonError;
|
||||
},
|
||||
|
||||
// 显示服务器配置的文本摘要
|
||||
getServerConfigSummary() {
|
||||
return (server) => {
|
||||
if (server.command) {
|
||||
return `${server.command} ${(server.args || []).join(' ')}`;
|
||||
}
|
||||
|
||||
// 如果没有command字段,尝试显示其他有意义的配置信息
|
||||
const configKeys = Object.keys(server).filter(key =>
|
||||
!['name', 'active', 'tools'].includes(key)
|
||||
);
|
||||
|
||||
if (configKeys.length > 0) {
|
||||
return `配置: ${configKeys.join(', ')}`;
|
||||
}
|
||||
|
||||
return '未设置配置';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
},
|
||||
|
||||
methods: {
|
||||
openurl(url) {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
formatToolName(name) {
|
||||
if (name.includes(':')) {
|
||||
// MCP 工具通常命名为 mcp:server:tool
|
||||
const parts = name.split(':');
|
||||
return parts[parts.length - 1]; // 返回最后一部分
|
||||
}
|
||||
return name;
|
||||
},
|
||||
|
||||
getServers() {
|
||||
axios.get('/api/tools/mcp/servers')
|
||||
.then(response => {
|
||||
this.mcpServers = response.data.data || [];
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError("获取 MCP 服务器列表失败: " + error.message);
|
||||
});
|
||||
},
|
||||
|
||||
getTools() {
|
||||
axios.get('/api/config/llmtools')
|
||||
.then(response => {
|
||||
this.tools = response.data.data || [];
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError("获取函数工具列表失败: " + error.message);
|
||||
});
|
||||
},
|
||||
|
||||
validateJson() {
|
||||
try {
|
||||
if (!this.serverConfigJson.trim()) {
|
||||
this.jsonError = '配置不能为空';
|
||||
return false;
|
||||
}
|
||||
|
||||
JSON.parse(this.serverConfigJson);
|
||||
this.jsonError = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.jsonError = `JSON 格式错误: ${e.message}`;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setConfigTemplate() {
|
||||
// 设置一个基本的配置模板
|
||||
const template = {
|
||||
command: "python",
|
||||
args: ["-m", "your_module"],
|
||||
// 可以添加其他 MCP 支持的配置项
|
||||
};
|
||||
|
||||
this.serverConfigJson = JSON.stringify(template, null, 2);
|
||||
},
|
||||
|
||||
saveServer() {
|
||||
if (!this.validateJson()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
// 解析JSON配置并与基本信息合并
|
||||
try {
|
||||
const configObj = JSON.parse(this.serverConfigJson);
|
||||
|
||||
// 创建要发送的完整配置对象
|
||||
const serverData = {
|
||||
name: this.currentServer.name,
|
||||
active: this.currentServer.active,
|
||||
...configObj
|
||||
};
|
||||
|
||||
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
|
||||
|
||||
axios.post(endpoint, serverData)
|
||||
.then(response => {
|
||||
this.loading = false;
|
||||
this.showMcpServerDialog = false;
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
this.showSuccess(response.data.message || "保存成功!");
|
||||
this.resetForm();
|
||||
})
|
||||
.catch(error => {
|
||||
this.loading = false;
|
||||
this.showError("保存失败: " + (error.response?.data?.message || error.message));
|
||||
});
|
||||
} catch (e) {
|
||||
this.loading = false;
|
||||
this.showError(`JSON 解析错误: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
deleteServer(serverName) {
|
||||
if (confirm(`确定要删除服务器 ${serverName} 吗?`)) {
|
||||
axios.post('/api/tools/mcp/delete', { name: serverName })
|
||||
.then(response => {
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
this.showSuccess(response.data.message || "删除成功!");
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError("删除失败: " + (error.response?.data?.message || error.message));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
editServer(server) {
|
||||
// 创建一个不包含基本字段的配置对象副本
|
||||
const configCopy = { ...server };
|
||||
|
||||
// 移除基本字段,只保留配置相关字段
|
||||
delete configCopy.name;
|
||||
delete configCopy.active;
|
||||
delete configCopy.tools;
|
||||
|
||||
// 设置当前服务器的基本信息
|
||||
this.currentServer = {
|
||||
name: server.name,
|
||||
active: server.active,
|
||||
tools: server.tools || []
|
||||
};
|
||||
|
||||
// 将剩余配置转换为JSON字符串
|
||||
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
|
||||
|
||||
this.isEditMode = true;
|
||||
this.showMcpServerDialog = true;
|
||||
},
|
||||
|
||||
updateServerStatus(server) {
|
||||
axios.post('/api/tools/mcp/update', server)
|
||||
.then(response => {
|
||||
this.getServers();
|
||||
this.showSuccess(response.data.message || "更新成功!");
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError("更新失败: " + (error.response?.data?.message || error.message));
|
||||
// 回滚状态
|
||||
server.active = !server.active;
|
||||
});
|
||||
},
|
||||
|
||||
closeServerDialog() {
|
||||
this.showMcpServerDialog = false;
|
||||
this.resetForm();
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.currentServer = {
|
||||
name: '',
|
||||
active: true,
|
||||
tools: []
|
||||
};
|
||||
this.serverConfigJson = '';
|
||||
this.jsonError = null;
|
||||
this.isEditMode = false;
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-page {
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-status-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.server-status-indicator.active {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.hover-elevation:hover {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tool-chips {
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-panel:hover {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.params-table {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.params-table th {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.monaco-container {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,84 @@
|
||||
<template>
|
||||
<v-row style="margin: 2px;">
|
||||
<v-alert
|
||||
:type="noticeType"
|
||||
:text="noticeContent"
|
||||
:title="noticeTitle"
|
||||
v-if="noticeTitle && noticeContent"
|
||||
closable
|
||||
></v-alert>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" md="4">
|
||||
<TotalMessage :stat="stat" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<OnlinePlatform :stat="stat" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<OnlineTime :stat="stat" />
|
||||
</v-col>
|
||||
<v-col cols="12" lg="8">
|
||||
<MessageStat :stat="stat" />
|
||||
</v-col>
|
||||
<v-col cols="12" lg="4">
|
||||
<PlatformStat :stat="stat" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<h1 class="dashboard-title">控制台</h1>
|
||||
<div class="dashboard-subtitle">实时监控和统计数据</div>
|
||||
</div>
|
||||
|
||||
<v-slide-y-transition>
|
||||
<v-row v-if="noticeTitle && noticeContent" class="notice-row">
|
||||
<v-alert
|
||||
:type="noticeType"
|
||||
:text="noticeContent"
|
||||
:title="noticeTitle"
|
||||
closable
|
||||
class="dashboard-alert"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
></v-alert>
|
||||
</v-row>
|
||||
</v-slide-y-transition>
|
||||
|
||||
<!-- 主指标卡片行 -->
|
||||
<v-row class="stats-row">
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<TotalMessage :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<OnlinePlatform :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<RunningTime :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<MemoryUsage :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 图表行 -->
|
||||
<v-row class="charts-row">
|
||||
<v-col cols="12" lg="8">
|
||||
<v-slide-y-transition>
|
||||
<MessageStat />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" lg="4">
|
||||
<v-slide-y-transition>
|
||||
<PlatformStat :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="dashboard-footer">
|
||||
<v-chip size="small" color="primary" variant="flat" prepend-icon="mdi-refresh">
|
||||
最后更新: {{ lastUpdated }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click="fetchData"
|
||||
:loading="isRefreshing"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import TotalMessage from './components/TotalMessage.vue';
|
||||
import OnlinePlatform from './components/OnlinePlatform.vue';
|
||||
import OnlineTime from './components/OnlineTime.vue';
|
||||
import RunningTime from './components/RunningTime.vue';
|
||||
import MemoryUsage from './components/MemoryUsage.vue';
|
||||
import MessageStat from './components/MessageStat.vue';
|
||||
import PlatformStat from './components/PlatformStat.vue';
|
||||
import axios from 'axios';
|
||||
@@ -41,7 +88,8 @@ export default {
|
||||
components: {
|
||||
TotalMessage,
|
||||
OnlinePlatform,
|
||||
OnlineTime,
|
||||
RunningTime,
|
||||
MemoryUsage,
|
||||
MessageStat,
|
||||
PlatformStat,
|
||||
},
|
||||
@@ -50,23 +98,142 @@ export default {
|
||||
noticeTitle: '',
|
||||
noticeContent: '',
|
||||
noticeType: '',
|
||||
lastUpdated: '加载中...',
|
||||
refreshInterval: null,
|
||||
isRefreshing: false
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
axios.get('/api/stat/get').then((res) => {
|
||||
this.stat = res.data.data;
|
||||
});
|
||||
|
||||
axios.get('https://api.soulter.top/astrbot-announcement').then((res) => {
|
||||
let data = res.data.data;
|
||||
// 如果 dashboard-notice 在其中
|
||||
if (data['dashboard-notice']) {
|
||||
this.noticeTitle = data['dashboard-notice'].title;
|
||||
this.noticeContent = data['dashboard-notice'].content;
|
||||
this.noticeType = data['dashboard-notice'].type;
|
||||
}
|
||||
});
|
||||
this.fetchData();
|
||||
this.fetchNotice();
|
||||
|
||||
// 设置自动刷新(每60秒)
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.fetchData();
|
||||
}, 60000);
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
// 清除定时器
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
const res = await axios.get('/api/stat/get');
|
||||
this.stat = res.data.data;
|
||||
this.lastUpdated = new Date().toLocaleTimeString();
|
||||
console.log('Dashboard data:', this.stat);
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchNotice() {
|
||||
axios.get('https://api.soulter.top/astrbot-announcement').then((res) => {
|
||||
let data = res.data.data;
|
||||
// 如果 dashboard-notice 在其中
|
||||
if (data['dashboard-notice']) {
|
||||
this.noticeTitle = data['dashboard-notice'].title;
|
||||
this.noticeContent = data['dashboard-notice'].content;
|
||||
this.noticeType = data['dashboard-notice'].type;
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('获取公告失败:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 16px;
|
||||
background-color: #f9fafc;
|
||||
min-height: calc(100vh - 64px);
|
||||
border-radius: 10px;
|
||||
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.notice-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dashboard-alert {
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.stats-row, .charts-row, .plugin-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.plugin-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.plugin-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="stat-card memory-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-memory" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">内存占用</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB / {{ stat.memory?.system || 0 }} MiB</span></h2>
|
||||
<v-chip :color="memoryStatus.color" size="x-small" class="status-chip">
|
||||
{{ memoryStatus.label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-container">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">CPU 负载</div>
|
||||
<div class="metric-value">{{ stat.cpu_percent || '0' }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MemoryUsage',
|
||||
props: ['stat'],
|
||||
computed: {
|
||||
memoryPercentage() {
|
||||
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
|
||||
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
|
||||
},
|
||||
memoryStatus() {
|
||||
const percentage = this.memoryPercentage;
|
||||
if (percentage < 30) {
|
||||
return { color: 'success', label: '良好' };
|
||||
} else if (percentage < 70) {
|
||||
return { color: 'warning', label: '正常' };
|
||||
} else {
|
||||
return { color: 'error', label: '偏高' };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.memory-card {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.memory-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metrics-container {
|
||||
display: flex;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +1,136 @@
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card elevation="0">
|
||||
<v-card variant="outlined">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="7">
|
||||
<span class="text-subtitle-2 text-disabled font-weight-bold">总消息趋势</span>
|
||||
<!-- <h3 class="text-h3 mt-1">{{ total_cnt }}</h3> -->
|
||||
</v-col>
|
||||
<v-col cols="12" sm="5">
|
||||
<v-select color="primary" variant="outlined" hide-details v-model="select" :items="items" item-title="state"
|
||||
item-value="abbr" label="Select" persistent-hint return-object single-line>
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="mt-4">
|
||||
<apexchart type="area" height="280" :options="chartOptions1" :series="lineChart1.series" ref="rtchart">
|
||||
</apexchart>
|
||||
<v-card elevation="1" class="chart-card">
|
||||
<v-card-text>
|
||||
<div class="chart-header">
|
||||
<div>
|
||||
<div class="chart-title">消息趋势分析</div>
|
||||
<div class="chart-subtitle">跟踪消息数量随时间的变化</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-select
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
v-model="selectedTimeRange"
|
||||
:items="timeRanges"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
class="time-select"
|
||||
@update:model-value="fetchMessageSeries"
|
||||
return-object
|
||||
single-line
|
||||
>
|
||||
<template v-slot:selection="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon start size="small">mdi-calendar-range</v-icon>
|
||||
{{ item.raw.label }}
|
||||
</div>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
|
||||
<div class="chart-stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">总消息数</div>
|
||||
<div class="stat-number">{{ totalMessages }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">平均每天</div>
|
||||
<div class="stat-number">{{ dailyAverage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box" :class="{'trend-up': growthRate > 0, 'trend-down': growthRate < 0}">
|
||||
<div class="stat-label">增长率</div>
|
||||
<div class="stat-number">
|
||||
<v-icon size="small" :icon="growthRate > 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'"></v-icon>
|
||||
{{ Math.abs(growthRate) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<div class="loading-text">加载中...</div>
|
||||
</div>
|
||||
<apexchart
|
||||
type="area"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="chartSeries"
|
||||
ref="chart"
|
||||
></apexchart>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'MessageStat',
|
||||
components: {
|
||||
},
|
||||
props: ['stat'],
|
||||
data: () => ({
|
||||
total_cnt: 0,
|
||||
select: { state: 'Today', abbr: 'FL' },
|
||||
items: [
|
||||
{ state: '过去 1 天', abbr: 'FL' },
|
||||
totalMessages: '0',
|
||||
dailyAverage: '0',
|
||||
growthRate: 0,
|
||||
loading: false,
|
||||
selectedTimeRange: { label: '过去 1 天', value: 86400 },
|
||||
timeRanges: [
|
||||
{ label: '过去 1 天', value: 86400 },
|
||||
{ label: '过去 3 天', value: 259200 },
|
||||
{ label: '过去 7 天', value: 604800 },
|
||||
{ label: '过去 30 天', value: 2592000 },
|
||||
],
|
||||
chartOptions1: {
|
||||
|
||||
chartOptions: {
|
||||
chart: {
|
||||
type: 'area',
|
||||
height: 400,
|
||||
fontFamily: `inherit`,
|
||||
foreColor: '#a1aab2',
|
||||
toolbar: {
|
||||
show: true,
|
||||
tools: {
|
||||
download: true,
|
||||
selection: false,
|
||||
zoom: true,
|
||||
zoomin: true,
|
||||
zoomout: true,
|
||||
pan: true,
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
},
|
||||
},
|
||||
colors: ['#5e35b1'],
|
||||
fill: {
|
||||
type: 'solid',
|
||||
opacity: 0.3,
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 1
|
||||
width: 2
|
||||
},
|
||||
markers: {
|
||||
size: 3,
|
||||
strokeWidth: 2,
|
||||
hover: {
|
||||
size: 5,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
fixed: {
|
||||
enabled: false
|
||||
},
|
||||
theme: 'light',
|
||||
x: {
|
||||
show: true,
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
},
|
||||
y: {
|
||||
@@ -75,45 +146,225 @@ export default {
|
||||
},
|
||||
labels: {
|
||||
formatter: function (value) {
|
||||
return new Date(value).toLocaleString();
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: '消息条数'
|
||||
}
|
||||
},
|
||||
min: function(min) {
|
||||
return min < 10 ? 0 : Math.floor(min * 0.8);
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true
|
||||
borderColor: '#f1f1f1',
|
||||
row: {
|
||||
colors: ['transparent', 'transparent'],
|
||||
opacity: 0.2
|
||||
},
|
||||
column: {
|
||||
colors: ['transparent', 'transparent'],
|
||||
},
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
lineChart1: {
|
||||
series: [
|
||||
{
|
||||
name: '消息条数',
|
||||
data: []
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
chartSeries: [
|
||||
{
|
||||
name: '消息条数',
|
||||
data: []
|
||||
}
|
||||
],
|
||||
|
||||
messageTimeSeries: []
|
||||
}),
|
||||
|
||||
watch: {
|
||||
stat: {
|
||||
handler: function (val, oldVal) {
|
||||
val = val.message_time_series
|
||||
// this.total_cnt = val.message_count
|
||||
// [[timestamp, cnt], ...]
|
||||
this.lineChart1.series[0].data = val.map((item) => {
|
||||
return [new Date(item[0]*1000).getTime(), item[1]];
|
||||
});
|
||||
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
mounted() {
|
||||
// 初始加载
|
||||
this.fetchMessageSeries();
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
methods: {
|
||||
formatNumber(num) {
|
||||
return new Intl.NumberFormat('zh-CN').format(num);
|
||||
},
|
||||
|
||||
async fetchMessageSeries() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/stat/get?offset_sec=${this.selectedTimeRange.value}`);
|
||||
const data = response.data.data;
|
||||
|
||||
if (data && data.message_time_series) {
|
||||
this.messageTimeSeries = data.message_time_series;
|
||||
this.processTimeSeriesData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取消息趋势数据失败:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
processTimeSeriesData() {
|
||||
// 转换数据为图表格式
|
||||
this.chartSeries[0].data = this.messageTimeSeries.map((item) => {
|
||||
return [new Date(item[0]*1000).getTime(), item[1]];
|
||||
});
|
||||
|
||||
// 计算总消息数
|
||||
let total = 0;
|
||||
this.messageTimeSeries.forEach(item => {
|
||||
total += item[1];
|
||||
});
|
||||
this.totalMessages = this.formatNumber(total);
|
||||
|
||||
// 计算日平均
|
||||
if (this.messageTimeSeries.length > 0) {
|
||||
const daysSpan = this.selectedTimeRange.value / 86400; // 将秒转换为天数
|
||||
this.dailyAverage = this.formatNumber(Math.round(total / daysSpan));
|
||||
}
|
||||
|
||||
// 计算增长率
|
||||
this.calculateGrowthRate();
|
||||
},
|
||||
|
||||
calculateGrowthRate() {
|
||||
if (this.messageTimeSeries.length < 4) {
|
||||
this.growthRate = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算前半部分和后半部分的消息总数
|
||||
const halfIndex = Math.floor(this.messageTimeSeries.length / 2);
|
||||
|
||||
const firstHalf = this.messageTimeSeries
|
||||
.slice(0, halfIndex)
|
||||
.reduce((sum, item) => sum + item[1], 0);
|
||||
|
||||
const secondHalf = this.messageTimeSeries
|
||||
.slice(halfIndex)
|
||||
.reduce((sum, item) => sum + item[1], 0);
|
||||
|
||||
// 计算增长率
|
||||
if (firstHalf > 0) {
|
||||
this.growthRate = Math.round(((secondHalf - firstHalf) / firstHalf) * 100);
|
||||
} else {
|
||||
this.growthRate = secondHalf > 0 ? 100 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
height: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chart-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.time-select {
|
||||
max-width: 150px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-up .stat-number {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trend-down .stat-number {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,84 @@
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card elevation="0" class="bg-primary overflow-hidden bubble-shape bubble-primary-shape">
|
||||
<v-card elevation="1" class="stat-card platform-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start mb-3">
|
||||
<v-btn icon rounded="sm" color="darkprimary" variant="flat">
|
||||
<v-icon icon="mdi-account-multiple-outline"></v-icon>
|
||||
</v-btn>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-server-network" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">消息平台</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ stat.platform_count || 0 }}</h2>
|
||||
</div>
|
||||
<div class="stat-subtitle">已连接的消息平台数量</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<h2 class="text-h1 font-weight-medium">
|
||||
{{ stat.platform_count }}
|
||||
</h2>
|
||||
<span class="text-subtitle-1 text-medium-emphasis text-white">消息平台数</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'TotalSession',
|
||||
props: ['stat'],
|
||||
data: () => ({
|
||||
stat: {
|
||||
platform_count: 0
|
||||
}
|
||||
}),
|
||||
name: 'OnlinePlatform',
|
||||
props: ['stat']
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +1,190 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<v-card elevation="0" class="bg-primary overflow-hidden bubble-shape-sm bubble-primary mb-6">
|
||||
<v-card-text class="pa-5">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<v-btn color="darkprimary" icon rounded="sm" variant="flat">
|
||||
<v-icon icon="mdi-clock"></v-icon>
|
||||
</v-btn>
|
||||
<div>
|
||||
<h4 class="text-h4 font-weight-medium">{{ stat.running }}</h4>
|
||||
<span class="text-subtitle-2 text-medium-emphasis text-white">运行时间</span>
|
||||
<div class="stats-container">
|
||||
<v-card elevation="1" class="stat-card uptime-card mb-4">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">运行时间</div>
|
||||
<h3 class="uptime-value">{{ stat.running || '加载中...' }}</h3>
|
||||
</div>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<div class="uptime-status">
|
||||
<v-icon icon="mdi-circle" size="10" color="success" class="blink-animation"></v-icon>
|
||||
<span class="status-text">在线</span>
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
<div>
|
||||
<v-btn icon rounded="sm" variant="plain">
|
||||
<v-icon color="black" icon="mdi-stop" size="32"></v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card elevation="0" class="bubble-shape-sm overflow-hidden bubble-warning">
|
||||
<v-card-text class="pa-5">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<v-btn color="lightwarning" icon rounded="sm" variant="flat">
|
||||
<v-icon icon="mdi-memory"></v-icon>
|
||||
</v-btn>
|
||||
<div>
|
||||
<h4 class="text-h4 font-weight-medium">{{ stat.memory?.process }} / {{ stat.memory?.system }} MiB</h4>
|
||||
|
||||
<span class="text-subtitle-2 text-disabled font-weight-medium">占用内存</span>
|
||||
<v-card elevation="1" class="stat-card memory-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-memory" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">内存占用</div>
|
||||
<div class="memory-values">
|
||||
<h3 class="memory-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB</span></h3>
|
||||
<span class="memory-separator">/</span>
|
||||
<h4 class="memory-total">{{ stat.memory?.system || 0 }} <span class="memory-unit">MiB</span></h4>
|
||||
</div>
|
||||
|
||||
<v-progress-linear
|
||||
:model-value="memoryPercentage"
|
||||
color="warning"
|
||||
height="4"
|
||||
class="mt-2"
|
||||
></v-progress-linear>
|
||||
|
||||
<div class="memory-percentage">{{ memoryPercentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'OnlineTime',
|
||||
components: {
|
||||
},
|
||||
props: ['stat'],
|
||||
watch: {
|
||||
},
|
||||
data: () => ({
|
||||
stat: {
|
||||
memory: "Loading",
|
||||
running: "Loading",
|
||||
memory: { process: 0, system: 0 },
|
||||
running: "加载中...",
|
||||
},
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
computed: {
|
||||
memoryPercentage() {
|
||||
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
|
||||
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
.stats-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.uptime-card {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-card {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uptime-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.uptime-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-values {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.memory-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.memory-separator {
|
||||
margin: 0 6px;
|
||||
font-weight: 300;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.memory-total {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.memory-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.memory-percentage {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.blink-animation {
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +1,253 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// chart 1
|
||||
const chartOptions1 = computed(() => {
|
||||
return {
|
||||
chart: {
|
||||
type: 'area',
|
||||
height: 95,
|
||||
fontFamily: `inherit`,
|
||||
foreColor: '#a1aab2',
|
||||
sparkline: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
colors: ['#5e35b1'],
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 1
|
||||
},
|
||||
tooltip: {
|
||||
theme: 'dark',
|
||||
fixed: {
|
||||
enabled: false
|
||||
},
|
||||
x: {
|
||||
show: false
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
formatter: () => '消息条数 '
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// chart 1
|
||||
const lineChart1 = {
|
||||
series: [
|
||||
{
|
||||
data: [0, 15, 10, 50, 30, 40, 25]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card elevation="0">
|
||||
<v-card variant="outlined">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<h4 class="text-h4 mt-1">各平台消息数</h4>
|
||||
<v-card elevation="1" class="platform-stat-card">
|
||||
<v-card-text>
|
||||
<div class="platform-header">
|
||||
<div>
|
||||
<div class="platform-title">平台消息统计</div>
|
||||
<div class="platform-subtitle">各平台消息数量分布</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<v-list lines="two" class="py-0" style="height: 270px;">
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3"></v-divider>
|
||||
|
||||
<div v-if="platforms.length > 0" class="platform-list-container">
|
||||
<v-list class="platform-list" density="compact">
|
||||
<v-list-item
|
||||
v-for="(platform, i) in sortedPlatforms"
|
||||
:key="i"
|
||||
:value="platform"
|
||||
class="platform-item"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="platform-rank" :class="{'top-rank': i < 3}">{{ i + 1 }}</div>
|
||||
</template>
|
||||
|
||||
<v-list-item v-for="(platform, i) in platforms" :key="i" :value="platform" color="secondary" rounded="sm">
|
||||
<div class="d-inline-flex align-center justify-space-between w-100">
|
||||
<div>
|
||||
<h6 class="text-subtitle-1 text-medium-emphasis font-weight-bold">
|
||||
{{ platform.name }}
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto text-subtitle-1 text-medium-emphasis font-weight-bold">{{ platform.count }} 条</div>
|
||||
<v-list-item-title class="platform-name">{{ platform.name }}</v-list-item-title>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="platform-count">
|
||||
<span class="count-value">{{ platform.count }}</span>
|
||||
<span class="count-label">条</span>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<v-btn color="primary" variant="text"
|
||||
>详情
|
||||
<template v-slot:append>
|
||||
<ChevronRightIcon stroke-width="1.5" width="20" />
|
||||
</template>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="platform-stats-summary">
|
||||
<div class="platform-stat-item">
|
||||
<div class="stat-label">平台数</div>
|
||||
<div class="stat-value">{{ platforms.length }}</div>
|
||||
</div>
|
||||
<v-divider vertical></v-divider>
|
||||
<div class="platform-stat-item">
|
||||
<div class="stat-label">最活跃</div>
|
||||
<div class="stat-value">{{ mostActivePlatform }}</div>
|
||||
</div>
|
||||
<v-divider vertical></v-divider>
|
||||
<div class="platform-stat-item">
|
||||
<div class="stat-label">总消息占比</div>
|
||||
<div class="stat-value">{{ topPlatformPercentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="platform-chart">
|
||||
<v-progress-linear
|
||||
v-for="(platform, i) in sortedPlatforms.slice(0, 5)"
|
||||
:key="i"
|
||||
:model-value="getPercentage(platform.count)"
|
||||
height="8"
|
||||
rounded
|
||||
class="platform-progress"
|
||||
:color="i === 0 ? 'primary' : i === 1 ? 'info' : i === 2 ? 'success' : 'grey-lighten-1'"
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<v-icon icon="mdi-information-outline" size="40" color="grey-lighten-1"></v-icon>
|
||||
<div class="no-data-text">暂无平台数据</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'PlatformStat',
|
||||
components: {
|
||||
},
|
||||
props: ['stat'],
|
||||
data: () => ({
|
||||
platforms: []
|
||||
}),
|
||||
computed: {
|
||||
sortedPlatforms() {
|
||||
return [...this.platforms].sort((a, b) => b.count - a.count);
|
||||
},
|
||||
totalCount() {
|
||||
return this.platforms.reduce((sum, platform) => sum + platform.count, 0);
|
||||
},
|
||||
mostActivePlatform() {
|
||||
return this.sortedPlatforms.length > 0 ? this.sortedPlatforms[0].name : '-';
|
||||
},
|
||||
topPlatformPercentage() {
|
||||
if (this.totalCount === 0 || this.sortedPlatforms.length === 0) return 0;
|
||||
return Math.round((this.sortedPlatforms[0].count / this.totalCount) * 100);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
stat: {
|
||||
handler: function (val, oldVal) {
|
||||
this.platforms = val.platform
|
||||
handler: function (val) {
|
||||
if (val && val.platform) {
|
||||
this.platforms = val.platform;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
platforms: [
|
||||
]
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
methods: {
|
||||
getPercentage(count) {
|
||||
return this.totalCount ? (count / this.totalCount) * 100 : 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-stat-card {
|
||||
height: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.platform-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.platform-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.platform-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.platform-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.platform-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.platform-list {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.platform-item {
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.platform-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.platform-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.top-rank {
|
||||
background-color: #5e35b1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.platform-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #5e35b1;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.platform-stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.platform-stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.platform-chart {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.platform-progress {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: #999;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="stat-card uptime-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">运行时间</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ formattedTime }}</h2>
|
||||
</div>
|
||||
<div class="stat-subtitle">AstrBot 运行时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RunningTime',
|
||||
props: ['stat'],
|
||||
computed: {
|
||||
formattedTime() {
|
||||
return this.stat?.running || '加载中...';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.uptime-card {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +1,97 @@
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card elevation="0" class="bg-secondary overflow-hidden bubble-shape bubble-secondary-shape">
|
||||
<v-card elevation="1" class="stat-card message-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start mb-3">
|
||||
<v-btn icon rounded="sm" color="darksecondary" variant="flat">
|
||||
<v-icon icon="mdi-account-multiple-outline"></v-icon>
|
||||
</v-btn>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-message-text-outline" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">消息总数</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ formattedCount }}</h2>
|
||||
<v-chip v-if="stat.daily_increase" class="trend-chip" size="x-small" color="success">
|
||||
+{{ stat.daily_increase }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="stat-subtitle">所有平台发送的消息总计</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<h2 class="text-h1 font-weight-medium">
|
||||
{{ stat.message_count }}
|
||||
</h2>
|
||||
<span class="text-subtitle-1 text-medium-emphasis text-white">消息总数</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'TotalMessage',
|
||||
props: ['stat'],
|
||||
data: () => ({
|
||||
stat: {
|
||||
message_count: 0
|
||||
computed: {
|
||||
formattedCount() {
|
||||
const count = this.stat?.message_count;
|
||||
return count ? count.toLocaleString() : '0';
|
||||
}
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
background-color: #5e35b1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.trend-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import asyncio
|
||||
import sys
|
||||
import mimetypes
|
||||
from astrbot.dashboard import AstrBotDashBoardLifecycle
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
from astrbot.core import db_helper
|
||||
from astrbot.core import logger, LogManager, LogBroker
|
||||
from astrbot.core.config.default import VERSION
|
||||
@@ -79,5 +79,5 @@ if __name__ == "__main__":
|
||||
# print logo
|
||||
logger.info(logo_tmpl)
|
||||
|
||||
dashboard_lifecycle = AstrBotDashBoardLifecycle(db, log_broker)
|
||||
dashboard_lifecycle = InitialLoader(db, log_broker)
|
||||
asyncio.run(dashboard_lifecycle.start())
|
||||
|
||||
@@ -7,10 +7,13 @@ import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.api import sp
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.provider.sources.dify_source import ProviderDify
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
@@ -88,6 +91,7 @@ class Main(star.Star):
|
||||
/model: 模型列表
|
||||
/ls: 对话列表
|
||||
/new: 创建新对话
|
||||
/groupnew 群号: 为群聊创建新对话(op)
|
||||
/switch 序号: 切换对话
|
||||
/rename 新名字: 重命名当前对话
|
||||
/del: 删除当前会话对话(op)
|
||||
@@ -193,7 +197,29 @@ class Main(star.Star):
|
||||
return
|
||||
await self.context._star_manager.turn_on_plugin(oper2)
|
||||
event.set_result(MessageEventResult().message(f"插件 {oper2} 已启用。"))
|
||||
elif oper1 == "get":
|
||||
if not oper2:
|
||||
raise Exception("请输入插件地址。")
|
||||
if not event.is_admin():
|
||||
raise Exception(
|
||||
"改指令限制仅管理员使用,且无法通过 /alter_cmd 更改。"
|
||||
)
|
||||
if not oper2.startswith("http"):
|
||||
oper2 = f"https://github.com/{oper2}"
|
||||
|
||||
logger.info(f"准备从 {oper2} 获取插件。")
|
||||
|
||||
if self.context._star_manager:
|
||||
star_mgr: PluginManager = self.context._star_manager
|
||||
try:
|
||||
await star_mgr.install_plugin(oper2)
|
||||
event.set_result(MessageEventResult().message("获取插件成功。"))
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件失败: {e}")
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"获取插件失败: {e}")
|
||||
)
|
||||
return
|
||||
else:
|
||||
# 获取插件帮助
|
||||
plugin = self.context.get_registered_star(oper1)
|
||||
@@ -700,6 +726,37 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。")
|
||||
)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("groupnew")
|
||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str):
|
||||
"""创建新群聊对话"""
|
||||
provider = self.context.get_using_provider()
|
||||
if provider and provider.meta().type == "dify":
|
||||
assert isinstance(provider, ProviderDify)
|
||||
await provider.forget(message.unified_msg_origin)
|
||||
message.set_result(
|
||||
MessageEventResult().message("成功,下次聊天将是新对话。")
|
||||
)
|
||||
return
|
||||
if sid:
|
||||
session = str(
|
||||
MessageSesion(
|
||||
platform_name=message.platform_meta.name,
|
||||
message_type=MessageType("GroupMessage"),
|
||||
session_id=sid,
|
||||
)
|
||||
)
|
||||
cid = await self.context.conversation_manager.new_conversation(session)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。"
|
||||
)
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message("请输入群聊 ID。/newgroup 群聊ID。")
|
||||
)
|
||||
|
||||
@filter.command("switch")
|
||||
async def switch_conv(self, message: AstrMessageEvent, index: int = None):
|
||||
"""通过 /ls 前面的序号切换对话"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import astrbot.api.message_components as Comp
|
||||
import copy
|
||||
import json
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.star import Context, Star, register
|
||||
@@ -54,7 +55,45 @@ class Waiter(Star):
|
||||
isinstance(messages[0], Comp.Plain)
|
||||
and messages[0].text.strip() in self.wake_prefix
|
||||
):
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
try:
|
||||
# 尝试使用 LLM 生成更生动的回复
|
||||
func_tools_mgr = self.context.get_llm_tool_manager()
|
||||
|
||||
# 获取用户当前的对话信息
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
conversation = None
|
||||
context = []
|
||||
|
||||
if curr_cid:
|
||||
conversation = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin, curr_cid
|
||||
)
|
||||
context = (
|
||||
json.loads(conversation.history)
|
||||
if conversation.history
|
||||
else []
|
||||
)
|
||||
else:
|
||||
# 创建新对话
|
||||
curr_cid = await self.context.conversation_manager.new_conversation(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
|
||||
# 使用 LLM 生成回复
|
||||
yield event.request_llm(
|
||||
prompt="用户只是@我或唤醒我,请友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。",
|
||||
func_tool_manager=func_tools_mgr,
|
||||
session_id=curr_cid,
|
||||
contexts=context,
|
||||
system_prompt="",
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {str(e)}")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
@@ -74,7 +113,16 @@ class Waiter(Star):
|
||||
try:
|
||||
await empty_mention_waiter(event)
|
||||
except TimeoutError as _:
|
||||
yield event.plain_result("如果需要帮助,请再次 @ 我哦~")
|
||||
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("如果需要帮助,请再次 @ 我哦~")
|
||||
except Exception as e:
|
||||
yield event.plain_result("发生错误,请联系管理员: " + str(e))
|
||||
finally:
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "3.4.39"
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiodocker>=0.24.0",
|
||||
"aiohttp>=3.11.14",
|
||||
"anthropic>=0.49.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"beautifulsoup4>=4.13.3",
|
||||
"certifi>=2025.1.31",
|
||||
"chardet~=5.1.0",
|
||||
"colorlog>=6.9.0",
|
||||
"cryptography>=44.0.2",
|
||||
"dashscope>=1.22.2",
|
||||
"defusedxml>=0.7.1",
|
||||
"dingtalk-stream>=0.22.1",
|
||||
"docstring-parser>=0.16",
|
||||
"googlesearch-python>=1.3.0",
|
||||
"lark-oapi>=1.4.12",
|
||||
"lxml-html-clean>=0.4.1",
|
||||
"mcp>=1.5.0",
|
||||
"openai>=1.68.2",
|
||||
"ormsgpack>=1.9.0",
|
||||
"pillow>=11.1.0",
|
||||
"pip>=25.0.1",
|
||||
"psutil>=5.8.0",
|
||||
"pydantic~=2.10.3",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-telegram-bot>=22.0",
|
||||
"qq-botpy>=1.2.1",
|
||||
"quart>=0.20.0",
|
||||
"readability-lxml>=0.8.1",
|
||||
"silk-python>=0.2.6",
|
||||
"wechatpy>=1.8.18",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
"astrbot/core/utils/t2i/local_strategy.py",
|
||||
|
||||
+5
-1
@@ -24,4 +24,8 @@ cryptography
|
||||
dashscope
|
||||
python-telegram-bot
|
||||
wechatpy
|
||||
dingtalk-stream
|
||||
dingtalk-stream
|
||||
defusedxml
|
||||
mcp
|
||||
certifi
|
||||
pip
|
||||
Reference in New Issue
Block a user