Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7311786f48 | |||
| 82de9c926e | |||
| 7fd86d4de3 | |||
| 724da29e2a | |||
| 54113d7b94 | |||
| 66396e8290 | |||
| 72be76215f | |||
| ace86703a9 | |||
| 7b25495463 | |||
| 3d4b651c1f | |||
| d305ae064d | |||
| ac4f3d8907 | |||
| af2687771b | |||
| a67b7f909a | |||
| f9c3e4cdb0 | |||
| dc62c1f8d4 | |||
| 0441b51a68 | |||
| 5c0c9f687e | |||
| e049c54043 | |||
| 99e47540d5 | |||
| 8e1885ffeb | |||
| 8501a0c205 | |||
| 797f2a3173 | |||
| 1057b4bc35 | |||
| efc0116595 | |||
| cdc560fad0 | |||
| 75a2803710 | |||
| fb3169faa4 | |||
| d587bd837e | |||
| b9fab74edc | |||
| 50c22bbadb | |||
| d0b10b9195 | |||
| c8fe4f4a3c | |||
| a8ba0720af | |||
| 745a01246c | |||
| bee5d3550f | |||
| 1789393151 | |||
| 345afe1338 | |||
| 65428aa49f | |||
| b251ee9322 | |||
| 04f00682a0 | |||
| 90dcda1475 | |||
| f1ee4eb89f | |||
| 343fc22168 | |||
| 00ef0d7e3d | |||
| f2deaf6199 | |||
| 617a2c010e | |||
| 38eae1d1ee | |||
| 7e4c89b0cb | |||
| 14c29f07bd | |||
| 825e3dbcf5 | |||
| 8275130f04 | |||
| 2c47abea95 | |||
| 85aa28d724 | |||
| 53a3736b04 | |||
| 86ba3c230e | |||
| 8d21126bd6 | |||
| 74ded91976 | |||
| 7c27520d57 | |||
| b54bbc4c5a | |||
| 3e09a4ddd4 | |||
| f93f04a536 | |||
| b93f30b809 | |||
| 95bd2f26a5 | |||
| 7cfcf056f9 | |||
| 96b565e1e8 | |||
| 9d7ad7a18f | |||
| 9838c2758b | |||
| 1b1f5f5a5e | |||
| 0f95f62aa1 | |||
| 9405ba7871 | |||
| 60b2ff0a7a | |||
| e6c8507379 | |||
| 420db5416e | |||
| 6e03218d54 | |||
| 5e4bd36b26 | |||
| bbc039366e | |||
| e1ec7dbbba | |||
| 075b008740 | |||
| b2c382fa01 | |||
| c5f9b5861f | |||
| 2dace4c697 | |||
| c7891385ca | |||
| 2059ddcadf | |||
| ba1b68df20 | |||
| 403b61836d | |||
| b5af7d1eb9 | |||
| f453af6e4c | |||
| 64245d001c | |||
| 7d92965cae | |||
| b4fa08c4e2 | |||
| d4e9566851 | |||
| a26b494f7f | |||
| b84e22e41f | |||
| cee6efab19 | |||
| 30f71cb550 | |||
| 771e755a78 | |||
| 16ec462abd | |||
| ca55465d3c | |||
| 7098c98dde | |||
| f56355da89 | |||
| 422160debd | |||
| 8062cf406a | |||
| 0e802232ec | |||
| f650a9205d | |||
| c85dbb2347 | |||
| a6a79128c8 | |||
| 42839627e8 | |||
| e7f35098e4 | |||
| 267e68a894 | |||
| b32b444438 | |||
| 522d0f8313 | |||
| 5715e5de67 | |||
| cc6b05e8b3 | |||
| 417747d5d0 | |||
| a34f439226 | |||
| b7ca014fd0 | |||
| fa098d585a | |||
| c35a14e3ec | |||
| 60651736a5 | |||
| 581f9b7bd3 | |||
| 124eb04807 | |||
| 1d561da7fb | |||
| 16e3cd0784 | |||
| a6d91933dc | |||
| 445c40f758 | |||
| 725a841a3b | |||
| f77c453843 | |||
| ba6718d5bc | |||
| cdb7a1b3fa | |||
| a03c79b89d | |||
| 98800d3426 | |||
| a616adaac4 | |||
| ffb5605c99 | |||
| 621b556856 | |||
| a3ffecbb2a | |||
| ea64cebe2a | |||
| e79487dd5f | |||
| 7fe1c1ec89 | |||
| ab2bbff369 | |||
| ec32825309 | |||
| fd0c182087 | |||
| 49fcff1daf | |||
| 33b64ddf39 | |||
| 4c447aa648 | |||
| ccbfc3d274 | |||
| f83fe43bbb | |||
| 19022d67f8 | |||
| 58a815dd6b | |||
| bc9fe82860 | |||
| b3cd9bf2b9 | |||
| c5c2b829ec | |||
| 9713f96401 | |||
| 11f35ebf96 | |||
| 7d403aa181 | |||
| 64af810a4a | |||
| 30821905af | |||
| a9dbff756b | |||
| a6aba10d3d | |||
| 9c276c37fe | |||
| 6ab6c0fd4c | |||
| b6b0fe3fff | |||
| 0d5825bda9 | |||
| cdfb64631a | |||
| d161c281c8 | |||
| 8fed5bf2a1 | |||
| 98d2e9bd27 | |||
| a03af55edd | |||
| 86e2fd9aee | |||
| 97bd0e5e58 | |||
| ceaba21986 | |||
| 172a77d942 | |||
| 4f9d2d2a7d | |||
| 8c929f6e05 | |||
| 3319b71f5b | |||
| 46ec028a5b | |||
| 0ce0ef3e5c | |||
| 375b071cb2 | |||
| 29e1417ff2 | |||
| 75db2bd366 | |||
| 60ca1efbda | |||
| 2692e4978b | |||
| 91982eb002 | |||
| bb1dec76fa | |||
| f618b8fcdc | |||
| 9147cab75b | |||
| 5f07bcc8e6 | |||
| 705cf2ea1b | |||
| 42c4394484 | |||
| 221221a3c1 | |||
| 9564166297 | |||
| f5cf3c3c8e | |||
| 18f919fb6b | |||
| 0924835253 | |||
| 20d2e5c578 | |||
| 907801605c | |||
| 93bc684e8c | |||
| a76c98d57e | |||
| d937a800d0 | |||
| d16f3a227f | |||
| 80c9a3eeda | |||
| e68173b451 | |||
| 40c27d87f5 | |||
| 3c13b5049d | |||
| 8288d5e51f | |||
| 6e1449900a | |||
| 4ffbb18ab4 | |||
| b27271b7a3 | |||
| ebb6665f64 | |||
| e4e5731ffd | |||
| 2ab5810f13 | |||
| af934c5d09 | |||
| 1e0cf7c112 | |||
| 46859c93c9 | |||
| ea1f9cb3b2 | |||
| 1641549016 | |||
| 716a5dbb8a | |||
| af98cb11c5 | |||
| 9a4c2cf341 | |||
| 2bc3bcd102 | |||
| d6c663f79d | |||
| 9ed86e5f53 | |||
| 303e0bc037 | |||
| 2cc24019f9 | |||
| 83ce774d19 | |||
| 2b4ee13b5e | |||
| 3a964561f0 | |||
| 6959f86632 | |||
| 537d373e10 | |||
| cceadf222c | |||
| cf5a4af623 | |||
| 39aea11c22 | |||
| c2f1227700 | |||
| 900f14d37c | |||
| 598249b1d6 | |||
| 7ed15bdf04 | |||
| 2fc0ec0f72 | |||
| 5e9c2a669b | |||
| b884fe0e86 | |||
| 855858c236 | |||
| c11a2a5419 | |||
| 773a6572af | |||
| 88ad373c9b |
@@ -23,6 +23,36 @@ jobs:
|
||||
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
echo ${{ github.ref_name }} > dist/assets/version
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Upload to Cloudflare R2
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET_NAME: "astrbot"
|
||||
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
|
||||
VERSION_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
echo "Installing rclone..."
|
||||
curl https://rclone.org/install.sh | sudo bash
|
||||
|
||||
echo "Configuring rclone remote..."
|
||||
mkdir -p ~/.config/rclone
|
||||
cat <<EOF > ~/.config/rclone/rclone.conf
|
||||
[r2]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = $R2_ACCESS_KEY_ID
|
||||
secret_access_key = $R2_SECRET_ACCESS_KEY
|
||||
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
echo "Uploading dist.zip to R2 bucket: $R2_BUCKET_NAME/$R2_OBJECT_NAME"
|
||||
mv dashboard/dist.zip dashboard/$R2_OBJECT_NAME
|
||||
rclone copy dashboard/$R2_OBJECT_NAME r2:$R2_BUCKET_NAME --progress
|
||||
mv dashboard/$R2_OBJECT_NAME dashboard/astrbot-webui-${VERSION_TAG}.zip
|
||||
rclone copy dashboard/astrbot-webui-${VERSION_TAG}.zip r2:$R2_BUCKET_NAME --progress
|
||||
mv dashboard/astrbot-webui-${VERSION_TAG}.zip dashboard/dist.zip
|
||||
|
||||
- name: Fetch Changelog
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: AstrBot Dashboard CI
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -11,24 +11,42 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 拉取源码
|
||||
- name: Pull The Codes
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0 # Must be 0 so we can fetch tags
|
||||
|
||||
- name: 设置 QEMU
|
||||
- name: Get latest tag (only on manual trigger)
|
||||
id: get-latest-tag
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "latest_tag=$tag" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout to latest tag (only on manual trigger)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }}
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 登录到 DockerHub
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: 构建和推送 Docker hub
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: Soulter
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Docker to DockerHub and Github GHCR
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
@@ -36,8 +54,9 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:latest
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.ref_name }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
|
||||
ghcr.io/soulter/astrbot:latest
|
||||
ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
|
||||
|
||||
- name: Post build notifications
|
||||
run: echo "Docker image has been built and pushed successfully"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-slim
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /AstrBot
|
||||
|
||||
COPY . /AstrBot/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
@@ -31,13 +31,21 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
<!-- [](https://codecov.io/gh/Soulter/AstrBot)
|
||||
-->
|
||||
|
||||
> [!NOTE]
|
||||
> [!WARNING]
|
||||
>
|
||||
> 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,`v3.5.10` 已经支持接入 WeChatPadPro 替换 gewechat 方式。详见文档 [WeChatPadPro](https://astrbot.app/deploy/platform/wechat/wechatpadpro.html)
|
||||
> 请务必修改默认密码以及保证 AstrBot 版本 >= 3.5.13。
|
||||
|
||||
## ✨ 近期更新
|
||||
|
||||
1. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器!
|
||||
<details><summary>1. AstrBot 现已自带知识库能力</summary>
|
||||
|
||||
📚 详见[文档](https://astrbot.app/use/knowledge-base.html)
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
2. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器!
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
@@ -45,7 +53,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
> 🪧 我们正基于前沿科研成果,设计并实现适用于角色扮演和情感陪伴的长短期记忆模型及情绪控制模型,旨在提升对话的真实性与情感表达能力。敬请期待 `v3.6.0` 版本!
|
||||
|
||||
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
|
||||
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
|
||||
2. **多消息平台接入**。支持接入 QQ(OneBot、QQ 官方机器人平台)、QQ 频道、微信、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK、VoceChat。支持速率限制、白名单、关键词过滤、百度内容审核。
|
||||
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://dify.ai/),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
|
||||
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
|
||||
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
|
||||
@@ -109,20 +117,24 @@ uvx astrbot init
|
||||
|
||||
## ⚡ 消息平台支持情况
|
||||
|
||||
| 平台 | 支持性 | 详情 | 消息类型 |
|
||||
| -------- | ------- | ------- | ------ |
|
||||
| QQ(官方机器人接口) | ✔ | 私聊、群聊,QQ 频道私聊、群聊 | 文字、图片 |
|
||||
| QQ(OneBot) | ✔ | 私聊、群聊 | 文字、图片、语音 |
|
||||
| 微信个人号 | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
|
||||
| Telegram | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 企业微信 | ✔ | 私聊 | 文字、图片、语音 |
|
||||
| 微信客服 | ✔ | 私聊 | 文字、图片 |
|
||||
| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 微信对话开放平台 | 🚧 | 计划内 | - |
|
||||
| Discord | 🚧 | 计划内 | - |
|
||||
| WhatsApp | 🚧 | 计划内 | - |
|
||||
| 小爱音响 | 🚧 | 计划内 | - |
|
||||
| 平台 | 支持性 |
|
||||
| -------- | ------- |
|
||||
| QQ(官方机器人接口) | ✔ |
|
||||
| QQ(OneBot) | ✔ |
|
||||
| 微信个人号 | ✔ |
|
||||
| Telegram | ✔ |
|
||||
| 企业微信 | ✔ |
|
||||
| 微信客服 | ✔ |
|
||||
| 微信公众号 | ✔ |
|
||||
| 飞书 | ✔ |
|
||||
| 钉钉 | ✔ |
|
||||
| Slack | ✔ |
|
||||
| Discord | ✔ |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
|
||||
| 微信对话开放平台 | 🚧 |
|
||||
| WhatsApp | 🚧 |
|
||||
| 小爱音响 | 🚧 |
|
||||
|
||||
## ⚡ 提供商支持情况
|
||||
|
||||
@@ -143,6 +155,7 @@ uvx astrbot init
|
||||
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
|
||||
| OpenAI TTS API | ✔ | 文本转语音 | |
|
||||
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
|
||||
| GPT-SoVITs | ✔ | 文本转语音 | GPT-Sovits-Inference |
|
||||
| FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
|
||||
| Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
|
||||
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
|
||||
@@ -171,7 +184,6 @@ pre-commit install
|
||||
|
||||
- Star 这个项目!
|
||||
- 在[爱发电](https://afdian.com/a/soulter)支持我!
|
||||
- 在[微信](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)支持我~
|
||||
|
||||
## ✨ Demo
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class AstrBotConfig(dict):
|
||||
"""不存在时载入默认配置"""
|
||||
with open(config_path, "w", encoding="utf-8-sig") as f:
|
||||
json.dump(default_config, f, indent=4, ensure_ascii=False)
|
||||
object.__setattr__(self, "first_deploy", True) # 标记第一次部署
|
||||
|
||||
with open(config_path, "r", encoding="utf-8-sig") as f:
|
||||
conf_str = f.read()
|
||||
@@ -82,23 +83,61 @@ class AstrBotConfig(dict):
|
||||
return conf
|
||||
|
||||
def check_config_integrity(self, refer_conf: Dict, conf: Dict, path=""):
|
||||
"""检查配置完整性,如果有新的配置项则返回 True"""
|
||||
"""检查配置完整性,如果有新的配置项或顺序不一致则返回 True"""
|
||||
has_new = False
|
||||
|
||||
# 创建一个新的有序字典以保持参考配置的顺序
|
||||
new_conf = {}
|
||||
|
||||
# 先按照参考配置的顺序添加配置项
|
||||
for key, value in refer_conf.items():
|
||||
if key not in conf:
|
||||
# logger.info(f"检查到配置项 {path + "." + key if path else key} 不存在,已插入默认值 {value}")
|
||||
# 配置项不存在,插入默认值
|
||||
path_ = path + "." + key if path else key
|
||||
logger.info(f"检查到配置项 {path_} 不存在,已插入默认值 {value}")
|
||||
conf[key] = value
|
||||
new_conf[key] = value
|
||||
has_new = True
|
||||
else:
|
||||
if conf[key] is None:
|
||||
conf[key] = value
|
||||
# 配置项为 None,使用默认值
|
||||
new_conf[key] = value
|
||||
has_new = True
|
||||
elif isinstance(value, dict):
|
||||
has_new |= self.check_config_integrity(
|
||||
value, conf[key], path + "." + key if path else key
|
||||
)
|
||||
# 递归检查子配置项
|
||||
if not isinstance(conf[key], dict):
|
||||
# 类型不匹配,使用默认值
|
||||
new_conf[key] = value
|
||||
has_new = True
|
||||
else:
|
||||
# 递归检查并同步顺序
|
||||
child_has_new = self.check_config_integrity(
|
||||
value, conf[key], path + "." + key if path else key
|
||||
)
|
||||
new_conf[key] = conf[key]
|
||||
has_new |= child_has_new
|
||||
else:
|
||||
# 直接使用现有配置
|
||||
new_conf[key] = conf[key]
|
||||
|
||||
# 检查是否存在参考配置中没有的配置项
|
||||
for key in list(conf.keys()):
|
||||
if key not in refer_conf:
|
||||
path_ = path + "." + key if path else key
|
||||
logger.info(f"检查到配置项 {path_} 不存在,将从当前配置中删除")
|
||||
has_new = True
|
||||
|
||||
# 顺序不一致也算作变更
|
||||
if list(conf.keys()) != list(new_conf.keys()):
|
||||
if path:
|
||||
logger.info(f"检查到配置项 {path} 的子项顺序不一致,已重新排序")
|
||||
else:
|
||||
logger.info("检查到配置项顺序不一致,已重新排序")
|
||||
has_new = True
|
||||
|
||||
# 更新原始配置
|
||||
conf.clear()
|
||||
conf.update(new_conf)
|
||||
|
||||
return has_new
|
||||
|
||||
def save_config(self, replace_config: Dict = None):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import os
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "3.5.12"
|
||||
VERSION = "3.5.16"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
|
||||
|
||||
# 默认配置
|
||||
@@ -40,12 +40,15 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"no_permission_reply": True,
|
||||
"empty_mention_waiting": True,
|
||||
"empty_mention_waiting_need_reply": True,
|
||||
"friend_message_needs_wake_prefix": False,
|
||||
"ignore_bot_self_message": False,
|
||||
"ignore_at_all": False,
|
||||
},
|
||||
"provider": [],
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
"wake_prefix": "",
|
||||
"web_search": False,
|
||||
"web_search_link": False,
|
||||
@@ -57,6 +60,7 @@ DEFAULT_CONFIG = {
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"streaming_segmented": False,
|
||||
"separate_provider": False,
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
@@ -122,7 +126,7 @@ CONFIG_METADATA_2 = {
|
||||
"description": "消息平台适配器",
|
||||
"type": "list",
|
||||
"config_template": {
|
||||
"qq_official(QQ)": {
|
||||
"QQ 官方机器人(WebSocket)": {
|
||||
"id": "default",
|
||||
"type": "qq_official",
|
||||
"enable": False,
|
||||
@@ -131,7 +135,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable_group_c2c": True,
|
||||
"enable_guild_direct_message": True,
|
||||
},
|
||||
"qq_official_webhook(QQ)": {
|
||||
"QQ 官方机器人(Webhook)": {
|
||||
"id": "default",
|
||||
"type": "qq_official_webhook",
|
||||
"enable": False,
|
||||
@@ -140,7 +144,7 @@ CONFIG_METADATA_2 = {
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6196,
|
||||
},
|
||||
"aiocqhttp(OneBotv11)": {
|
||||
"QQ 个人号(aiocqhttp)": {
|
||||
"id": "default",
|
||||
"type": "aiocqhttp",
|
||||
"enable": False,
|
||||
@@ -148,7 +152,7 @@ CONFIG_METADATA_2 = {
|
||||
"ws_reverse_port": 6199,
|
||||
"ws_reverse_token": "",
|
||||
},
|
||||
"gewechat(微信)": {
|
||||
"微信个人号(Gewechat)": {
|
||||
"id": "gwchat",
|
||||
"type": "gewechat",
|
||||
"enable": False,
|
||||
@@ -157,7 +161,7 @@ CONFIG_METADATA_2 = {
|
||||
"host": "这里填写你的局域网IP或者公网服务器IP",
|
||||
"port": 11451,
|
||||
},
|
||||
"wechatpadpro(微信)": {
|
||||
"微信个人号(WeChatPadPro)": {
|
||||
"id": "wechatpadpro",
|
||||
"type": "wechatpadpro",
|
||||
"enable": False,
|
||||
@@ -167,7 +171,7 @@ CONFIG_METADATA_2 = {
|
||||
"wpp_active_message_poll": False,
|
||||
"wpp_active_message_poll_interval": 3,
|
||||
},
|
||||
"weixin_official_account(微信公众平台)": {
|
||||
"微信公众平台": {
|
||||
"id": "weixin_official_account",
|
||||
"type": "weixin_official_account",
|
||||
"enable": False,
|
||||
@@ -180,7 +184,7 @@ CONFIG_METADATA_2 = {
|
||||
"port": 6194,
|
||||
"active_send_mode": False,
|
||||
},
|
||||
"wecom(企业微信)": {
|
||||
"企业微信(含微信客服)": {
|
||||
"id": "wecom",
|
||||
"type": "wecom",
|
||||
"enable": False,
|
||||
@@ -193,7 +197,7 @@ CONFIG_METADATA_2 = {
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6195,
|
||||
},
|
||||
"lark(飞书)": {
|
||||
"飞书(Lark)": {
|
||||
"id": "lark",
|
||||
"type": "lark",
|
||||
"enable": False,
|
||||
@@ -202,14 +206,14 @@ CONFIG_METADATA_2 = {
|
||||
"app_secret": "",
|
||||
"domain": "https://open.feishu.cn",
|
||||
},
|
||||
"dingtalk(钉钉)": {
|
||||
"钉钉(DingTalk)": {
|
||||
"id": "dingtalk",
|
||||
"type": "dingtalk",
|
||||
"enable": False,
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
},
|
||||
"telegram": {
|
||||
"Telegram": {
|
||||
"id": "telegram",
|
||||
"type": "telegram",
|
||||
"enable": False,
|
||||
@@ -221,8 +225,51 @@ CONFIG_METADATA_2 = {
|
||||
"telegram_command_auto_refresh": True,
|
||||
"telegram_command_register_interval": 300,
|
||||
},
|
||||
"Discord": {
|
||||
"id": "discord",
|
||||
"type": "discord",
|
||||
"enable": False,
|
||||
"discord_token": "",
|
||||
"discord_proxy": "",
|
||||
"discord_command_register": True,
|
||||
"discord_guild_id_for_debug": "",
|
||||
"discord_activity_name": "",
|
||||
},
|
||||
"Slack": {
|
||||
"id": "slack",
|
||||
"type": "slack",
|
||||
"enable": False,
|
||||
"bot_token": "",
|
||||
"app_token": "",
|
||||
"signing_secret": "",
|
||||
"slack_connection_mode": "socket", # webhook, socket
|
||||
"slack_webhook_host": "0.0.0.0",
|
||||
"slack_webhook_port": 6197,
|
||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"slack_connection_mode": {
|
||||
"description": "Slack Connection Mode",
|
||||
"type": "string",
|
||||
"options": ["webhook", "socket"],
|
||||
"hint": "The connection mode for Slack. `webhook` uses a webhook server, `socket` uses Slack's Socket Mode.",
|
||||
},
|
||||
"slack_webhook_host": {
|
||||
"description": "Slack Webhook Host",
|
||||
"type": "string",
|
||||
"hint": "Only valid when Slack connection mode is `webhook`.",
|
||||
},
|
||||
"slack_webhook_port": {
|
||||
"description": "Slack Webhook Port",
|
||||
"type": "int",
|
||||
"hint": "Only valid when Slack connection mode is `webhook`.",
|
||||
},
|
||||
"slack_webhook_path": {
|
||||
"description": "Slack Webhook Path",
|
||||
"type": "string",
|
||||
"hint": "Only valid when Slack connection mode is `webhook`.",
|
||||
},
|
||||
"active_send_mode": {
|
||||
"description": "是否换用主动发送接口",
|
||||
"type": "bool",
|
||||
@@ -320,6 +367,29 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"discord_token":{
|
||||
"description": "Discord Bot Token",
|
||||
"type": "string",
|
||||
"hint": "在此处填入你的Discord Bot Token"
|
||||
},
|
||||
"discord_proxy":{
|
||||
"description": "Discord 代理地址",
|
||||
"type": "string",
|
||||
"hint": "可选的代理地址:http://ip:port"
|
||||
},
|
||||
"discord_command_register": {
|
||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令",
|
||||
"type": "bool",
|
||||
},
|
||||
"discord_activity_name": {
|
||||
"description": "Discord 活动名称",
|
||||
"type": "string",
|
||||
"hint": "可选的 Discord 活动名称。留空则不设置活动。",
|
||||
},
|
||||
"discord_guild_id_for_debug": {
|
||||
"description": "【开发用】指定一个服务器(Guild)ID。在此服务器注册的指令会立刻生效,便于调试。留空则注册为全局指令。",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
"platform_settings": {
|
||||
@@ -355,9 +425,14 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "启用后,当用户没有权限执行某个操作时,机器人会回复一条消息。",
|
||||
},
|
||||
"empty_mention_waiting": {
|
||||
"description": "只 @ 机器人是否触发等待回复",
|
||||
"description": "只 @ 机器人是否触发等待",
|
||||
"type": "bool",
|
||||
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
|
||||
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
|
||||
},
|
||||
"empty_mention_waiting_need_reply": {
|
||||
"description": "只 @ 机器人触发等待时是否需要回复提醒",
|
||||
"type": "bool",
|
||||
"hint": "在上面一个配置项中,如果启用了触发等待,启用此项后,机器人会使用 LLM 生成一条回复。否则,将不回复而只是等待。",
|
||||
},
|
||||
"friend_message_needs_wake_prefix": {
|
||||
"description": "私聊消息是否需要唤醒前缀",
|
||||
@@ -369,6 +444,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
|
||||
},
|
||||
"ignore_at_all": {
|
||||
"description": "是否忽略 @ 全体成员",
|
||||
"type": "bool",
|
||||
"hint": "启用后,机器人会忽略 @ 全体成员 的消息事件。",
|
||||
},
|
||||
"segmented_reply": {
|
||||
"description": "分段回复",
|
||||
"type": "object",
|
||||
@@ -620,6 +700,7 @@ CONFIG_METADATA_2 = {
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_native_search": False,
|
||||
"gm_native_coderunner": False,
|
||||
"gm_url_context": False,
|
||||
"gm_safety_settings": {
|
||||
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
@@ -785,6 +866,37 @@ CONFIG_METADATA_2 = {
|
||||
"edge-tts-voice": "zh-CN-XiaoxiaoNeural",
|
||||
"timeout": 20,
|
||||
},
|
||||
"GSV TTS(本地加载)": {
|
||||
"id": "gsv_tts",
|
||||
"enable": False,
|
||||
"type": "gsv_tts_selfhost",
|
||||
"provider_type": "text_to_speech",
|
||||
"api_base": "http://127.0.0.1:9880",
|
||||
"gpt_weights_path": "",
|
||||
"sovits_weights_path": "",
|
||||
"timeout": 60,
|
||||
"gsv_default_parms": {
|
||||
"gsv_ref_audio_path": "",
|
||||
"gsv_prompt_text": "",
|
||||
"gsv_prompt_lang": "zh",
|
||||
"gsv_aux_ref_audio_paths": "",
|
||||
"gsv_text_lang": "zh",
|
||||
"gsv_top_k": 5,
|
||||
"gsv_top_p": 1.0,
|
||||
"gsv_temperature": 1.0,
|
||||
"gsv_text_split_method": "cut3",
|
||||
"gsv_batch_size": 1,
|
||||
"gsv_batch_threshold": 0.75,
|
||||
"gsv_split_bucket": True,
|
||||
"gsv_speed_factor": 1,
|
||||
"gsv_fragment_interval": 0.3,
|
||||
"gsv_streaming_mode": False,
|
||||
"gsv_seed": -1,
|
||||
"gsv_parallel_infer": True,
|
||||
"gsv_repetition_penalty": 1.35,
|
||||
"gsv_media_type": "wav",
|
||||
},
|
||||
},
|
||||
"GSVI TTS(API)": {
|
||||
"id": "gsvi_tts",
|
||||
"type": "gsvi_tts_api",
|
||||
@@ -862,8 +974,172 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://openspeech.bytedance.com/api/v1/tts",
|
||||
"timeout": 20,
|
||||
},
|
||||
"OpenAI Embedding": {
|
||||
"id": "openai_embedding",
|
||||
"type": "openai_embedding",
|
||||
"provider_type": "embedding",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
"embedding_model": "",
|
||||
"embedding_dimensions": 1536,
|
||||
"timeout": 20,
|
||||
},
|
||||
"Gemini Embedding": {
|
||||
"id": "gemini_embedding",
|
||||
"type": "gemini_embedding",
|
||||
"provider_type": "embedding",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
"embedding_model": "gemini-embedding-exp-03-07",
|
||||
"embedding_dimensions": 768,
|
||||
"timeout": 20,
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"gpt_weights_path": {
|
||||
"description": "GPT模型文件路径",
|
||||
"type": "string",
|
||||
"hint": "即“.ckpt”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"sovits_weights_path": {
|
||||
"description": "SoVITS模型文件路径",
|
||||
"type": "string",
|
||||
"hint": "即“.pth”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gsv_default_parms": {
|
||||
"description": "GPT_SoVITS默认参数",
|
||||
"hint": "参考音频文件路径、参考音频文本必填,其他参数根据个人爱好自行填写",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"gsv_ref_audio_path": {
|
||||
"description": "参考音频文件路径",
|
||||
"type": "string",
|
||||
"hint": "必填!请使用绝对路径!路径两端不要带双引号!",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gsv_prompt_text": {
|
||||
"description": "参考音频文本",
|
||||
"type": "string",
|
||||
"hint": "必填!请填写参考音频讲述的文本",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gsv_prompt_lang": {
|
||||
"description": "参考音频文本语言",
|
||||
"type": "string",
|
||||
"hint": "请填写参考音频讲述的文本的语言,默认为中文",
|
||||
},
|
||||
"gsv_aux_ref_audio_paths": {
|
||||
"description": "辅助参考音频文件路径",
|
||||
"type": "string",
|
||||
"hint": "辅助参考音频文件,可不填",
|
||||
},
|
||||
"gsv_text_lang": {
|
||||
"description": "文本语言",
|
||||
"type": "string",
|
||||
"hint": "默认为中文",
|
||||
},
|
||||
"gsv_top_k": {
|
||||
"description": "生成语音的多样性",
|
||||
"type": "int",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_top_p": {
|
||||
"description": "核采样的阈值",
|
||||
"type": "float",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_temperature": {
|
||||
"description": "生成语音的随机性",
|
||||
"type": "float",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_text_split_method": {
|
||||
"description": "切分文本的方法",
|
||||
"type": "string",
|
||||
"hint": "可选值: `cut0`:不切分 `cut1`:四句一切 `cut2`:50字一切 `cut3`:按中文句号切 `cut4`:按英文句号切 `cut5`:按标点符号切",
|
||||
"options": [
|
||||
"cut0",
|
||||
"cut1",
|
||||
"cut2",
|
||||
"cut3",
|
||||
"cut4",
|
||||
"cut5",
|
||||
],
|
||||
},
|
||||
"gsv_batch_size": {
|
||||
"description": "批处理大小",
|
||||
"type": "int",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_batch_threshold": {
|
||||
"description": "批处理阈值",
|
||||
"type": "float",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_split_bucket": {
|
||||
"description": "将文本分割成桶以便并行处理",
|
||||
"type": "bool",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_speed_factor": {
|
||||
"description": "语音播放速度",
|
||||
"type": "float",
|
||||
"hint": "1为原始语速",
|
||||
},
|
||||
"gsv_fragment_interval": {
|
||||
"description": "语音片段之间的间隔时间",
|
||||
"type": "float",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_streaming_mode": {
|
||||
"description": "启用流模式",
|
||||
"type": "bool",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_seed": {
|
||||
"description": "随机种子",
|
||||
"type": "int",
|
||||
"hint": "用于结果的可重复性",
|
||||
},
|
||||
"gsv_parallel_infer": {
|
||||
"description": "并行执行推理",
|
||||
"type": "bool",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_repetition_penalty": {
|
||||
"description": "重复惩罚因子",
|
||||
"type": "float",
|
||||
"hint": "",
|
||||
},
|
||||
"gsv_media_type": {
|
||||
"description": "输出媒体的类型",
|
||||
"type": "string",
|
||||
"hint": "建议用wav",
|
||||
},
|
||||
},
|
||||
},
|
||||
"embedding_dimensions": {
|
||||
"description": "嵌入维度",
|
||||
"type": "int",
|
||||
"hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。",
|
||||
},
|
||||
"embedding_model": {
|
||||
"description": "嵌入模型",
|
||||
"type": "string",
|
||||
"hint": "嵌入模型名称。",
|
||||
},
|
||||
"embedding_api_key": {
|
||||
"description": "API Key",
|
||||
"type": "string",
|
||||
},
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL",
|
||||
"type": "string",
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"type": "string",
|
||||
"description": "火山引擎集群",
|
||||
@@ -984,6 +1260,12 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "启用后所有函数工具将全部失效",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gm_url_context": {
|
||||
"description": "启用URL上下文功能",
|
||||
"type": "bool",
|
||||
"hint": "启用后所有函数工具将全部失效",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gm_safety_settings": {
|
||||
"description": "安全过滤器",
|
||||
"type": "object",
|
||||
@@ -1339,9 +1621,19 @@ CONFIG_METADATA_2 = {
|
||||
"enable": {
|
||||
"description": "启用大语言模型聊天",
|
||||
"type": "bool",
|
||||
"hint": "如需切换大语言模型提供商,请使用 `/provider` 命令。",
|
||||
"hint": "如需切换大语言模型提供商,请使用 /provider 命令。",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"separate_provider": {
|
||||
"description": "提供商会话隔离",
|
||||
"type": "bool",
|
||||
"hint": "启用后,每个会话支持独立选择文本生成、STT、TTS 等提供商。如果会话在使用 /provider 指令时提示无权限,可以将会话加入管理员名单或者使用 /alter_cmd provider member 将指令设为非管理员指令。",
|
||||
},
|
||||
"default_provider_id": {
|
||||
"description": "默认模型提供商 ID",
|
||||
"type": "string",
|
||||
"hint": "可选。每个聊天会话的默认提供商 ID。",
|
||||
},
|
||||
"wake_prefix": {
|
||||
"description": "LLM 聊天额外唤醒前缀",
|
||||
"type": "string",
|
||||
@@ -1454,7 +1746,7 @@ CONFIG_METADATA_2 = {
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"provider_id": {
|
||||
"description": "提供商 ID,不填则默认第一个STT提供商",
|
||||
"description": "提供商 ID",
|
||||
"type": "string",
|
||||
"hint": "语音转文本提供商 ID。如果不填写将使用载入的第一个提供商。",
|
||||
},
|
||||
@@ -1471,7 +1763,7 @@ CONFIG_METADATA_2 = {
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"provider_id": {
|
||||
"description": "提供商 ID,不填则默认第一个TTS提供商",
|
||||
"description": "提供商 ID",
|
||||
"type": "string",
|
||||
"hint": "文本转语音提供商 ID。如果不填写将使用载入的第一个提供商。",
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@ class SQLiteDatabase(BaseDatabase):
|
||||
super().__init__()
|
||||
self.db_path = db_path
|
||||
|
||||
with open(os.path.dirname(__file__) + "/sqlite_init.sql", "r") as f:
|
||||
with open(
|
||||
os.path.dirname(__file__) + "/sqlite_init.sql", "r", encoding="utf-8"
|
||||
) as f:
|
||||
sql = f.read()
|
||||
|
||||
# 初始化数据库
|
||||
|
||||
@@ -29,9 +29,9 @@ class EmbeddingStorage:
|
||||
Raises:
|
||||
ValueError: 如果向量的维度与存储的维度不匹配
|
||||
"""
|
||||
if vector.shape[0] != self.dimention:
|
||||
if vector.shape[0] != self.dimension:
|
||||
raise ValueError(
|
||||
f"向量维度不匹配, 期望: {self.dimention}, 实际: {vector.shape[0]}"
|
||||
f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}"
|
||||
)
|
||||
self.index.add_with_ids(vector.reshape(1, -1), np.array([id]))
|
||||
self.storage[id] = vector
|
||||
|
||||
@@ -30,19 +30,13 @@ class FaissVecDB(BaseVecDB):
|
||||
async def initialize(self):
|
||||
await self.document_storage.initialize()
|
||||
|
||||
async def insert(
|
||||
self,
|
||||
content: str,
|
||||
metadata: dict = None,
|
||||
id: str = None,
|
||||
) -> int:
|
||||
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
|
||||
"""
|
||||
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
|
||||
"""
|
||||
metadata = metadata or {}
|
||||
str_id = id or str(uuid.uuid4()) # 使用 UUID 作为原始 ID
|
||||
|
||||
# 获取向量
|
||||
vector = await self.embedding_provider.get_embedding(content)
|
||||
vector = np.array(vector, dtype=np.float32)
|
||||
async with self.document_storage.connection.cursor() as cursor:
|
||||
@@ -54,9 +48,9 @@ class FaissVecDB(BaseVecDB):
|
||||
result = await self.document_storage.get_document_by_doc_id(str_id)
|
||||
int_id = result["id"]
|
||||
|
||||
# 插入向量到 FAISS
|
||||
await self.embedding_storage.insert(vector, int_id)
|
||||
return int_id
|
||||
# 插入向量到 FAISS
|
||||
await self.embedding_storage.insert(vector, int_id)
|
||||
return int_id
|
||||
|
||||
async def retrieve(
|
||||
self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None
|
||||
|
||||
@@ -43,9 +43,8 @@ class PreProcessStage(Stage):
|
||||
# STT
|
||||
if self.stt_settings.get("enable", False):
|
||||
# TODO: 独立
|
||||
stt_provider = (
|
||||
self.plugin_manager.context.provider_manager.curr_stt_provider_inst
|
||||
)
|
||||
ctx = self.plugin_manager.context
|
||||
stt_provider = ctx.get_using_stt_provider(event.unified_msg_origin)
|
||||
if not stt_provider:
|
||||
return
|
||||
message_chain = event.get_messages()
|
||||
|
||||
@@ -33,6 +33,7 @@ from mcp.types import (
|
||||
TextResourceContents,
|
||||
BlobResourceContents,
|
||||
)
|
||||
from astrbot.core import web_chat_back_queue
|
||||
|
||||
|
||||
class LLMRequestSubStage(Stage):
|
||||
@@ -70,8 +71,8 @@ class LLMRequestSubStage(Stage):
|
||||
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
|
||||
logger.debug("未启用 LLM 能力,跳过处理。")
|
||||
return
|
||||
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider()
|
||||
umo = event.unified_msg_origin
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider(umo=umo)
|
||||
if provider is None:
|
||||
return
|
||||
|
||||
@@ -287,7 +288,66 @@ class LLMRequestSubStage(Stage):
|
||||
if img_b64 := event.get_extra("tool_call_img_respond"):
|
||||
await event.send(MessageChain(chain=[Image.fromBase64(img_b64)]))
|
||||
event.set_extra("tool_call_img_respond", None)
|
||||
yield
|
||||
|
||||
if event.get_platform_name() == "webchat":
|
||||
# 异步处理 WebChat 特殊情况
|
||||
asyncio.create_task(self._handle_webchat(event, req))
|
||||
|
||||
async def _handle_webchat(self, event: AstrMessageEvent, req: ProviderRequest):
|
||||
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
|
||||
conversation = await self.conv_manager.get_conversation(
|
||||
event.unified_msg_origin, req.conversation.cid
|
||||
)
|
||||
if conversation and not req.conversation.title:
|
||||
messages = json.loads(conversation.history)
|
||||
latest_pair = messages[-2:]
|
||||
if not latest_pair:
|
||||
return
|
||||
provider = self.ctx.plugin_manager.context.get_using_provider()
|
||||
cleaned_text = "User: " + latest_pair[0].get("content", "").strip()
|
||||
# if len(latest_pair) > 1:
|
||||
# cleaned_text += (
|
||||
# "\nAssistant: " + latest_pair[1].get("content", "").strip()
|
||||
# )
|
||||
logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
|
||||
llm_resp = await provider.text_chat(
|
||||
system_prompt="You are expert in summarizing user's query.",
|
||||
prompt=(
|
||||
f"Please summarize the following query of user:\n"
|
||||
f"{cleaned_text}\n"
|
||||
"Only output the summary within 10 words, DO NOT INCLUDE any other text."
|
||||
"You must use the same language as the user."
|
||||
"If you think the dialog is too short to summarize, only output a special mark: `None`"
|
||||
),
|
||||
)
|
||||
if llm_resp and llm_resp.completion_text:
|
||||
logger.debug(
|
||||
f"WebChat 对话标题生成响应: {llm_resp.completion_text.strip()}"
|
||||
)
|
||||
title = llm_resp.completion_text.strip()
|
||||
if not title or "None" == title:
|
||||
return
|
||||
await self.conv_manager.update_conversation_title(
|
||||
event.unified_msg_origin, title=title
|
||||
)
|
||||
# 由于 WebChat 平台特殊性,其有两个对话,因此我们要更新两个对话的标题
|
||||
# webchat adapter 中,session_id 的格式是 f"webchat!{username}!{cid}"
|
||||
# TODO: 优化 WebChat 适配器的对话管理
|
||||
if event.session_id:
|
||||
username, cid = event.session_id.split("!")[1:3]
|
||||
db_helper = self.ctx.plugin_manager.context._db
|
||||
db_helper.update_conversation_title(
|
||||
user_id=username,
|
||||
cid=cid,
|
||||
title=title,
|
||||
)
|
||||
web_chat_back_queue.put_nowait(
|
||||
{
|
||||
"type": "update_title",
|
||||
"cid": cid,
|
||||
"data": title,
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_llm_response(
|
||||
self,
|
||||
|
||||
@@ -32,6 +32,7 @@ class RespondStage(Stage):
|
||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
|
||||
}
|
||||
|
||||
async def initialize(self, ctx: PipelineContext):
|
||||
@@ -190,6 +191,7 @@ class RespondStage(Stage):
|
||||
await asyncio.sleep(i)
|
||||
try:
|
||||
await event.send(MessageChain([*decorated_comps, comp]))
|
||||
decorated_comps = [] # 清空已发送的装饰组件
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
break
|
||||
|
||||
@@ -169,8 +169,8 @@ class ResultDecorateStage(Stage):
|
||||
result.chain = new_chain
|
||||
|
||||
# TTS
|
||||
tts_provider = (
|
||||
self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst
|
||||
tts_provider = self.ctx.plugin_manager.context.get_using_tts_provider(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
if (
|
||||
self.ctx.astrbot_config["provider_tts_settings"]["enable"]
|
||||
|
||||
@@ -4,7 +4,7 @@ from astrbot import logger
|
||||
from typing import Union, AsyncGenerator
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
|
||||
from astrbot.core.message.components import At
|
||||
from astrbot.core.message.components import At, AtAll, Reply
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
@@ -39,6 +39,9 @@ class WakingCheckStage(Stage):
|
||||
self.ignore_bot_self_message = self.ctx.astrbot_config["platform_settings"].get(
|
||||
"ignore_bot_self_message", False
|
||||
)
|
||||
self.ignore_at_all = self.ctx.astrbot_config["platform_settings"].get(
|
||||
"ignore_at_all", False
|
||||
)
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
@@ -77,11 +80,18 @@ class WakingCheckStage(Stage):
|
||||
event.message_str = event.message_str[len(wake_prefix) :].strip()
|
||||
break
|
||||
if not is_wake:
|
||||
# 检查是否有 at 消息
|
||||
# 检查是否有at消息 / at全体成员消息 / 引用了bot的消息
|
||||
for message in messages:
|
||||
if isinstance(message, At) and (
|
||||
str(message.qq) == str(event.get_self_id())
|
||||
or str(message.qq) == "all"
|
||||
if (
|
||||
(
|
||||
isinstance(message, At)
|
||||
and (str(message.qq) == str(event.get_self_id()))
|
||||
)
|
||||
or (isinstance(message, AtAll) and not self.ignore_at_all)
|
||||
or (
|
||||
isinstance(message, Reply)
|
||||
and str(message.sender_id) == str(event.get_self_id())
|
||||
)
|
||||
):
|
||||
is_wake = True
|
||||
event.is_wake = True
|
||||
|
||||
@@ -77,7 +77,15 @@ class PlatformManager:
|
||||
case "wecom":
|
||||
from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401
|
||||
case "weixin_official_account":
|
||||
from .sources.weixin_official_account.weixin_offacc_adapter import WeixinOfficialAccountPlatformAdapter # noqa
|
||||
from .sources.weixin_official_account.weixin_offacc_adapter import (
|
||||
WeixinOfficialAccountPlatformAdapter, # noqa
|
||||
)
|
||||
case "discord":
|
||||
from .sources.discord.discord_platform_adapter import (
|
||||
DiscordPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "slack":
|
||||
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
|
||||
|
||||
@@ -221,6 +221,9 @@ class AiocqhttpAdapter(Platform):
|
||||
a = None
|
||||
if t == "text":
|
||||
current_text = "".join(m["data"]["text"] for m in m_group).strip()
|
||||
if not current_text:
|
||||
# 如果文本段为空,则跳过
|
||||
continue
|
||||
message_str += current_text
|
||||
a = ComponentTypes[t](text=current_text) # noqa: F405
|
||||
abm.message.append(a)
|
||||
|
||||
@@ -32,31 +32,31 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(segment, Comp.Image):
|
||||
markdown_str = ""
|
||||
if segment.file and segment.file.startswith("file:///"):
|
||||
logger.warning(
|
||||
"dingtalk only support url image, not: " + segment.file
|
||||
)
|
||||
continue
|
||||
elif segment.file and segment.file.startswith("http"):
|
||||
markdown_str += f"\n\n"
|
||||
elif segment.file and segment.file.startswith("base64://"):
|
||||
logger.warning("dingtalk only support url image, not base64")
|
||||
continue
|
||||
else:
|
||||
logger.warning(
|
||||
"dingtalk only support url image, not: " + segment.file
|
||||
)
|
||||
continue
|
||||
|
||||
ret = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
client.reply_markdown,
|
||||
"😄",
|
||||
markdown_str,
|
||||
self.message_obj.raw_message,
|
||||
)
|
||||
logger.debug(f"send image: {ret}")
|
||||
try:
|
||||
if not segment.file:
|
||||
logger.warning("钉钉图片 segment 缺少 file 字段,跳过")
|
||||
continue
|
||||
if segment.file.startswith(("http://", "https://")):
|
||||
image_url = segment.file
|
||||
else:
|
||||
image_url = await segment.register_to_file_service()
|
||||
|
||||
markdown_str = f"\n\n"
|
||||
|
||||
ret = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
client.reply_markdown,
|
||||
"😄",
|
||||
markdown_str,
|
||||
self.message_obj.raw_message,
|
||||
)
|
||||
logger.debug(f"send image: {ret}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉图片处理失败: {e}")
|
||||
logger.warning(f"跳过图片发送: {image_path}")
|
||||
continue
|
||||
async def send(self, message: MessageChain):
|
||||
await self.send_with_client(self.client, message)
|
||||
await super().send(message)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import discord
|
||||
from astrbot import logger
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
# Discord Bot客户端
|
||||
class DiscordBotClient(discord.Bot):
|
||||
"""Discord客户端封装"""
|
||||
|
||||
def __init__(self, token: str, proxy: str = None):
|
||||
self.token = token
|
||||
self.proxy = proxy
|
||||
|
||||
# 设置Intent权限,遵循权限最小化原则
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True # 订阅消息内容事件 (Privileged)
|
||||
intents.members = True # 订阅成员事件 (Privileged)
|
||||
|
||||
# 初始化Bot
|
||||
super().__init__(intents=intents, proxy=proxy)
|
||||
|
||||
# 回调函数
|
||||
self.on_message_received = None
|
||||
self.on_ready_once_callback = None
|
||||
self._ready_once_fired = False
|
||||
|
||||
@override
|
||||
async def on_ready(self):
|
||||
"""当机器人成功连接并准备就绪时触发"""
|
||||
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
|
||||
logger.info("[Discord] 客户端已准备就绪。")
|
||||
|
||||
if self.on_ready_once_callback and not self._ready_once_fired:
|
||||
self._ready_once_fired = True
|
||||
try:
|
||||
await self.on_ready_once_callback()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True)
|
||||
|
||||
def _create_message_data(self, message: discord.Message) -> dict:
|
||||
"""从 discord.Message 创建数据字典"""
|
||||
is_mentioned = self.user in message.mentions
|
||||
return {
|
||||
"message": message,
|
||||
"bot_id": str(self.user.id),
|
||||
"content": message.content,
|
||||
"username": message.author.display_name,
|
||||
"userid": str(message.author.id),
|
||||
"message_id": str(message.id),
|
||||
"channel_id": str(message.channel.id),
|
||||
"guild_id": str(message.guild.id) if message.guild else None,
|
||||
"type": "message",
|
||||
"is_mentioned": is_mentioned,
|
||||
"clean_content": message.clean_content,
|
||||
}
|
||||
|
||||
def _create_interaction_data(self, interaction: discord.Interaction) -> dict:
|
||||
"""从 discord.Interaction 创建数据字典"""
|
||||
return {
|
||||
"interaction": interaction,
|
||||
"bot_id": str(self.user.id),
|
||||
"content": self._extract_interaction_content(interaction),
|
||||
"username": interaction.user.display_name,
|
||||
"userid": str(interaction.user.id),
|
||||
"message_id": str(interaction.id),
|
||||
"channel_id": str(interaction.channel_id)
|
||||
if interaction.channel_id
|
||||
else None,
|
||||
"guild_id": str(interaction.guild_id) if interaction.guild_id else None,
|
||||
"type": "interaction",
|
||||
}
|
||||
|
||||
@override
|
||||
async def on_message(self, message: discord.Message):
|
||||
"""当接收到消息时触发"""
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"[Discord] 收到原始消息 from {message.author.name}: {message.content}"
|
||||
)
|
||||
|
||||
if self.on_message_received:
|
||||
message_data = self._create_message_data(message)
|
||||
await self.on_message_received(message_data)
|
||||
|
||||
|
||||
def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
|
||||
"""从交互中提取内容"""
|
||||
interaction_type = interaction.type
|
||||
interaction_data = getattr(interaction, "data", {})
|
||||
|
||||
if not interaction_data:
|
||||
return ""
|
||||
|
||||
if interaction_type == discord.InteractionType.application_command:
|
||||
command_name = interaction_data.get("name", "")
|
||||
if options := interaction_data.get("options", []):
|
||||
params = " ".join(
|
||||
[f"{opt['name']}:{opt.get('value', '')}" for opt in options]
|
||||
)
|
||||
return f"/{command_name} {params}"
|
||||
return f"/{command_name}"
|
||||
|
||||
elif interaction_type == discord.InteractionType.component:
|
||||
custom_id = interaction_data.get("custom_id", "")
|
||||
component_type = interaction_data.get("component_type", "")
|
||||
return f"component:{custom_id}:{component_type}"
|
||||
|
||||
return str(interaction_data)
|
||||
|
||||
async def start_polling(self):
|
||||
"""开始轮询消息,这是个阻塞方法"""
|
||||
await self.start(self.token)
|
||||
|
||||
@override
|
||||
async def close(self):
|
||||
"""关闭客户端"""
|
||||
if not self.is_closed():
|
||||
await super().close()
|
||||
@@ -0,0 +1,133 @@
|
||||
import discord
|
||||
from typing import List
|
||||
from astrbot.api.message_components import BaseMessageComponent
|
||||
|
||||
|
||||
# Discord专用组件
|
||||
class DiscordEmbed(BaseMessageComponent):
|
||||
"""Discord Embed消息组件"""
|
||||
|
||||
type: str = "discord_embed"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str = None,
|
||||
description: str = None,
|
||||
color: int = None,
|
||||
url: str = None,
|
||||
thumbnail: str = None,
|
||||
image: str = None,
|
||||
footer: str = None,
|
||||
fields: List[dict] = None,
|
||||
):
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.color = color
|
||||
self.url = url
|
||||
self.thumbnail = thumbnail
|
||||
self.image = image
|
||||
self.footer = footer
|
||||
self.fields = fields or []
|
||||
|
||||
def to_discord_embed(self) -> discord.Embed:
|
||||
"""转换为Discord Embed对象"""
|
||||
embed = discord.Embed()
|
||||
|
||||
if self.title:
|
||||
embed.title = self.title
|
||||
if self.description:
|
||||
embed.description = self.description
|
||||
if self.color:
|
||||
embed.color = self.color
|
||||
if self.url:
|
||||
embed.url = self.url
|
||||
if self.thumbnail:
|
||||
embed.set_thumbnail(url=self.thumbnail)
|
||||
if self.image:
|
||||
embed.set_image(url=self.image)
|
||||
if self.footer:
|
||||
embed.set_footer(text=self.footer)
|
||||
|
||||
for field in self.fields:
|
||||
embed.add_field(
|
||||
name=field.get("name", ""),
|
||||
value=field.get("value", ""),
|
||||
inline=field.get("inline", False),
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
class DiscordButton(BaseMessageComponent):
|
||||
"""Discord按钮组件"""
|
||||
|
||||
type: str = "discord_button"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
custom_id: str = None,
|
||||
style: str = "primary",
|
||||
emoji: str = None,
|
||||
url: str = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
self.label = label
|
||||
self.custom_id = custom_id
|
||||
self.style = style
|
||||
self.emoji = emoji
|
||||
self.url = url
|
||||
self.disabled = disabled
|
||||
|
||||
class DiscordReference(BaseMessageComponent):
|
||||
"""Discord引用组件"""
|
||||
type: str = "discord_reference"
|
||||
def __init__(self, message_id: str, channel_id: str):
|
||||
self.message_id = message_id
|
||||
self.channel_id = channel_id
|
||||
|
||||
|
||||
class DiscordView(BaseMessageComponent):
|
||||
"""Discord视图组件,包含按钮和选择菜单"""
|
||||
|
||||
type: str = "discord_view"
|
||||
|
||||
def __init__(
|
||||
self, components: List[BaseMessageComponent] = None, timeout: float = None
|
||||
):
|
||||
self.components = components or []
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
def to_discord_view(self) -> discord.ui.View:
|
||||
"""转换为Discord View对象"""
|
||||
view = discord.ui.View(timeout=self.timeout)
|
||||
|
||||
for component in self.components:
|
||||
if isinstance(component, DiscordButton):
|
||||
button_style = getattr(
|
||||
discord.ButtonStyle, component.style, discord.ButtonStyle.primary
|
||||
)
|
||||
|
||||
if component.url:
|
||||
# URL按钮
|
||||
button = discord.ui.Button(
|
||||
label=component.label,
|
||||
style=discord.ButtonStyle.link,
|
||||
url=component.url,
|
||||
emoji=component.emoji,
|
||||
disabled=component.disabled,
|
||||
)
|
||||
else:
|
||||
# 普通按钮
|
||||
button = discord.ui.Button(
|
||||
label=component.label,
|
||||
style=button_style,
|
||||
custom_id=component.custom_id,
|
||||
emoji=component.emoji,
|
||||
disabled=component.disabled,
|
||||
)
|
||||
|
||||
view.add_item(button)
|
||||
|
||||
return view
|
||||
@@ -0,0 +1,412 @@
|
||||
import asyncio
|
||||
import discord
|
||||
import sys
|
||||
import re
|
||||
from discord.abc import Messageable
|
||||
from discord.channel import DMChannel
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
PlatformMetadata,
|
||||
MessageType,
|
||||
)
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import Plain, Image, File
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.api.platform import register_platform_adapter
|
||||
from astrbot import logger
|
||||
from .client import DiscordBotClient
|
||||
from .discord_platform_event import DiscordPlatformEvent
|
||||
|
||||
from typing import Any, Tuple
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
# 注册平台适配器
|
||||
@register_platform_adapter("discord", "Discord 适配器 (基于 Pycord)")
|
||||
class DiscordPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(event_queue)
|
||||
self.config = platform_config
|
||||
self.settings = platform_settings
|
||||
self.client_self_id = None
|
||||
self.registered_handlers = []
|
||||
# 指令注册相关
|
||||
self.enable_command_register = self.config.get("discord_command_register", True)
|
||||
self.guild_id = self.config.get("discord_guild_id_for_debug", None)
|
||||
self.activity_name = self.config.get("discord_activity_name", None)
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
"""通过会话发送消息"""
|
||||
# 创建一个 message_obj 以便在 event 中使用
|
||||
message_obj = AstrBotMessage()
|
||||
if "_" in session.session_id:
|
||||
session.session_id = session.session_id.split("_")[1]
|
||||
channel_id_str = session.session_id
|
||||
channel = None
|
||||
try:
|
||||
channel_id = int(channel_id_str)
|
||||
channel = self.client.get_channel(channel_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"[Discord] Invalid channel ID format: {channel_id_str}")
|
||||
|
||||
if channel:
|
||||
message_obj.type = self._get_message_type(channel)
|
||||
message_obj.group_id = self._get_channel_id(channel)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Discord] Can't get channel info for {channel_id_str}, will guess message type."
|
||||
)
|
||||
message_obj.type = MessageType.GROUP_MESSAGE
|
||||
message_obj.group_id = session.session_id
|
||||
|
||||
message_obj.message_str = message_chain.get_plain_text()
|
||||
message_obj.sender = MessageMember(
|
||||
user_id=str(self.client_self_id), nickname=self.client.user.display_name
|
||||
)
|
||||
message_obj.self_id = self.client_self_id
|
||||
message_obj.session_id = session.session_id
|
||||
message_obj.message = message_chain
|
||||
|
||||
# 创建临时事件对象来发送消息
|
||||
temp_event = DiscordPlatformEvent(
|
||||
message_str=message_chain.get_plain_text(),
|
||||
message_obj=message_obj,
|
||||
platform_meta=self.meta(),
|
||||
session_id=session.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
await temp_event.send(message_chain)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
@override
|
||||
def meta(self) -> PlatformMetadata:
|
||||
"""返回平台元数据"""
|
||||
return PlatformMetadata(
|
||||
"discord",
|
||||
"Discord 适配器",
|
||||
id=self.config.get("id"),
|
||||
default_config_tmpl=self.config,
|
||||
)
|
||||
|
||||
@override
|
||||
async def run(self):
|
||||
"""主要运行逻辑"""
|
||||
|
||||
# 初始化回调函数
|
||||
async def on_received(message_data):
|
||||
logger.debug(f"[Discord] 收到消息: {message_data}")
|
||||
if self.client_self_id is None:
|
||||
self.client_self_id = message_data.get("bot_id")
|
||||
abm = await self.convert_message(data=message_data)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
# 初始化 Discord 客户端
|
||||
token = str(self.config.get("discord_token"))
|
||||
if not token:
|
||||
logger.error("[Discord] Bot Token 未配置。请在配置文件中正确设置 token。")
|
||||
return
|
||||
|
||||
proxy = self.config.get("discord_proxy") or None
|
||||
self.client = DiscordBotClient(token, proxy)
|
||||
self.client.on_message_received = on_received
|
||||
|
||||
async def callback():
|
||||
if self.enable_command_register:
|
||||
await self._collect_and_register_commands()
|
||||
if self.activity_name:
|
||||
await self.client.change_presence(
|
||||
status=discord.Status.online,
|
||||
activity=discord.CustomActivity(name=self.activity_name),
|
||||
)
|
||||
|
||||
self.client.on_ready_once_callback = callback
|
||||
|
||||
try:
|
||||
await self.client.start_polling()
|
||||
except discord.errors.LoginFailure:
|
||||
logger.error("[Discord] 登录失败。请检查你的 Bot Token 是否正确。")
|
||||
except discord.errors.ConnectionClosed:
|
||||
logger.warning("[Discord] 与 Discord 的连接已关闭。")
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
|
||||
|
||||
def _get_message_type(
|
||||
self, channel: Messageable, guild_id: int | None = None
|
||||
) -> MessageType:
|
||||
"""根据 channel 对象和 guild_id 判断消息类型"""
|
||||
if guild_id is not None:
|
||||
return MessageType.GROUP_MESSAGE
|
||||
if isinstance(channel, DMChannel) or getattr(channel, "guild", None) is None:
|
||||
return MessageType.FRIEND_MESSAGE
|
||||
return MessageType.GROUP_MESSAGE
|
||||
|
||||
def _get_channel_id(self, channel: Messageable) -> str:
|
||||
"""根据 channel 对象获取ID"""
|
||||
return str(getattr(channel, "id", None))
|
||||
|
||||
def _convert_message_to_abm(self, data: dict) -> AstrBotMessage:
|
||||
"""将普通消息转换为 AstrBotMessage"""
|
||||
message: discord.Message = data["message"]
|
||||
is_mentioned = data.get("is_mentioned", False)
|
||||
|
||||
content = message.content
|
||||
|
||||
# 如果机器人被@,移除@部分
|
||||
if (
|
||||
is_mentioned
|
||||
and self.client
|
||||
and self.client.user
|
||||
and self.client.user in message.mentions
|
||||
):
|
||||
# 构建机器人的@字符串,格式为 <@USER_ID> 或 <@!USER_ID>
|
||||
mention_str = f"<@{self.client.user.id}>"
|
||||
mention_str_nickname = (
|
||||
f"<@!{self.client.user.id}>" # 有些客户端会使用带!的格式
|
||||
)
|
||||
|
||||
if content.startswith(mention_str):
|
||||
content = content[len(mention_str) :].lstrip()
|
||||
elif content.startswith(mention_str_nickname):
|
||||
content = content[len(mention_str_nickname) :].lstrip()
|
||||
|
||||
abm = AstrBotMessage()
|
||||
|
||||
abm.type = self._get_message_type(message.channel)
|
||||
abm.group_id = self._get_channel_id(message.channel)
|
||||
|
||||
abm.message_str = content
|
||||
abm.sender = MessageMember(
|
||||
user_id=str(message.author.id), nickname=message.author.display_name
|
||||
)
|
||||
|
||||
message_chain = []
|
||||
if abm.message_str:
|
||||
message_chain.append(Plain(text=abm.message_str))
|
||||
|
||||
if message.attachments:
|
||||
for attachment in message.attachments:
|
||||
if attachment.content_type and attachment.content_type.startswith(
|
||||
"image/"
|
||||
):
|
||||
message_chain.append(
|
||||
Image(file=attachment.url, filename=attachment.filename)
|
||||
)
|
||||
else:
|
||||
message_chain.append(
|
||||
File(name=attachment.filename, url=attachment.url)
|
||||
)
|
||||
|
||||
abm.message = message_chain
|
||||
abm.raw_message = message
|
||||
abm.self_id = self.client_self_id
|
||||
abm.session_id = str(message.channel.id)
|
||||
abm.message_id = str(message.id)
|
||||
return abm
|
||||
|
||||
async def convert_message(self, data: dict) -> AstrBotMessage:
|
||||
"""将平台消息转换成 AstrBotMessage"""
|
||||
# 由于 on_interaction 已被禁用,我们只处理普通消息
|
||||
return self._convert_message_to_abm(data)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage, followup_webhook=None):
|
||||
"""处理消息"""
|
||||
message_event = DiscordPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
interaction_followup_webhook=followup_webhook,
|
||||
)
|
||||
|
||||
# 检查是否为斜杠指令
|
||||
is_slash_command = message_event.interaction_followup_webhook is not None
|
||||
|
||||
# 检查是否被@
|
||||
is_mention = (
|
||||
self.client
|
||||
and self.client.user
|
||||
and hasattr(message.raw_message, "mentions")
|
||||
and self.client.user in message.raw_message.mentions
|
||||
)
|
||||
|
||||
# 如果是斜杠指令或被@的消息,设置为唤醒状态
|
||||
if is_slash_command or is_mention:
|
||||
message_event.is_wake = True
|
||||
message_event.is_at_or_wake_command = True
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
@override
|
||||
async def terminate(self):
|
||||
"""终止适配器"""
|
||||
logger.info("[Discord] 正在终止适配器...")
|
||||
|
||||
# 清理指令
|
||||
if self.enable_command_register and self.client:
|
||||
logger.info("[Discord] 正在清理已注册的斜杠指令...")
|
||||
try:
|
||||
# 传入空的列表来清除所有全局指令
|
||||
# 如果指定了 guild_id,则只清除该服务器的指令
|
||||
await self.client.sync_commands(
|
||||
commands=[], guild_ids=[self.guild_id] if self.guild_id else None
|
||||
)
|
||||
logger.info("[Discord] 指令清理完成。")
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
|
||||
|
||||
if self.client and hasattr(self.client, "close"):
|
||||
await self.client.close()
|
||||
logger.info("[Discord] 适配器已终止。")
|
||||
|
||||
def register_handler(self, handler_info):
|
||||
"""注册处理器信息"""
|
||||
self.registered_handlers.append(handler_info)
|
||||
|
||||
async def _collect_and_register_commands(self):
|
||||
"""收集所有指令并注册到Discord"""
|
||||
logger.info("[Discord] 开始收集并注册斜杠指令...")
|
||||
registered_commands = []
|
||||
|
||||
for handler_md in star_handlers_registry:
|
||||
if not star_map[handler_md.handler_module_path].activated:
|
||||
continue
|
||||
for event_filter in handler_md.event_filters:
|
||||
cmd_info = self._extract_command_info(event_filter, handler_md)
|
||||
if not cmd_info:
|
||||
continue
|
||||
|
||||
cmd_name, description, cmd_filter_instance = cmd_info
|
||||
|
||||
# 创建动态回调
|
||||
callback = self._create_dynamic_callback(cmd_name)
|
||||
|
||||
# 创建一个通用的参数选项来接收所有文本输入
|
||||
options = [
|
||||
discord.Option(
|
||||
name="params",
|
||||
description="指令的所有参数",
|
||||
type=discord.SlashCommandOptionType.string,
|
||||
required=False,
|
||||
)
|
||||
]
|
||||
|
||||
# 创建SlashCommand
|
||||
slash_command = discord.SlashCommand(
|
||||
name=cmd_name,
|
||||
description=description,
|
||||
func=callback,
|
||||
options=options,
|
||||
guild_ids=[self.guild_id] if self.guild_id else None,
|
||||
)
|
||||
self.client.add_application_command(slash_command)
|
||||
registered_commands.append(cmd_name)
|
||||
|
||||
if registered_commands:
|
||||
logger.info(
|
||||
f"[Discord] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}"
|
||||
)
|
||||
else:
|
||||
logger.info("[Discord] 没有发现可注册的指令。")
|
||||
|
||||
# 使用 Pycord 的方法同步指令
|
||||
# 注意:这可能需要一些时间,并且有频率限制
|
||||
await self.client.sync_commands()
|
||||
logger.info("[Discord] 指令同步完成。")
|
||||
|
||||
def _create_dynamic_callback(self, cmd_name: str):
|
||||
"""为每个指令动态创建一个异步回调函数"""
|
||||
|
||||
async def dynamic_callback(ctx: discord.ApplicationContext, params: str = None):
|
||||
# 将平台特定的前缀'/'剥离,以适配通用的CommandFilter
|
||||
logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
|
||||
logger.debug(f"[Discord] 回调函数参数: {ctx}")
|
||||
logger.debug(f"[Discord] 回调函数参数: {params}")
|
||||
message_str_for_filter = cmd_name
|
||||
if params:
|
||||
message_str_for_filter += f" {params}"
|
||||
|
||||
logger.debug(
|
||||
f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
|
||||
f"原始参数: '{params}'. "
|
||||
f"构建的指令字符串: '{message_str_for_filter}'"
|
||||
)
|
||||
|
||||
# 尝试立即响应,防止超时
|
||||
followup_webhook = None
|
||||
try:
|
||||
await ctx.defer()
|
||||
followup_webhook = ctx.followup
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
|
||||
|
||||
# 2. 构建 AstrBotMessage
|
||||
abm = AstrBotMessage()
|
||||
abm.type = self._get_message_type(ctx.channel, ctx.guild_id)
|
||||
abm.group_id = self._get_channel_id(ctx.channel)
|
||||
abm.message_str = message_str_for_filter
|
||||
abm.sender = MessageMember(
|
||||
user_id=str(ctx.author.id), nickname=ctx.author.display_name
|
||||
)
|
||||
abm.message = [Plain(text=message_str_for_filter)]
|
||||
abm.raw_message = ctx.interaction
|
||||
abm.self_id = self.client_self_id
|
||||
abm.session_id = str(ctx.channel_id)
|
||||
abm.message_id = str(ctx.interaction.id)
|
||||
|
||||
# 3. 将消息和 webhook 分别交给 handle_msg 处理
|
||||
await self.handle_msg(abm, followup_webhook)
|
||||
|
||||
return dynamic_callback
|
||||
|
||||
@staticmethod
|
||||
def _extract_command_info(
|
||||
event_filter: Any, handler_metadata: StarHandlerMetadata
|
||||
) -> Tuple[str, str, CommandFilter] | None:
|
||||
"""从事件过滤器中提取指令信息"""
|
||||
cmd_name = None
|
||||
# is_group = False
|
||||
cmd_filter_instance = None
|
||||
|
||||
if isinstance(event_filter, CommandFilter):
|
||||
# 暂不支持子指令注册为斜杠指令
|
||||
if (
|
||||
event_filter.parent_command_names
|
||||
and event_filter.parent_command_names != [""]
|
||||
):
|
||||
return None
|
||||
cmd_name = event_filter.command_name
|
||||
cmd_filter_instance = event_filter
|
||||
|
||||
elif isinstance(event_filter, CommandGroupFilter):
|
||||
# 暂不支持指令组直接注册为斜杠指令,因为它们没有 handle 方法
|
||||
return None
|
||||
|
||||
if not cmd_name:
|
||||
return None
|
||||
|
||||
# Discord 斜杠指令名称规范
|
||||
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
|
||||
logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
|
||||
return None
|
||||
|
||||
description = handler_metadata.desc or f"指令: {cmd_name}"
|
||||
if len(description) > 100:
|
||||
description = f"{description[:97]}..."
|
||||
|
||||
return cmd_name, description, cmd_filter_instance
|
||||
@@ -0,0 +1,291 @@
|
||||
import asyncio
|
||||
import discord
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import sys
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, At
|
||||
from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Image,
|
||||
File,
|
||||
BaseMessageComponent,
|
||||
Reply,
|
||||
)
|
||||
from astrbot import logger
|
||||
from .client import DiscordBotClient
|
||||
from .components import DiscordEmbed, DiscordView
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
# 自定义Discord视图组件(兼容旧版本)
|
||||
class DiscordViewComponent(BaseMessageComponent):
|
||||
type: str = "discord_view"
|
||||
|
||||
def __init__(self, view: discord.ui.View):
|
||||
self.view = view
|
||||
|
||||
|
||||
class DiscordPlatformEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj: AstrBotMessage,
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: DiscordBotClient,
|
||||
interaction_followup_webhook: Optional[discord.Webhook] = None,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.interaction_followup_webhook = interaction_followup_webhook
|
||||
|
||||
@override
|
||||
async def send(self, message: MessageChain):
|
||||
"""发送消息到Discord平台"""
|
||||
|
||||
# 解析消息链为 Discord 所需的对象
|
||||
try:
|
||||
content, files, view, embeds, reference_message_id = await self._parse_to_discord(message)
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
kwargs = {}
|
||||
if content:
|
||||
kwargs["content"] = content
|
||||
if files:
|
||||
kwargs["files"] = files
|
||||
if view:
|
||||
kwargs["view"] = view
|
||||
if embeds:
|
||||
kwargs["embeds"] = embeds
|
||||
if reference_message_id and not self.interaction_followup_webhook:
|
||||
kwargs["reference"] = self.client.get_message(int(reference_message_id))
|
||||
if not kwargs:
|
||||
logger.debug("[Discord] 尝试发送空消息,已忽略。")
|
||||
return
|
||||
|
||||
# 根据上下文执行发送/回复操作
|
||||
try:
|
||||
# -- 斜杠指令/交互上下文 --
|
||||
if self.interaction_followup_webhook:
|
||||
await self.interaction_followup_webhook.send(**kwargs)
|
||||
|
||||
# -- 常规消息上下文 --
|
||||
else:
|
||||
channel = await self._get_channel()
|
||||
if not channel:
|
||||
return
|
||||
else:
|
||||
await channel.send(**kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 发送消息时发生未知错误: {e}", exc_info=True)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def _get_channel(self) -> Optional[discord.abc.Messageable]:
|
||||
"""获取当前事件对应的频道对象"""
|
||||
try:
|
||||
channel_id = int(self.session_id)
|
||||
return self.client.get_channel(
|
||||
channel_id
|
||||
) or await self.client.fetch_channel(channel_id)
|
||||
except (ValueError, discord.errors.NotFound, discord.errors.Forbidden):
|
||||
logger.error(f"[Discord] 无法获取频道 {self.session_id}")
|
||||
return None
|
||||
|
||||
async def _parse_to_discord(
|
||||
self,
|
||||
message: MessageChain,
|
||||
) -> tuple[str, list[discord.File], Optional[discord.ui.View], list[discord.Embed]]:
|
||||
"""将 MessageChain 解析为 Discord 发送所需的内容"""
|
||||
content = ""
|
||||
files = []
|
||||
view = None
|
||||
embeds = []
|
||||
reference_message_id = None
|
||||
for i in message.chain: # 遍历消息链
|
||||
if isinstance(i, Plain): # 如果是文字类型的
|
||||
content += i.text
|
||||
elif isinstance(i, Reply):
|
||||
reference_message_id = i.id
|
||||
elif isinstance(i, At):
|
||||
content += f"<@{i.qq}>"
|
||||
elif isinstance(i, Image):
|
||||
logger.debug(f"[Discord] 开始处理 Image 组件: {i}")
|
||||
try:
|
||||
filename = getattr(i, "filename", None)
|
||||
file_content = getattr(i, "file", None)
|
||||
|
||||
if not file_content:
|
||||
logger.warning(f"[Discord] Image 组件没有 file 属性: {i}")
|
||||
continue
|
||||
|
||||
discord_file = None
|
||||
|
||||
# 1. URL
|
||||
if file_content.startswith("http"):
|
||||
logger.debug(f"[Discord] 处理 URL 图片: {file_content}")
|
||||
embed = discord.Embed().set_image(url=file_content)
|
||||
embeds.append(embed)
|
||||
continue
|
||||
|
||||
# 2. File URI
|
||||
elif file_content.startswith("file:///"):
|
||||
logger.debug(f"[Discord] 处理 File URI: {file_content}")
|
||||
path = Path(file_content[8:])
|
||||
if await asyncio.to_thread(path.exists):
|
||||
file_bytes = await asyncio.to_thread(path.read_bytes)
|
||||
discord_file = discord.File(
|
||||
BytesIO(file_bytes), filename=filename or path.name
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[Discord] 图片文件不存在: {path}")
|
||||
|
||||
# 3. Base64 URI
|
||||
elif file_content.startswith("base64://"):
|
||||
logger.debug("[Discord] 处理 Base64 URI")
|
||||
b64_data = file_content.split("base64://", 1)[1]
|
||||
missing_padding = len(b64_data) % 4
|
||||
if missing_padding:
|
||||
b64_data += "=" * (4 - missing_padding)
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
discord_file = discord.File(
|
||||
BytesIO(img_bytes), filename=filename or "image.png"
|
||||
)
|
||||
|
||||
# 4. 裸 Base64 或本地路径
|
||||
else:
|
||||
try:
|
||||
logger.debug("[Discord] 尝试作为裸 Base64 处理")
|
||||
b64_data = file_content
|
||||
missing_padding = len(b64_data) % 4
|
||||
if missing_padding:
|
||||
b64_data += "=" * (4 - missing_padding)
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
discord_file = discord.File(
|
||||
BytesIO(img_bytes), filename=filename or "image.png"
|
||||
)
|
||||
except (ValueError, TypeError, base64.binascii.Error):
|
||||
logger.debug(
|
||||
f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}"
|
||||
)
|
||||
path = Path(file_content)
|
||||
if await asyncio.to_thread(path.exists):
|
||||
file_bytes = await asyncio.to_thread(path.read_bytes)
|
||||
discord_file = discord.File(
|
||||
BytesIO(file_bytes), filename=filename or path.name
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[Discord] 图片文件不存在: {path}")
|
||||
|
||||
if discord_file:
|
||||
files.append(discord_file)
|
||||
|
||||
except Exception:
|
||||
# 使用 getattr 来安全地访问 i.file,以防 i 本身就是问题
|
||||
file_info = getattr(i, "file", "未知")
|
||||
logger.error(
|
||||
f"[Discord] 处理图片时发生未知严重错误: {file_info}",
|
||||
exc_info=True,
|
||||
)
|
||||
elif isinstance(i, File):
|
||||
try:
|
||||
file_path_str = await i.get_file()
|
||||
if file_path_str:
|
||||
path = Path(file_path_str)
|
||||
if await asyncio.to_thread(path.exists):
|
||||
file_bytes = await asyncio.to_thread(path.read_bytes)
|
||||
files.append(
|
||||
discord.File(BytesIO(file_bytes),
|
||||
filename=i.name)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Discord] 获取文件失败,路径不存在: {file_path_str}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[Discord] 获取文件失败: {i.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] 处理文件失败: {i.name}, 错误: {e}")
|
||||
elif isinstance(i, DiscordEmbed):
|
||||
# Discord Embed消息
|
||||
embeds.append(i.to_discord_embed())
|
||||
elif isinstance(i, DiscordView):
|
||||
# Discord视图组件(按钮、选择菜单等)
|
||||
view = i.to_discord_view()
|
||||
elif isinstance(i, DiscordViewComponent):
|
||||
# 如果消息链中包含Discord视图组件(兼容旧版本)
|
||||
if isinstance(i.view, discord.ui.View):
|
||||
view = i.view
|
||||
else:
|
||||
logger.debug(f"[Discord] 忽略了不支持的消息组件: {i.type}")
|
||||
|
||||
if len(content) > 2000:
|
||||
logger.warning("[Discord] 消息内容超过2000字符,将被截断。")
|
||||
content = content[:2000]
|
||||
return content, files, view, embeds, reference_message_id
|
||||
|
||||
async def react(self, emoji: str):
|
||||
"""对原消息添加反应"""
|
||||
try:
|
||||
if hasattr(self.message_obj, "raw_message") and hasattr(
|
||||
self.message_obj.raw_message, "add_reaction"
|
||||
):
|
||||
await self.message_obj.raw_message.add_reaction(emoji)
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 添加反应失败: {e}")
|
||||
|
||||
def is_slash_command(self) -> bool:
|
||||
"""判断是否为斜杠命令"""
|
||||
return (
|
||||
hasattr(self.message_obj, "raw_message")
|
||||
and hasattr(self.message_obj.raw_message, "type")
|
||||
and self.message_obj.raw_message.type
|
||||
== discord.InteractionType.application_command
|
||||
)
|
||||
|
||||
def is_button_interaction(self) -> bool:
|
||||
"""判断是否为按钮交互"""
|
||||
return (
|
||||
hasattr(self.message_obj, "raw_message")
|
||||
and hasattr(self.message_obj.raw_message, "type")
|
||||
and self.message_obj.raw_message.type == discord.InteractionType.component
|
||||
)
|
||||
|
||||
def get_interaction_custom_id(self) -> str:
|
||||
"""获取交互组件的custom_id"""
|
||||
if self.is_button_interaction():
|
||||
try:
|
||||
return self.message_obj.raw_message.data.get("custom_id", "")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def is_mentioned(self) -> bool:
|
||||
"""判断机器人是否被@"""
|
||||
if hasattr(self.message_obj, "raw_message") and hasattr(
|
||||
self.message_obj.raw_message, "mentions"
|
||||
):
|
||||
return any(
|
||||
mention.id == int(self.message_obj.self_id)
|
||||
for mention in self.message_obj.raw_message.mentions
|
||||
)
|
||||
return False
|
||||
|
||||
def get_mention_clean_content(self) -> str:
|
||||
"""获取去除@后的清洁内容"""
|
||||
if hasattr(self.message_obj, "raw_message") and hasattr(
|
||||
self.message_obj.raw_message, "clean_content"
|
||||
):
|
||||
return self.message_obj.raw_message.clean_content
|
||||
return self.message_str
|
||||
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
from quart import Quart, request, Response
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
from slack_sdk.socket_mode.aiohttp import SocketModeClient
|
||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class SlackWebhookClient:
|
||||
"""Slack Webhook 模式客户端,使用 Quart 作为 Web 服务器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
web_client: AsyncWebClient,
|
||||
signing_secret: str,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 3000,
|
||||
path: str = "/slack/events",
|
||||
event_handler: Optional[Callable] = None,
|
||||
):
|
||||
self.web_client = web_client
|
||||
self.signing_secret = signing_secret
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.path = path
|
||||
self.event_handler = event_handler
|
||||
|
||||
self.app = Quart(__name__)
|
||||
self._setup_routes()
|
||||
|
||||
# 禁用 Quart 的默认日志输出
|
||||
logging.getLogger("quart.app").setLevel(logging.WARNING)
|
||||
logging.getLogger("quart.serving").setLevel(logging.WARNING)
|
||||
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
def _setup_routes(self):
|
||||
"""设置路由"""
|
||||
|
||||
@self.app.route(self.path, methods=["POST"])
|
||||
async def slack_events():
|
||||
"""处理 Slack 事件"""
|
||||
try:
|
||||
# 获取请求体和头部
|
||||
body = await request.get_data()
|
||||
event_data = json.loads(body.decode("utf-8"))
|
||||
|
||||
# Verify Slack request signature
|
||||
timestamp = request.headers.get("X-Slack-Request-Timestamp")
|
||||
signature = request.headers.get("X-Slack-Signature")
|
||||
if not timestamp or not signature:
|
||||
return Response("Missing headers", status=400)
|
||||
# Calculate the HMAC signature
|
||||
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
|
||||
my_signature = (
|
||||
"v0="
|
||||
+ hmac.new(
|
||||
self.signing_secret.encode("utf-8"),
|
||||
sig_basestring.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
# Verify the signature
|
||||
if not hmac.compare_digest(my_signature, signature):
|
||||
logger.warning("Slack request signature verification failed")
|
||||
return Response("Invalid signature", status=400)
|
||||
logger.info(f"Received Slack event: {event_data}")
|
||||
|
||||
# 处理 URL 验证事件
|
||||
if event_data.get("type") == "url_verification":
|
||||
return {"challenge": event_data.get("challenge")}
|
||||
# 处理事件
|
||||
if self.event_handler and event_data.get("type") == "event_callback":
|
||||
await self.event_handler(event_data)
|
||||
|
||||
return Response("", status=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理 Slack 事件时出错: {e}")
|
||||
return Response("Internal Server Error", status=500)
|
||||
|
||||
@self.app.route("/health", methods=["GET"])
|
||||
async def health_check():
|
||||
"""健康检查端点"""
|
||||
return {"status": "ok", "service": "slack-webhook"}
|
||||
|
||||
async def start(self):
|
||||
"""启动 Webhook 服务器"""
|
||||
logger.info(
|
||||
f"Slack Webhook 服务器启动中,监听 {self.host}:{self.port}{self.path}..."
|
||||
)
|
||||
|
||||
await self.app.run_task(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
debug=False,
|
||||
shutdown_trigger=self.shutdown_trigger,
|
||||
)
|
||||
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
async def stop(self):
|
||||
"""停止 Webhook 服务器"""
|
||||
self.shutdown_event.set()
|
||||
logger.info("Slack Webhook 服务器已停止")
|
||||
|
||||
|
||||
class SlackSocketClient:
|
||||
"""Slack Socket 模式客户端"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
web_client: AsyncWebClient,
|
||||
app_token: str,
|
||||
event_handler: Optional[Callable] = None,
|
||||
):
|
||||
self.web_client = web_client
|
||||
self.app_token = app_token
|
||||
self.event_handler = event_handler
|
||||
self.socket_client = None
|
||||
|
||||
async def _handle_events(self, _: SocketModeClient, req: SocketModeRequest):
|
||||
"""处理 Socket Mode 事件"""
|
||||
try:
|
||||
# 确认收到事件
|
||||
response = SocketModeResponse(envelope_id=req.envelope_id)
|
||||
await self.socket_client.send_socket_mode_response(response)
|
||||
|
||||
# 处理事件
|
||||
if self.event_handler:
|
||||
await self.event_handler(req)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理 Socket Mode 事件时出错: {e}")
|
||||
|
||||
async def start(self):
|
||||
"""启动 Socket Mode 连接"""
|
||||
self.socket_client = SocketModeClient(
|
||||
app_token=self.app_token,
|
||||
logger=logger,
|
||||
web_client=self.web_client,
|
||||
)
|
||||
|
||||
# 注册事件处理器
|
||||
self.socket_client.socket_mode_request_listeners.append(self._handle_events)
|
||||
|
||||
logger.info("Slack Socket Mode 客户端启动中...")
|
||||
await self.socket_client.connect()
|
||||
|
||||
async def stop(self):
|
||||
"""停止 Socket Mode 连接"""
|
||||
if self.socket_client:
|
||||
await self.socket_client.disconnect()
|
||||
await self.socket_client.close()
|
||||
logger.info("Slack Socket Mode 客户端已停止")
|
||||
@@ -0,0 +1,396 @@
|
||||
import time
|
||||
import asyncio
|
||||
import uuid
|
||||
import aiohttp
|
||||
import re
|
||||
import base64
|
||||
from typing import Awaitable, Any
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.api.event import MessageChain
|
||||
from .slack_event import SlackMessageEvent
|
||||
from .client import SlackWebhookClient, SlackSocketClient
|
||||
from astrbot.api.message_components import * # noqa: F403
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from ...register import register_platform_adapter
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"slack", "适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。"
|
||||
)
|
||||
class SlackAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(event_queue)
|
||||
|
||||
self.config = platform_config
|
||||
self.settings = platform_settings
|
||||
self.unique_session = platform_settings.get("unique_session", False)
|
||||
|
||||
self.bot_token = platform_config.get("bot_token")
|
||||
self.app_token = platform_config.get("app_token")
|
||||
self.signing_secret = platform_config.get("signing_secret")
|
||||
self.connection_mode = platform_config.get("slack_connection_mode", "socket")
|
||||
self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0")
|
||||
self.webhook_port = platform_config.get("slack_webhook_port", 3000)
|
||||
self.webhook_path = platform_config.get(
|
||||
"slack_webhook_path", "/astrbot-slack-webhook/callback"
|
||||
)
|
||||
|
||||
if not self.bot_token:
|
||||
raise ValueError("Slack bot_token 是必需的")
|
||||
|
||||
if self.connection_mode == "socket" and not self.app_token:
|
||||
raise ValueError("Socket Mode 需要 app_token")
|
||||
|
||||
if self.connection_mode == "webhook" and not self.signing_secret:
|
||||
raise ValueError("Webhook Mode 需要 signing_secret")
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
name="slack",
|
||||
description="适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
# 初始化 Slack Web Client
|
||||
self.web_client = AsyncWebClient(token=self.bot_token, logger=logger)
|
||||
self.socket_client = None
|
||||
self.webhook_client = None
|
||||
|
||||
self.bot_self_id = None
|
||||
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
blocks, text = SlackMessageEvent._parse_slack_blocks(
|
||||
message_chain=message_chain, web_client=self.web_client
|
||||
)
|
||||
|
||||
try:
|
||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
||||
# 发送到频道
|
||||
channel_id = (
|
||||
session.session_id.split("_")[-1]
|
||||
if "_" in session.session_id
|
||||
else session.session_id
|
||||
)
|
||||
await self.web_client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
text=text,
|
||||
blocks=blocks if blocks else None,
|
||||
)
|
||||
else:
|
||||
# 发送私信
|
||||
await self.web_client.chat_postMessage(
|
||||
channel=session.session_id,
|
||||
text=text,
|
||||
blocks=blocks if blocks else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Slack 发送消息失败: {e}")
|
||||
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def convert_message(self, event: dict) -> AstrBotMessage:
|
||||
logger.debug(f"[slack] RawMessage {event}")
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self.bot_self_id
|
||||
|
||||
# 获取用户信息
|
||||
user_id = event.get("user", "")
|
||||
try:
|
||||
user_info = await self.web_client.users_info(user=user_id)
|
||||
user_data = user_info["user"]
|
||||
user_name = user_data.get("real_name") or user_data.get("name", user_id)
|
||||
except Exception:
|
||||
user_name = user_id
|
||||
|
||||
abm.sender = MessageMember(user_id=user_id, nickname=user_name)
|
||||
|
||||
# 判断消息类型
|
||||
channel_id = event.get("channel", "")
|
||||
try:
|
||||
channel_info = await self.web_client.conversations_info(channel=channel_id)
|
||||
is_im = channel_info["channel"]["is_im"]
|
||||
|
||||
if is_im:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
else:
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = channel_id
|
||||
except Exception:
|
||||
# 默认作为群组消息处理
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = channel_id
|
||||
|
||||
# 设置会话ID
|
||||
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
|
||||
abm.session_id = f"{user_id}_{channel_id}"
|
||||
else:
|
||||
abm.session_id = (
|
||||
channel_id if abm.type == MessageType.GROUP_MESSAGE else user_id
|
||||
)
|
||||
|
||||
abm.message_id = event.get("client_msg_id", uuid.uuid4().hex)
|
||||
abm.timestamp = int(float(event.get("ts", time.time())))
|
||||
|
||||
# 处理消息内容
|
||||
message_text = event.get("text", "")
|
||||
abm.message_str = message_text
|
||||
abm.message = []
|
||||
|
||||
# 优先使用 blocks 字段解析消息
|
||||
if "blocks" in event and event["blocks"]:
|
||||
abm.message = self._parse_blocks(event["blocks"])
|
||||
# 更新 message_str
|
||||
abm.message_str = ""
|
||||
for component in abm.message:
|
||||
if isinstance(component, Plain):
|
||||
abm.message_str += component.text
|
||||
elif message_text:
|
||||
# 处理传统的文本消息
|
||||
if "<@" in message_text:
|
||||
mentions = re.findall(r"<@([^>]+)>", message_text)
|
||||
for mention in mentions:
|
||||
try:
|
||||
mentioned_user = await self.web_client.users_info(user=mention)
|
||||
user_data = mentioned_user["user"]
|
||||
user_name = user_data.get("real_name") or user_data.get(
|
||||
"name", mention
|
||||
)
|
||||
abm.message.append(At(qq=mention, name=user_name))
|
||||
except Exception:
|
||||
abm.message.append(At(qq=mention, name=""))
|
||||
|
||||
# 清理消息文本中的@标记
|
||||
if clean_text := re.sub(r"<@[^>]+>", "", message_text).strip():
|
||||
abm.message.append(Plain(text=clean_text))
|
||||
else:
|
||||
abm.message.append(Plain(text=message_text))
|
||||
|
||||
# 处理文件附件
|
||||
if "files" in event:
|
||||
for file_info in event["files"]:
|
||||
file_name = file_info.get("name", "unknown")
|
||||
file_url = file_info.get("url_private", "")
|
||||
if file_info.get("mimetype", "").startswith("image/"):
|
||||
file_url = await self.get_file_base64(file_url)
|
||||
abm.message.append(Image.fromBase64(base64=file_url))
|
||||
else:
|
||||
# TODO: 下载鉴权
|
||||
abm.message.append(
|
||||
File(name=file_name, file=file_url, url=file_url)
|
||||
)
|
||||
|
||||
abm.raw_message = event
|
||||
return abm
|
||||
|
||||
def _parse_blocks(self, blocks: list) -> list:
|
||||
"""解析 Slack blocks 格式的消息内容"""
|
||||
message_components = []
|
||||
|
||||
for block in blocks:
|
||||
block_type = block.get("type", "")
|
||||
|
||||
if block_type == "rich_text":
|
||||
# 处理富文本块
|
||||
elements = block.get("elements", [])
|
||||
for element in elements:
|
||||
if element.get("type") == "rich_text_section":
|
||||
# 处理富文本段落
|
||||
section_elements = element.get("elements", [])
|
||||
text_content = ""
|
||||
|
||||
for section_element in section_elements:
|
||||
element_type = section_element.get("type", "")
|
||||
|
||||
if element_type == "text":
|
||||
# 普通文本
|
||||
text_content += section_element.get("text", "")
|
||||
elif element_type == "user":
|
||||
# @用户提及
|
||||
user_id = section_element.get("user_id", "")
|
||||
if user_id:
|
||||
# 将之前的文本内容先添加到组件中
|
||||
if text_content.strip():
|
||||
message_components.append(
|
||||
Plain(text=text_content)
|
||||
)
|
||||
text_content = ""
|
||||
# 添加@提及组件
|
||||
message_components.append(At(qq=user_id, name=""))
|
||||
elif element_type == "channel":
|
||||
# #频道提及
|
||||
channel_id = section_element.get("channel_id", "")
|
||||
text_content += f"#{channel_id}"
|
||||
elif element_type == "link":
|
||||
# 链接
|
||||
url = section_element.get("url", "")
|
||||
link_text = section_element.get("text", url)
|
||||
text_content += f"[{link_text}]({url})"
|
||||
elif element_type == "emoji":
|
||||
# 表情符号
|
||||
emoji_name = section_element.get("name", "")
|
||||
text_content += f":{emoji_name}:"
|
||||
|
||||
if text_content.strip():
|
||||
message_components.append(Plain(text=text_content))
|
||||
|
||||
elif element.get("type") == "rich_text_list":
|
||||
# 处理列表
|
||||
list_items = element.get("elements", [])
|
||||
list_text = ""
|
||||
for item in list_items:
|
||||
if item.get("type") == "rich_text_section":
|
||||
item_elements = item.get("elements", [])
|
||||
item_text = ""
|
||||
for item_element in item_elements:
|
||||
if item_element.get("type") == "text":
|
||||
item_text += item_element.get("text", "")
|
||||
list_text += f"• {item_text}\n"
|
||||
|
||||
if list_text.strip():
|
||||
message_components.append(Plain(text=list_text.strip()))
|
||||
|
||||
elif block_type == "section":
|
||||
# 处理段落块
|
||||
if "text" in block:
|
||||
text_obj = block["text"]
|
||||
if text_obj.get("type") == "mrkdwn":
|
||||
text_content = text_obj.get("text", "")
|
||||
message_components.append(Plain(text=text_content))
|
||||
|
||||
return message_components
|
||||
|
||||
async def _handle_socket_event(self, req: SocketModeRequest):
|
||||
"""处理 Socket Mode 事件"""
|
||||
if req.type == "events_api":
|
||||
# 事件 API
|
||||
event = req.payload.get("event", {})
|
||||
|
||||
# 忽略机器人自己的消息和消息编辑
|
||||
if event.get("subtype") in [
|
||||
"bot_message",
|
||||
"message_changed",
|
||||
"message_deleted",
|
||||
]:
|
||||
return
|
||||
|
||||
if event.get("bot_id"):
|
||||
return
|
||||
|
||||
if event.get("type") in ["message", "app_mention"]:
|
||||
abm = await self.convert_message(event)
|
||||
if abm:
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def get_bot_user_id(self):
|
||||
auth_info = await self.web_client.auth_test()
|
||||
return auth_info.get("user_id")
|
||||
|
||||
async def get_file_base64(self, url: str) -> str:
|
||||
"""下载 Slack 文件并返回 Base64 编码的内容"""
|
||||
headers = {"Authorization": f"Bearer {self.bot_token}"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
content = await resp.read()
|
||||
base64_content = base64.b64encode(content).decode("utf-8")
|
||||
return base64_content
|
||||
else:
|
||||
logger.error(f"Failed to download slack file: {resp.status} {await resp.text()}")
|
||||
raise Exception(f"下载文件失败: {resp.status}")
|
||||
|
||||
async def run(self) -> Awaitable[Any]:
|
||||
self.bot_self_id = await self.get_bot_user_id()
|
||||
logger.info(f"Slack auth test OK. Bot ID: {self.bot_self_id}")
|
||||
|
||||
if self.connection_mode == "socket":
|
||||
if not self.app_token:
|
||||
raise ValueError("Socket Mode 需要 app_token")
|
||||
|
||||
# 创建 Socket 客户端
|
||||
self.socket_client = SlackSocketClient(
|
||||
self.web_client, self.app_token, self._handle_socket_event
|
||||
)
|
||||
|
||||
logger.info("Slack 适配器 (Socket Mode) 启动中...")
|
||||
await self.socket_client.start()
|
||||
|
||||
elif self.connection_mode == "webhook":
|
||||
if not self.signing_secret:
|
||||
raise ValueError("Webhook Mode 需要 signing_secret")
|
||||
|
||||
# 创建 Webhook 客户端
|
||||
self.webhook_client = SlackWebhookClient(
|
||||
self.web_client,
|
||||
self.signing_secret,
|
||||
self.webhook_host,
|
||||
self.webhook_port,
|
||||
self.webhook_path,
|
||||
self._handle_webhook_event,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}..."
|
||||
)
|
||||
await self.webhook_client.start()
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"不支持的连接模式: {self.connection_mode},请使用 'socket' 或 'webhook'"
|
||||
)
|
||||
|
||||
async def _handle_webhook_event(self, event_data: dict):
|
||||
"""处理 Webhook 事件"""
|
||||
event = event_data.get("event", {})
|
||||
|
||||
# 忽略机器人自己的消息和消息编辑
|
||||
if event.get("subtype") in [
|
||||
"bot_message",
|
||||
"message_changed",
|
||||
"message_deleted",
|
||||
]:
|
||||
return
|
||||
|
||||
if event.get("bot_id"):
|
||||
return
|
||||
|
||||
if event.get("type") in ["message", "app_mention"]:
|
||||
abm = await self.convert_message(event)
|
||||
if abm:
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def terminate(self):
|
||||
if self.socket_client:
|
||||
await self.socket_client.stop()
|
||||
if self.webhook_client:
|
||||
await self.webhook_client.stop()
|
||||
logger.info("Slack 适配器已被优雅地关闭")
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = SlackMessageEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
web_client=self.web_client,
|
||||
)
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
def get_client(self):
|
||||
return self.web_client
|
||||
@@ -0,0 +1,237 @@
|
||||
import asyncio
|
||||
import re
|
||||
from typing import AsyncGenerator
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
Image,
|
||||
Plain,
|
||||
File,
|
||||
BaseMessageComponent,
|
||||
)
|
||||
from astrbot.api.platform import Group, MessageMember
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class SlackMessageEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str,
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id,
|
||||
web_client: AsyncWebClient,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.web_client = web_client
|
||||
|
||||
@staticmethod
|
||||
async def _from_segment_to_slack_block(
|
||||
segment: BaseMessageComponent, web_client: AsyncWebClient
|
||||
) -> dict:
|
||||
"""将消息段转换为 Slack 块格式"""
|
||||
if isinstance(segment, Plain):
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}}
|
||||
elif isinstance(segment, Image):
|
||||
# upload file
|
||||
url = segment.url or segment.file
|
||||
if url.startswith("http"):
|
||||
return {
|
||||
"type": "image",
|
||||
"image_url": url,
|
||||
"alt_text": "图片",
|
||||
}
|
||||
path = await segment.convert_to_file_path()
|
||||
response = await web_client.files_upload_v2(
|
||||
file=path,
|
||||
filename="image.jpg",
|
||||
)
|
||||
if not response["ok"]:
|
||||
logger.error(f"Slack file upload failed: {response['error']}")
|
||||
return {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "图片上传失败"},
|
||||
}
|
||||
image_url = response["files"][0]["url_private"]
|
||||
logger.debug(f"Slack file upload response: {response}")
|
||||
return {
|
||||
"type": "image",
|
||||
"slack_file": {
|
||||
"url": image_url,
|
||||
},
|
||||
"alt_text": "图片",
|
||||
}
|
||||
elif isinstance(segment, File):
|
||||
# upload file
|
||||
url = segment.url or segment.file
|
||||
response = await web_client.files_upload_v2(
|
||||
file=url,
|
||||
filename=segment.name or "file",
|
||||
)
|
||||
if not response["ok"]:
|
||||
logger.error(f"Slack file upload failed: {response['error']}")
|
||||
return {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "文件上传失败"},
|
||||
}
|
||||
file_url = response["files"][0]["permalink"]
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": f"文件: <{file_url}|{segment.name or '文件'}>"}}
|
||||
else:
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
|
||||
|
||||
@staticmethod
|
||||
async def _parse_slack_blocks(
|
||||
message_chain: MessageChain, web_client: AsyncWebClient
|
||||
):
|
||||
"""解析成 Slack 块格式"""
|
||||
blocks = []
|
||||
text_content = ""
|
||||
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
text_content += segment.text
|
||||
else:
|
||||
# 如果有文本内容,先添加文本块
|
||||
if text_content.strip():
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": text_content},
|
||||
}
|
||||
)
|
||||
text_content = ""
|
||||
|
||||
# 添加其他类型的块
|
||||
block = await SlackMessageEvent._from_segment_to_slack_block(
|
||||
segment, web_client
|
||||
)
|
||||
blocks.append(block)
|
||||
|
||||
# 如果最后还有文本内容
|
||||
if text_content.strip():
|
||||
blocks.append(
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": text_content}}
|
||||
)
|
||||
|
||||
return blocks, "" if blocks else text_content
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
blocks, text = await SlackMessageEvent._parse_slack_blocks(
|
||||
message, self.web_client
|
||||
)
|
||||
|
||||
try:
|
||||
if self.get_group_id():
|
||||
# 发送到频道
|
||||
await self.web_client.chat_postMessage(
|
||||
channel=self.get_group_id(),
|
||||
text=text,
|
||||
blocks=blocks or None,
|
||||
)
|
||||
else:
|
||||
# 发送私信
|
||||
await self.web_client.chat_postMessage(
|
||||
channel=self.get_sender_id(),
|
||||
text=text,
|
||||
blocks=blocks or None,
|
||||
)
|
||||
except Exception:
|
||||
# 如果块发送失败,尝试只发送文本
|
||||
fallback_text = ""
|
||||
for segment in message.chain:
|
||||
if isinstance(segment, Plain):
|
||||
fallback_text += segment.text
|
||||
elif isinstance(segment, File):
|
||||
fallback_text += f" [文件: {segment.name}] "
|
||||
elif isinstance(segment, Image):
|
||||
fallback_text += " [图片] "
|
||||
|
||||
if self.get_group_id():
|
||||
await self.web_client.chat_postMessage(
|
||||
channel=self.get_group_id(), text=fallback_text
|
||||
)
|
||||
else:
|
||||
await self.web_client.chat_postMessage(
|
||||
channel=self.get_sender_id(), text=fallback_text
|
||||
)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator, use_fallback: bool = False
|
||||
):
|
||||
if not use_fallback:
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
buffer = ""
|
||||
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
buffer += comp.text
|
||||
if any(p in buffer for p in "。?!~…"):
|
||||
buffer = await self.process_buffer(buffer, pattern)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
|
||||
if buffer.strip():
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
async def get_group(self, group_id=None, **kwargs):
|
||||
if group_id:
|
||||
channel_id = group_id
|
||||
elif self.get_group_id():
|
||||
channel_id = self.get_group_id()
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 获取频道信息
|
||||
channel_info = await self.web_client.conversations_info(channel=channel_id)
|
||||
|
||||
# 获取频道成员
|
||||
members_response = await self.web_client.conversations_members(
|
||||
channel=channel_id
|
||||
)
|
||||
|
||||
members = []
|
||||
for member_id in members_response["members"]:
|
||||
try:
|
||||
user_info = await self.web_client.users_info(user=member_id)
|
||||
user_data = user_info["user"]
|
||||
members.append(
|
||||
MessageMember(
|
||||
user_id=member_id,
|
||||
nickname=user_data.get("real_name")
|
||||
or user_data.get("name", member_id),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# 如果获取用户信息失败,使用默认信息
|
||||
members.append(MessageMember(user_id=member_id, nickname=member_id))
|
||||
|
||||
channel_data = channel_info["channel"]
|
||||
return Group(
|
||||
group_id=channel_id,
|
||||
group_name=channel_data.get("name", ""),
|
||||
group_avatar="",
|
||||
group_admins=[], # Slack 的管理员信息需要特殊权限获取
|
||||
group_owner=channel_data.get("creator", ""),
|
||||
members=members,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,14 +1,16 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import anyio
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from astrbot.api.message_components import Plain, Image, At, Record
|
||||
from astrbot.api.platform import Platform, PlatformMetadata
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.astrbot_message import (
|
||||
@@ -22,6 +24,13 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from ...register import register_platform_adapter
|
||||
from .wechatpadpro_message_event import WeChatPadProMessageEvent
|
||||
|
||||
try:
|
||||
from .xml_data_parser import GeweDataParser
|
||||
except ImportError as e:
|
||||
logger.warning(
|
||||
f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@register_platform_adapter("wechatpadpro", "WeChatPadPro 消息平台适配器")
|
||||
class WeChatPadProAdapter(Platform):
|
||||
@@ -59,6 +68,18 @@ class WeChatPadProAdapter(Platform):
|
||||
) # 持久化文件路径
|
||||
self.ws_handle_task = None
|
||||
|
||||
# 添加图片消息缓存,用于引用消息处理
|
||||
self.cached_images = {}
|
||||
"""缓存图片消息。key是NewMsgId (对应引用消息的svrid),value是图片的base64数据"""
|
||||
# 设置缓存大小限制,避免内存占用过大
|
||||
self.max_image_cache = 50
|
||||
|
||||
# 添加文本消息缓存,用于引用消息处理
|
||||
self.cached_texts = {}
|
||||
"""缓存文本消息。key是NewMsgId (对应引用消息的svrid),value是消息文本内容"""
|
||||
# 设置文本缓存大小限制
|
||||
self.max_text_cache = 100
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
启动平台适配器的运行实例。
|
||||
@@ -102,7 +123,7 @@ class WeChatPadProAdapter(Platform):
|
||||
logger.warning("登录失败或超时,WeChatPadPro 适配器将关闭。")
|
||||
await self.terminate()
|
||||
return
|
||||
|
||||
|
||||
# 登录成功后,连接 WebSocket 接收消息
|
||||
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
|
||||
|
||||
@@ -138,7 +159,6 @@ class WeChatPadProAdapter(Platform):
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
with open(self.credentials_file, "w") as f:
|
||||
json.dump(credentials, f)
|
||||
logger.info("成功保存 WeChatPadPro 凭据。")
|
||||
except Exception as e:
|
||||
logger.error(f"保存 WeChatPadPro 凭据失败: {e}")
|
||||
|
||||
@@ -146,6 +166,8 @@ class WeChatPadProAdapter(Platform):
|
||||
"""
|
||||
检查 WeChatPadPro 设备是否在线。
|
||||
"""
|
||||
if not self.auth_key:
|
||||
return False
|
||||
url = f"{self.base_url}/login/GetLoginStatus"
|
||||
params = {"key": self.auth_key}
|
||||
|
||||
@@ -161,32 +183,31 @@ class WeChatPadProAdapter(Platform):
|
||||
return True
|
||||
# login_state == 3 为离线状态
|
||||
elif login_state == 3:
|
||||
logger.info(
|
||||
"WeChatPadPro 设备不在线。"
|
||||
)
|
||||
logger.info("WeChatPadPro 设备不在线。")
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
f"未知的在线状态: {login_state:}"
|
||||
)
|
||||
logger.error(f"未知的在线状态: {response_data}")
|
||||
return False
|
||||
# Code == 300 为微信退出状态。
|
||||
elif response.status == 200 and response_data.get("Code") == 300:
|
||||
logger.info(
|
||||
"WeChatPadPro 设备已退出。"
|
||||
)
|
||||
logger.info("WeChatPadPro 设备已退出。")
|
||||
return False
|
||||
elif response.status == 200 and response_data.get("Code") == -2:
|
||||
# 该链接不存在
|
||||
self.auth_key = None
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
f"检查在线状态失败: {response.status}, {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"检查在线状态时发生错误: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
async def generate_auth_key(self):
|
||||
@@ -210,7 +231,7 @@ class WeChatPadProAdapter(Platform):
|
||||
and len(response_data["Data"]) > 0
|
||||
):
|
||||
self.auth_key = response_data["Data"][0]
|
||||
logger.info("成功获取授权码")
|
||||
logger.info(f"成功获取授权码 {self.auth_key[:8]}...")
|
||||
else:
|
||||
logger.error(
|
||||
f"生成授权码成功但未找到授权码: {response_data}"
|
||||
@@ -236,7 +257,6 @@ class WeChatPadProAdapter(Platform):
|
||||
try:
|
||||
async with session.post(url, params=params, json=payload) as response:
|
||||
response_data = await response.json()
|
||||
# 修正成功判断条件和数据提取路径
|
||||
if response.status == 200 and response_data.get("Code") == 200:
|
||||
# 二维码地址在 Data.QrCodeUrl 字段中
|
||||
if response_data.get("Data") and response_data["Data"].get(
|
||||
@@ -248,6 +268,13 @@ class WeChatPadProAdapter(Platform):
|
||||
f"获取登录二维码成功但未找到二维码地址: {response_data}"
|
||||
)
|
||||
return None
|
||||
elif "该 key 无效" in response_data.get("Text"):
|
||||
logger.error(
|
||||
"授权码无效,已经清除。请重新启动 AstrBot 或者本消息适配器。原因也可能是 WeChatPadPro 的 MySQL 服务没有启动成功,请检查 WeChatPadPro 服务的日志。"
|
||||
)
|
||||
self.auth_key = None
|
||||
self.save_credentials()
|
||||
return None
|
||||
else:
|
||||
logger.error(
|
||||
f"获取登录二维码失败: {response.status}, {response_data}"
|
||||
@@ -340,7 +367,7 @@ class WeChatPadProAdapter(Platform):
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(ws_url) as websocket:
|
||||
logger.info("WebSocket 连接成功。")
|
||||
logger.debug("WebSocket 连接成功。")
|
||||
# 设置空闲超时重连
|
||||
wait_time = (
|
||||
self.active_message_poll_interval
|
||||
@@ -355,7 +382,7 @@ class WeChatPadProAdapter(Platform):
|
||||
# logger.debug(message) # 不显示原始消息内容
|
||||
asyncio.create_task(self.handle_websocket_message(message))
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"WebSocket 连接空闲超过 {wait_time} s")
|
||||
logger.debug(f"WebSocket 连接空闲超过 {wait_time} s")
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
logger.info("WebSocket 连接正常关闭。")
|
||||
@@ -364,7 +391,9 @@ class WeChatPadProAdapter(Platform):
|
||||
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态,或尝试重启WeChatPadPro适配器。")
|
||||
logger.error(
|
||||
f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态,或尝试重启WeChatPadPro适配器。"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def handle_websocket_message(self, message: str):
|
||||
@@ -439,7 +468,7 @@ class WeChatPadProAdapter(Platform):
|
||||
):
|
||||
# 再根据消息类型处理消息内容
|
||||
await self._process_message_content(abm, raw_message, msg_type, content)
|
||||
|
||||
|
||||
return abm
|
||||
return None
|
||||
|
||||
@@ -457,6 +486,7 @@ class WeChatPadProAdapter(Platform):
|
||||
"""
|
||||
if from_user_name == "weixin":
|
||||
return False
|
||||
at_me = False
|
||||
if "@chatroom" in from_user_name:
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = from_user_name
|
||||
@@ -475,9 +505,17 @@ class WeChatPadProAdapter(Platform):
|
||||
|
||||
# 对于群聊,session_id 可以是群聊 ID 或发送者 ID + 群聊 ID (如果 unique_session 为 True)
|
||||
if self.unique_session:
|
||||
abm.session_id = f"{from_user_name}_{to_user_name}"
|
||||
abm.session_id = f"{from_user_name}#{abm.sender.user_id}"
|
||||
else:
|
||||
abm.session_id = from_user_name
|
||||
|
||||
msg_source = raw_message.get("msg_source", "")
|
||||
if self.wxid in msg_source:
|
||||
at_me = True
|
||||
if "在群聊中@了你" in raw_message.get("push_content", ""):
|
||||
at_me = True
|
||||
if at_me:
|
||||
abm.message.insert(0, At(qq=abm.self_id, name=""))
|
||||
else:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.group_id = ""
|
||||
@@ -558,6 +596,32 @@ class WeChatPadProAdapter(Platform):
|
||||
logger.error(f"下载图片时发生错误: {e}")
|
||||
return None
|
||||
|
||||
async def download_voice(
|
||||
self, to_user_name: str, new_msg_id: str, bufid: str, length: int
|
||||
):
|
||||
"""下载原始音频。"""
|
||||
url = f"{self.base_url}/message/GetMsgVoice"
|
||||
params = {"key": self.auth_key}
|
||||
payload = {
|
||||
"Bufid": bufid,
|
||||
"ToUserName": to_user_name,
|
||||
"NewMsgId": new_msg_id,
|
||||
"Length": length,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.post(url, params=params, json=payload) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
logger.error(f"下载音频失败: {response.status}")
|
||||
return None
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"下载音频时发生错误: {e}")
|
||||
return None
|
||||
|
||||
async def _process_message_content(
|
||||
self, abm: AstrBotMessage, raw_message: dict, msg_type: int, content: str
|
||||
):
|
||||
@@ -569,12 +633,82 @@ class WeChatPadProAdapter(Platform):
|
||||
if abm.type == MessageType.GROUP_MESSAGE:
|
||||
parts = content.split(":\n", 1)
|
||||
if len(parts) == 2:
|
||||
abm.message_str = parts[1]
|
||||
abm.message.append(Plain(abm.message_str))
|
||||
message_content = parts[1]
|
||||
abm.message_str = message_content
|
||||
|
||||
# 检查是否@了机器人,参考 gewechat 的实现方式
|
||||
# 微信大部分客户端在@用户昵称后面,紧接着是一个\u2005字符(四分之一空格)
|
||||
at_me = False
|
||||
|
||||
# 检查 msg_source 中是否包含机器人的 wxid
|
||||
# wechatpadpro 的格式: <atuserlist>wxid</atuserlist>
|
||||
# gewechat 的格式: <atuserlist><![CDATA[wxid]]></atuserlist>
|
||||
msg_source = raw_message.get("msg_source", "")
|
||||
if (
|
||||
f"<atuserlist>{abm.self_id}</atuserlist>" in msg_source
|
||||
or f"<atuserlist>{abm.self_id}," in msg_source
|
||||
or f",{abm.self_id}</atuserlist>" in msg_source
|
||||
):
|
||||
at_me = True
|
||||
|
||||
# 也检查 push_content 中是否有@提示
|
||||
push_content = raw_message.get("push_content", "")
|
||||
if "在群聊中@了你" in push_content:
|
||||
at_me = True
|
||||
|
||||
if at_me:
|
||||
# 被@了,在消息开头插入At组件(参考gewechat的做法)
|
||||
bot_nickname = await self._get_group_member_nickname(
|
||||
abm.group_id, abm.self_id
|
||||
)
|
||||
abm.message.insert(
|
||||
0, At(qq=abm.self_id, name=bot_nickname or abm.self_id)
|
||||
)
|
||||
|
||||
# 只有当消息内容不仅仅是@时才添加Plain组件
|
||||
if "\u2005" in message_content:
|
||||
# 检查@之后是否还有其他内容
|
||||
parts = message_content.split("\u2005")
|
||||
if len(parts) > 1 and any(
|
||||
part.strip() for part in parts[1:]
|
||||
):
|
||||
abm.message.append(Plain(message_content))
|
||||
else:
|
||||
# 检查是否只包含@机器人
|
||||
is_pure_at = False
|
||||
if (
|
||||
bot_nickname
|
||||
and message_content.strip() == f"@{bot_nickname}"
|
||||
):
|
||||
is_pure_at = True
|
||||
if not is_pure_at:
|
||||
abm.message.append(Plain(message_content))
|
||||
else:
|
||||
# 没有@机器人,作为普通文本处理
|
||||
abm.message.append(Plain(message_content))
|
||||
else:
|
||||
abm.message.append(Plain(abm.message_str))
|
||||
else: # 私聊消息
|
||||
abm.message.append(Plain(abm.message_str))
|
||||
|
||||
# 缓存文本消息,以便引用消息可以查找
|
||||
try:
|
||||
# 获取msg_id作为缓存的key
|
||||
new_msg_id = raw_message.get("new_msg_id")
|
||||
if new_msg_id:
|
||||
# 限制缓存大小
|
||||
if (
|
||||
len(self.cached_texts) >= self.max_text_cache
|
||||
and self.cached_texts
|
||||
):
|
||||
# 删除最早的一条缓存
|
||||
oldest_key = next(iter(self.cached_texts))
|
||||
self.cached_texts.pop(oldest_key)
|
||||
|
||||
logger.debug(f"缓存文本消息,new_msg_id={new_msg_id}")
|
||||
self.cached_texts[str(new_msg_id)] = content
|
||||
except Exception as e:
|
||||
logger.error(f"缓存文本消息失败: {e}")
|
||||
elif msg_type == 3:
|
||||
# 图片消息
|
||||
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
|
||||
@@ -588,15 +722,87 @@ class WeChatPadProAdapter(Platform):
|
||||
)
|
||||
if image_bs64_data:
|
||||
abm.message.append(Image.fromBase64(image_bs64_data))
|
||||
# 缓存图片,以便引用消息可以查找
|
||||
try:
|
||||
# 获取msg_id作为缓存的key
|
||||
new_msg_id = raw_message.get("new_msg_id")
|
||||
if new_msg_id:
|
||||
# 限制缓存大小
|
||||
if (
|
||||
len(self.cached_images) >= self.max_image_cache
|
||||
and self.cached_images
|
||||
):
|
||||
# 删除最早的一条缓存
|
||||
oldest_key = next(iter(self.cached_images))
|
||||
self.cached_images.pop(oldest_key)
|
||||
|
||||
logger.debug(f"缓存图片消息,new_msg_id={new_msg_id}")
|
||||
self.cached_images[str(new_msg_id)] = image_bs64_data
|
||||
except Exception as e:
|
||||
logger.error(f"缓存图片消息失败: {e}")
|
||||
elif msg_type == 47:
|
||||
# 视频消息 (注意:表情消息也是 47,需要区分)
|
||||
logger.warning("收到视频消息,待实现。")
|
||||
data_parser = GeweDataParser(
|
||||
content=content,
|
||||
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
|
||||
raw_message=raw_message,
|
||||
)
|
||||
emoji_message = data_parser.parse_emoji()
|
||||
if emoji_message is not None:
|
||||
abm.message.append(emoji_message)
|
||||
elif msg_type == 50:
|
||||
# 语音/视频
|
||||
logger.warning("收到语音/视频消息,待实现。")
|
||||
elif msg_type == 34:
|
||||
# 语音消息
|
||||
bufid = 0
|
||||
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
|
||||
new_msg_id = raw_message.get("new_msg_id")
|
||||
data_parser = GeweDataParser(
|
||||
content=content,
|
||||
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
|
||||
raw_message=raw_message,
|
||||
)
|
||||
|
||||
voicemsg = data_parser._format_to_xml().find("voicemsg")
|
||||
bufid = voicemsg.get("bufid") or "0"
|
||||
length = int(voicemsg.get("length") or 0)
|
||||
voice_resp = await self.download_voice(
|
||||
to_user_name=to_user_name,
|
||||
new_msg_id=new_msg_id,
|
||||
bufid=bufid,
|
||||
length=length,
|
||||
)
|
||||
voice_bs64_data = voice_resp.get("Data", {}).get("Base64", None)
|
||||
if voice_bs64_data:
|
||||
voice_bs64_data = base64.b64decode(voice_bs64_data)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
file_path = os.path.join(
|
||||
temp_dir, f"wechatpadpro_voice_{abm.message_id}.silk"
|
||||
)
|
||||
|
||||
async with await anyio.open_file(file_path, "wb") as f:
|
||||
await f.write(voice_bs64_data)
|
||||
abm.message.append(Record(file=file_path, url=file_path))
|
||||
elif msg_type == 49:
|
||||
# 引用消息
|
||||
logger.warning("收到引用消息,待实现。")
|
||||
try:
|
||||
parser = GeweDataParser(
|
||||
content=content,
|
||||
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
|
||||
cached_texts=self.cached_texts,
|
||||
cached_images=self.cached_images,
|
||||
raw_message=raw_message,
|
||||
downloader=self._download_raw_image,
|
||||
)
|
||||
components = await parser.parse_mutil_49()
|
||||
if components:
|
||||
abm.message.extend(components)
|
||||
abm.message_str = "\n".join(
|
||||
c.text for c in components if isinstance(c, Plain)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"msg_type 49 处理失败: {e}")
|
||||
abm.message.append(Plain("[XML 消息处理失败]"))
|
||||
abm.message_str = "[XML 消息处理失败]"
|
||||
else:
|
||||
logger.warning(f"收到未处理的消息类型: {msg_type}。")
|
||||
|
||||
@@ -626,7 +832,10 @@ class WeChatPadProAdapter(Platform):
|
||||
# 根据 session_id 判断消息类型
|
||||
if "@chatroom" in session.session_id:
|
||||
dummy_message_obj.type = MessageType.GROUP_MESSAGE
|
||||
dummy_message_obj.group_id = session.session_id
|
||||
if "#" in session.session_id:
|
||||
dummy_message_obj.group_id = session.session_id.split("#")[0]
|
||||
else:
|
||||
dummy_message_obj.group_id = session.session_id
|
||||
dummy_message_obj.sender = MessageMember(user_id="", nickname="")
|
||||
else:
|
||||
dummy_message_obj.type = MessageType.FRIEND_MESSAGE
|
||||
|
||||
@@ -7,11 +7,17 @@ import aiohttp
|
||||
from PIL import Image as PILImage # 使用别名避免冲突
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.components import Image, Plain # Import Image
|
||||
from astrbot.core.message.components import (
|
||||
Image,
|
||||
Plain,
|
||||
WechatEmoji,
|
||||
Record,
|
||||
) # Import Image
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageType
|
||||
from astrbot.core.platform.platform_metadata import PlatformMetadata
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk_base64
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .wechatpadpro_adapter import WeChatPadProAdapter
|
||||
@@ -38,6 +44,10 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
|
||||
await self._send_text(session, comp.text)
|
||||
elif isinstance(comp, Image):
|
||||
await self._send_image(session, comp)
|
||||
elif isinstance(comp, WechatEmoji):
|
||||
await self._send_emoji(session, comp)
|
||||
elif isinstance(comp, Record):
|
||||
await self._send_voice(session, comp)
|
||||
await super().send(message)
|
||||
|
||||
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
|
||||
@@ -71,14 +81,48 @@ class WeChatPadProMessageEvent(AstrMessageEvent):
|
||||
# logger.info(f"已添加 @ 信息: {message_text}")
|
||||
else:
|
||||
message_text = text
|
||||
if self.get_group_id() and "#" in self.session_id:
|
||||
session_id = self.session_id.split("#")[0]
|
||||
else:
|
||||
session_id = self.session_id
|
||||
payload = {
|
||||
"MsgItem": [
|
||||
{"MsgType": 1, "TextContent": message_text, "ToUserName": self.session_id}
|
||||
{
|
||||
"MsgType": 1,
|
||||
"TextContent": message_text,
|
||||
"ToUserName": session_id,
|
||||
}
|
||||
]
|
||||
}
|
||||
url = f"{self.adapter.base_url}/message/SendTextMessage"
|
||||
await self._post(session, url, payload)
|
||||
|
||||
async def _send_emoji(self, session: aiohttp.ClientSession, comp: WechatEmoji):
|
||||
payload = {
|
||||
"EmojiList": [
|
||||
{
|
||||
"EmojiMd5": comp.md5,
|
||||
"EmojiSize": comp.md5_len,
|
||||
"ToUserName": self.session_id,
|
||||
}
|
||||
]
|
||||
}
|
||||
url = f"{self.adapter.base_url}/message/SendEmojiMessage"
|
||||
await self._post(session, url, payload)
|
||||
|
||||
async def _send_voice(self, session: aiohttp.ClientSession, comp: Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 默认已经存在 data/temp 中
|
||||
b64, duration = await wav_to_tencent_silk_base64(record_path)
|
||||
payload = {
|
||||
"ToUserName": self.session_id,
|
||||
"VoiceData": b64,
|
||||
"VoiceFormat": 4,
|
||||
"VoiceSecond": duration,
|
||||
}
|
||||
url = f"{self.adapter.base_url}/message/SendVoice"
|
||||
await self._post(session, url, payload)
|
||||
|
||||
@staticmethod
|
||||
def _validate_base64(b64: str) -> bytes:
|
||||
return base64.b64decode(b64, validate=True)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from defusedxml import ElementTree as eT
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.message_components import (
|
||||
WechatEmoji as Emoji,
|
||||
Plain,
|
||||
Image,
|
||||
BaseMessageComponent,
|
||||
)
|
||||
|
||||
|
||||
class GeweDataParser:
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
is_private_chat: bool = False,
|
||||
cached_texts=None,
|
||||
cached_images=None,
|
||||
raw_message: dict = None,
|
||||
downloader=None,
|
||||
):
|
||||
self._xml = None
|
||||
self.content = content
|
||||
self.is_private_chat = is_private_chat
|
||||
self.cached_texts = cached_texts or {}
|
||||
self.cached_images = cached_images or {}
|
||||
self.downloader = downloader
|
||||
|
||||
raw_message = raw_message or {}
|
||||
self.from_user_name = raw_message.get("from_user_name", {}).get("str", "")
|
||||
self.to_user_name = raw_message.get("to_user_name", {}).get("str", "")
|
||||
self.msg_id = raw_message.get("msg_id", "")
|
||||
|
||||
def _format_to_xml(self):
|
||||
if self._xml:
|
||||
return self._xml
|
||||
|
||||
try:
|
||||
msg_str = self.content
|
||||
if not self.is_private_chat:
|
||||
parts = self.content.split(":\n", 1)
|
||||
msg_str = parts[1] if len(parts) == 2 else self.content
|
||||
|
||||
self._xml = eT.fromstring(msg_str)
|
||||
return self._xml
|
||||
except Exception as e:
|
||||
logger.error(f"[XML解析失败] {e}")
|
||||
raise
|
||||
|
||||
async def parse_mutil_49(self) -> list[BaseMessageComponent] | None:
|
||||
"""
|
||||
处理 msg_type == 49 的多种 appmsg 类型(目前支持 type==57)
|
||||
"""
|
||||
try:
|
||||
appmsg_type = self._format_to_xml().findtext(".//appmsg/type")
|
||||
if appmsg_type == "57":
|
||||
return await self.parse_reply()
|
||||
except Exception as e:
|
||||
logger.warning(f"[parse_mutil_49] 解析失败: {e}")
|
||||
return None
|
||||
|
||||
async def parse_reply(self) -> list[BaseMessageComponent]:
|
||||
"""
|
||||
处理 type == 57 的引用消息:支持文本(1)、图片(3)、嵌套49(49)
|
||||
"""
|
||||
components = []
|
||||
|
||||
try:
|
||||
appmsg = self._format_to_xml().find("appmsg")
|
||||
if appmsg is None:
|
||||
return [Plain("[引用消息解析失败]")]
|
||||
|
||||
refermsg = appmsg.find("refermsg")
|
||||
if refermsg is None:
|
||||
return [Plain("[引用消息解析失败]")]
|
||||
|
||||
quote_type = int(refermsg.findtext("type", "0"))
|
||||
nickname = refermsg.findtext("displayname", "未知发送者")
|
||||
quote_content = refermsg.findtext("content", "")
|
||||
svrid = refermsg.findtext("svrid")
|
||||
|
||||
match quote_type:
|
||||
case 1: # 文本引用
|
||||
quoted_text = self.cached_texts.get(str(svrid), quote_content)
|
||||
components.append(Plain(f"[引用] {nickname}: {quoted_text}"))
|
||||
|
||||
case 3: # 图片引用
|
||||
quoted_image_b64 = self.cached_images.get(str(svrid))
|
||||
if not quoted_image_b64:
|
||||
try:
|
||||
quote_xml = eT.fromstring(quote_content)
|
||||
img = quote_xml.find("img")
|
||||
cdn_url = (
|
||||
img.get("cdnbigimgurl") or img.get("cdnmidimgurl")
|
||||
if img is not None
|
||||
else None
|
||||
)
|
||||
if cdn_url and self.downloader:
|
||||
image_resp = await self.downloader(
|
||||
self.from_user_name, self.to_user_name, self.msg_id
|
||||
)
|
||||
quoted_image_b64 = (
|
||||
image_resp.get("Data", {})
|
||||
.get("Data", {})
|
||||
.get("Buffer")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[引用图片解析失败] svrid={svrid} err={e}")
|
||||
|
||||
if quoted_image_b64:
|
||||
components.extend(
|
||||
[
|
||||
Image.fromBase64(quoted_image_b64),
|
||||
Plain(f"[引用] {nickname}: [引用的图片]"),
|
||||
]
|
||||
)
|
||||
else:
|
||||
components.append(
|
||||
Plain(f"[引用] {nickname}: [引用的图片 - 未能获取]")
|
||||
)
|
||||
|
||||
case 49: # 嵌套引用
|
||||
try:
|
||||
nested_root = eT.fromstring(quote_content)
|
||||
nested_title = nested_root.findtext(".//appmsg/title", "")
|
||||
components.append(Plain(f"[引用] {nickname}: {nested_title}"))
|
||||
except Exception as e:
|
||||
logger.warning(f"[嵌套引用解析失败] err={e}")
|
||||
components.append(Plain(f"[引用] {nickname}: [嵌套引用消息]"))
|
||||
|
||||
case _: # 其他未识别类型
|
||||
logger.info(f"[未知引用类型] quote_type={quote_type}")
|
||||
components.append(Plain(f"[引用] {nickname}: [不支持的引用类型]"))
|
||||
|
||||
# 主消息标题
|
||||
title = appmsg.findtext("title", "")
|
||||
if title:
|
||||
components.append(Plain(title))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[parse_reply] 总体解析失败: {e}")
|
||||
return [Plain("[引用消息解析失败]")]
|
||||
|
||||
return components
|
||||
|
||||
def parse_emoji(self) -> Emoji | None:
|
||||
"""
|
||||
处理 msg_type == 47 的表情消息(emoji)
|
||||
"""
|
||||
try:
|
||||
emoji_element = self._format_to_xml().find(".//emoji")
|
||||
if emoji_element is not None:
|
||||
return Emoji(
|
||||
md5=emoji_element.get("md5"),
|
||||
md5_len=emoji_element.get("len"),
|
||||
cdnurl=emoji_element.get("cdnurl"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[parse_emoji] 解析失败: {e}")
|
||||
|
||||
return None
|
||||
@@ -303,6 +303,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.session_id = external_userid
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.message_id = msg.get("msgid", uuid.uuid4().hex[:8])
|
||||
abm.message_str = ""
|
||||
if msgtype == "text":
|
||||
text = msg.get("text", {}).get("content", "").strip()
|
||||
abm.message = [Plain(text=text)]
|
||||
@@ -316,7 +317,29 @@ class WecomPlatformAdapter(Platform):
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
abm.message = [Image(file=path, url=path)]
|
||||
abm.message_str = "[图片]"
|
||||
elif msgtype == "voice":
|
||||
media_id = msg.get("voice", {}).get("media_id", "")
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.client.media.download, media_id
|
||||
)
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
try:
|
||||
from pydub import AudioSegment
|
||||
|
||||
path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav")
|
||||
audio = AudioSegment.from_file(path)
|
||||
audio.export(path_wav, format="wav")
|
||||
except Exception as e:
|
||||
logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
|
||||
path_wav = path
|
||||
return
|
||||
|
||||
abm.message = [Record(file=path_wav, url=path_wav)]
|
||||
else:
|
||||
logger.warning(f"未实现的微信客服消息事件: {msg}")
|
||||
return
|
||||
|
||||
@@ -120,6 +120,30 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
self.get_self_id(),
|
||||
response["media_id"],
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr")
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr, format="amr"
|
||||
)
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信客服上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信客服上传语音失败: {e}")
|
||||
)
|
||||
return
|
||||
logger.info(f"微信客服上传语音返回: {response}")
|
||||
kf_message_api.send_voice(
|
||||
user_id,
|
||||
self.get_self_id(),
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
|
||||
else:
|
||||
|
||||
@@ -19,6 +19,7 @@ class ProviderType(enum.Enum):
|
||||
CHAT_COMPLETION = "chat_completion"
|
||||
SPEECH_TO_TEXT = "speech_to_text"
|
||||
TEXT_TO_SPEECH = "text_to_speech"
|
||||
EMBEDDING = "embedding"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -155,7 +156,9 @@ class ProviderRequest:
|
||||
if self.image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": self.prompt if self.prompt else "[图片]"}],
|
||||
"content": [
|
||||
{"type": "text", "text": self.prompt if self.prompt else "[图片]"}
|
||||
],
|
||||
}
|
||||
for image_url in self.image_urls:
|
||||
if image_url.startswith("http"):
|
||||
|
||||
@@ -18,13 +18,6 @@ class ProviderManager:
|
||||
self.persona_configs: list = config.get("persona", [])
|
||||
self.astrbot_config = config
|
||||
|
||||
self.selected_provider_id = sp.get("curr_provider")
|
||||
self.selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
|
||||
self.selected_tts_provider_id = self.provider_settings.get("provider_id")
|
||||
# self.provider_enabled = self.provider_settings.get("enable", False)
|
||||
# self.stt_enabled = self.provider_stt_settings.get("enable", False)
|
||||
# self.tts_enabled = self.provider_tts_settings.get("enable", False)
|
||||
|
||||
# 人格情景管理
|
||||
# 目前没有拆成独立的模块
|
||||
self.default_persona_name = self.provider_settings.get(
|
||||
@@ -98,17 +91,18 @@ class ProviderManager:
|
||||
"""加载的 Speech To Text Provider 的实例"""
|
||||
self.tts_provider_insts: List[TTSProvider] = []
|
||||
"""加载的 Text To Speech Provider 的实例"""
|
||||
self.embedding_provider_insts: List[Provider] = []
|
||||
"""加载的 Embedding Provider 的实例"""
|
||||
self.inst_map = {}
|
||||
"""Provider 实例映射. key: provider_id, value: Provider 实例"""
|
||||
self.llm_tools = llm_tools
|
||||
self.default_provider_inst: Provider = None
|
||||
"""默认的 Provider 实例。第 0 个或者用户以前指定的 Provider 实例"""
|
||||
|
||||
self.curr_provider_inst: Provider = None
|
||||
"""当前使用的 Provider 实例"""
|
||||
"""默认的 Provider 实例"""
|
||||
self.curr_stt_provider_inst: STTProvider = None
|
||||
"""当前使用的 Speech To Text Provider 实例"""
|
||||
"""默认的 Speech To Text Provider 实例"""
|
||||
self.curr_tts_provider_inst: TTSProvider = None
|
||||
"""当前使用的 Text To Speech Provider 实例"""
|
||||
"""默认的 Text To Speech Provider 实例"""
|
||||
self.db_helper = db_helper
|
||||
|
||||
# kdb(experimental)
|
||||
@@ -117,13 +111,57 @@ class ProviderManager:
|
||||
if kdb_cfg and len(kdb_cfg):
|
||||
self.curr_kdb_name = list(kdb_cfg.keys())[0]
|
||||
|
||||
async def set_provider(
|
||||
self, provider_id: str, provider_type: ProviderType, umo: str = None
|
||||
):
|
||||
"""设置提供商。
|
||||
|
||||
Args:
|
||||
provider_id (str): 提供商 ID。
|
||||
provider_type (ProviderType): 提供商类型。
|
||||
umo (str, optional): 用户会话 ID,用于提供商会话隔离。当用户启用了提供商会话隔离时此参数才生效。
|
||||
"""
|
||||
if provider_id not in self.inst_map:
|
||||
raise ValueError(f"提供商 {provider_id} 不存在,无法设置。")
|
||||
if umo and self.provider_settings["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
session_perf = perf.get(umo, {})
|
||||
session_perf[provider_type.value] = provider_id
|
||||
perf[umo] = session_perf
|
||||
sp.put("session_provider_perf", perf)
|
||||
return
|
||||
# 不启用提供商会话隔离模式的情况
|
||||
self.curr_provider_inst = self.inst_map[provider_id]
|
||||
if provider_type == ProviderType.TEXT_TO_SPEECH:
|
||||
sp.put("curr_provider_tts", provider_id)
|
||||
elif provider_type == ProviderType.SPEECH_TO_TEXT:
|
||||
sp.put("curr_provider_stt", provider_id)
|
||||
elif provider_type == ProviderType.CHAT_COMPLETION:
|
||||
sp.put("curr_provider", provider_id)
|
||||
|
||||
async def initialize(self):
|
||||
# 逐个初始化提供商
|
||||
for provider_config in self.providers_config:
|
||||
await self.load_provider(provider_config)
|
||||
|
||||
self.default_provider_inst = self.inst_map.get(self.selected_provider_id)
|
||||
if not self.default_provider_inst and self.provider_insts:
|
||||
self.default_provider_inst = self.provider_insts[0]
|
||||
# 设置默认提供商
|
||||
self.curr_provider_inst = self.inst_map.get(
|
||||
self.provider_settings.get("default_provider_id")
|
||||
)
|
||||
if not self.curr_provider_inst and self.provider_insts:
|
||||
self.curr_provider_inst = self.provider_insts[0]
|
||||
|
||||
self.curr_stt_provider_inst = self.inst_map.get(
|
||||
self.provider_stt_settings.get("provider_id")
|
||||
)
|
||||
if not self.curr_stt_provider_inst and self.stt_provider_insts:
|
||||
self.curr_stt_provider_inst = self.stt_provider_insts[0]
|
||||
|
||||
self.curr_tts_provider_inst = self.inst_map.get(
|
||||
self.provider_tts_settings.get("provider_id")
|
||||
)
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(
|
||||
@@ -187,6 +225,10 @@ class ProviderManager:
|
||||
from .sources.edge_tts_source import (
|
||||
ProviderEdgeTTS as ProviderEdgeTTS,
|
||||
)
|
||||
case "gsv_tts_selfhost":
|
||||
from .sources.gsv_selfhosted_source import (
|
||||
ProviderGSVTTS as ProviderGSVTTS,
|
||||
)
|
||||
case "gsvi_tts_api":
|
||||
from .sources.gsvi_tts_source import (
|
||||
ProviderGSVITTS as ProviderGSVITTS,
|
||||
@@ -211,6 +253,14 @@ class ProviderManager:
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
case "openai_embedding":
|
||||
from .sources.openai_embedding_source import (
|
||||
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
|
||||
)
|
||||
case "gemini_embedding":
|
||||
from .sources.gemini_embedding_source import (
|
||||
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(
|
||||
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
|
||||
@@ -242,7 +292,10 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.stt_provider_insts.append(inst)
|
||||
if self.selected_stt_provider_id == provider_config["id"]:
|
||||
if (
|
||||
self.provider_stt_settings.get("provider_id")
|
||||
== provider_config["id"]
|
||||
):
|
||||
self.curr_stt_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。"
|
||||
@@ -260,7 +313,7 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.tts_provider_insts.append(inst)
|
||||
if self.selected_tts_provider_id == provider_config["id"]:
|
||||
if self.provider_settings.get("provider_id") == provider_config["id"]:
|
||||
self.curr_tts_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。"
|
||||
@@ -282,7 +335,10 @@ class ProviderManager:
|
||||
await inst.initialize()
|
||||
|
||||
self.provider_insts.append(inst)
|
||||
if self.selected_provider_id == provider_config["id"]:
|
||||
if (
|
||||
self.provider_settings.get("default_provider_id")
|
||||
== provider_config["id"]
|
||||
):
|
||||
self.curr_provider_inst = inst
|
||||
logger.info(
|
||||
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。"
|
||||
@@ -290,6 +346,14 @@ class ProviderManager:
|
||||
if not self.curr_provider_inst:
|
||||
self.curr_provider_inst = inst
|
||||
|
||||
elif provider_metadata.provider_type == ProviderType.EMBEDDING:
|
||||
inst = provider_metadata.cls_type(
|
||||
provider_config, self.provider_settings
|
||||
)
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
self.embedding_provider_insts.append(inst)
|
||||
|
||||
self.inst_map[provider_config["id"]] = inst
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -312,7 +376,6 @@ class ProviderManager:
|
||||
self.curr_provider_inst = None
|
||||
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
|
||||
self.curr_provider_inst = self.provider_insts[0]
|
||||
self.selected_provider_id = self.curr_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。"
|
||||
)
|
||||
@@ -321,7 +384,6 @@ class ProviderManager:
|
||||
self.curr_stt_provider_inst = None
|
||||
elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0:
|
||||
self.curr_stt_provider_inst = self.stt_provider_insts[0]
|
||||
self.selected_stt_provider_id = self.curr_stt_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。"
|
||||
)
|
||||
@@ -330,7 +392,6 @@ class ProviderManager:
|
||||
self.curr_tts_provider_inst = None
|
||||
elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
self.selected_tts_provider_id = self.curr_tts_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。"
|
||||
)
|
||||
|
||||
@@ -192,6 +192,11 @@ class EmbeddingProvider(AbstractProvider):
|
||||
"""获取文本的向量"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的向量"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
|
||||
@@ -104,11 +104,13 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
contexts=None,
|
||||
system_prompt=None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
if not prompt:
|
||||
prompt = "<image>"
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
system_prompt: str = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
# 获得会话变量
|
||||
payload_vars = self.variables.copy()
|
||||
# 动态变量
|
||||
|
||||
@@ -61,13 +61,16 @@ class ProviderDify(Provider):
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
image_urls: List[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts: List = None,
|
||||
system_prompt: str = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if image_urls is None:
|
||||
image_urls = []
|
||||
result = ""
|
||||
session_id = session_id or kwargs.get("user") # 1734
|
||||
conversation_id = self.conversation_ids.get(session_id, "")
|
||||
|
||||
files_payload = []
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from google.genai.errors import APIError
|
||||
from ..provider import EmbeddingProvider
|
||||
from ..register import register_provider_adapter
|
||||
from ..entities import ProviderType
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"gemini_embedding",
|
||||
"Google Gemini Embedding 提供商适配器",
|
||||
provider_type=ProviderType.EMBEDDING,
|
||||
)
|
||||
class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.provider_config = provider_config
|
||||
self.provider_settings = provider_settings
|
||||
|
||||
api_key: str = provider_config.get("embedding_api_key")
|
||||
api_base: str = provider_config.get("embedding_api_base", None)
|
||||
timeout: int = int(provider_config.get("timeout", 20))
|
||||
|
||||
http_options = types.HttpOptions(timeout=timeout * 1000)
|
||||
if api_base:
|
||||
if api_base.endswith("/"):
|
||||
api_base = api_base[:-1]
|
||||
http_options.base_url = api_base
|
||||
|
||||
self.client = genai.Client(api_key=api_key, http_options=http_options).aio
|
||||
|
||||
self.model = provider_config.get(
|
||||
"embedding_model", "gemini-embedding-exp-03-07"
|
||||
)
|
||||
self.dimension = provider_config.get("embedding_dimensions", 768)
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""
|
||||
获取文本的嵌入
|
||||
"""
|
||||
try:
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model, contents=text
|
||||
)
|
||||
return result.embeddings[0].values
|
||||
except APIError as e:
|
||||
raise Exception(f"Gemini Embedding API请求失败: {e.message}")
|
||||
|
||||
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
批量获取文本的嵌入
|
||||
"""
|
||||
try:
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model, contents=texts
|
||||
)
|
||||
return [embedding.values for embedding in result.embeddings]
|
||||
except APIError as e:
|
||||
raise Exception(f"Gemini Embedding API批量请求失败: {e.message}")
|
||||
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
return self.dimension
|
||||
@@ -141,24 +141,66 @@ class ProviderGoogleGenAI(Provider):
|
||||
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
|
||||
modalities = ["Text"]
|
||||
|
||||
tool_list = None
|
||||
tool_list = []
|
||||
model_name = self.get_model()
|
||||
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
|
||||
native_search = self.provider_config.get("gm_native_search", False)
|
||||
url_context = self.provider_config.get("gm_url_context", False)
|
||||
|
||||
if native_coderunner:
|
||||
tool_list = [types.Tool(code_execution=types.ToolCodeExecution())]
|
||||
if native_search:
|
||||
logger.warning("已启用代码执行工具,搜索工具将被忽略")
|
||||
if tools:
|
||||
logger.warning("已启用代码执行工具,函数工具将被忽略")
|
||||
elif native_search:
|
||||
tool_list = [types.Tool(google_search=types.GoogleSearch())]
|
||||
if tools:
|
||||
logger.warning("已启用搜索工具,函数工具将被忽略")
|
||||
if "gemini-2.5" in model_name:
|
||||
if native_coderunner:
|
||||
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
|
||||
if native_search:
|
||||
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
|
||||
if url_context:
|
||||
logger.warning(
|
||||
"代码执行工具与URL上下文工具互斥,已忽略URL上下文工具"
|
||||
)
|
||||
else:
|
||||
if native_search:
|
||||
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
|
||||
|
||||
if url_context:
|
||||
if hasattr(types, "UrlContext"):
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
else:
|
||||
logger.warning(
|
||||
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
|
||||
)
|
||||
|
||||
elif "gemini-2.0-lite" in model_name:
|
||||
if native_coderunner or native_search or url_context:
|
||||
logger.warning(
|
||||
"gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文,将忽略这些设置"
|
||||
)
|
||||
tool_list = None
|
||||
|
||||
else:
|
||||
if native_coderunner:
|
||||
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
|
||||
if native_search:
|
||||
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
|
||||
elif native_search:
|
||||
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
|
||||
|
||||
if url_context and not native_coderunner:
|
||||
if hasattr(types, "UrlContext"):
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
else:
|
||||
logger.warning(
|
||||
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
|
||||
)
|
||||
|
||||
if not tool_list:
|
||||
tool_list = None
|
||||
|
||||
if tools and tool_list:
|
||||
logger.warning("已启用原生工具,函数工具将被忽略")
|
||||
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
|
||||
tool_list = [
|
||||
types.Tool(function_declarations=func_desc["function_declarations"])
|
||||
]
|
||||
|
||||
return types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
temperature=temperature,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from ..provider import TTSProvider
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
provider_type_name="gsv_tts_selfhost",
|
||||
desc="GPT-SoVITS TTS(本地加载)",
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
)
|
||||
class ProviderGSVTTS(TTSProvider):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
|
||||
self.api_base = provider_config.get("api_base", "http://127.0.0.1:9880").rstrip(
|
||||
"/"
|
||||
)
|
||||
self.gpt_weights_path: str = provider_config.get("gpt_weights_path", "")
|
||||
self.sovits_weights_path: str = provider_config.get("sovits_weights_path", "")
|
||||
|
||||
# TTS 请求的默认参数,移除前缀gsv_
|
||||
self.default_params: dict = {
|
||||
key.removeprefix("gsv_"): str(value).lower()
|
||||
for key, value in provider_config.get("gsv_default_parms", {}).items()
|
||||
}
|
||||
self.timeout = provider_config.get("timeout", 60)
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def initialize(self):
|
||||
"""异步初始化:在 ProviderManager 中被调用"""
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout)
|
||||
)
|
||||
try:
|
||||
await self._set_model_weights()
|
||||
logger.info("[GSV TTS] 初始化完成")
|
||||
except Exception as e:
|
||||
logger.error(f"[GSV TTS] 初始化失败:{e}")
|
||||
raise
|
||||
|
||||
def get_session(self) -> aiohttp.ClientSession:
|
||||
if not self._session or self._session.closed:
|
||||
raise RuntimeError(
|
||||
"[GSV TTS] Provider HTTP session is not ready or closed."
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def _make_request(
|
||||
self, endpoint: str, params=None, retries: int = 3
|
||||
) -> bytes | None:
|
||||
"""发起请求"""
|
||||
for attempt in range(retries):
|
||||
logger.debug(f"[GSV TTS] 请求地址:{endpoint},参数:{params}")
|
||||
try:
|
||||
async with self.get_session().get(endpoint, params=params) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(
|
||||
f"[GSV TTS] Request to {endpoint} failed with status {response.status}: {error_text}"
|
||||
)
|
||||
return await response.read()
|
||||
except Exception as e:
|
||||
if attempt < retries - 1:
|
||||
logger.warning(
|
||||
f"[GSV TTS] 请求 {endpoint} 第 {attempt + 1} 次失败:{e},重试中..."
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
logger.error(f"[GSV TTS] 请求 {endpoint} 最终失败:{e}")
|
||||
raise
|
||||
|
||||
async def _set_model_weights(self):
|
||||
"""设置模型路径"""
|
||||
try:
|
||||
if self.gpt_weights_path:
|
||||
await self._make_request(
|
||||
f"{self.api_base}/set_gpt_weights",
|
||||
{"weights_path": self.gpt_weights_path},
|
||||
)
|
||||
logger.info(f"[GSV TTS] 成功设置 GPT 模型路径:{self.gpt_weights_path}")
|
||||
else:
|
||||
logger.info("[GSV TTS] GPT 模型路径未配置,将使用内置 GPT 模型")
|
||||
|
||||
if self.sovits_weights_path:
|
||||
await self._make_request(
|
||||
f"{self.api_base}/set_sovits_weights",
|
||||
{"weights_path": self.sovits_weights_path},
|
||||
)
|
||||
logger.info(
|
||||
f"[GSV TTS] 成功设置 SoVITS 模型路径:{self.sovits_weights_path}"
|
||||
)
|
||||
else:
|
||||
logger.info("[GSV TTS] SoVITS 模型路径未配置,将使用内置 SoVITS 模型")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[GSV TTS] 设置模型路径时发生网络错误:{e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[GSV TTS] 设置模型路径时发生未知错误:{e}")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
"""实现 TTS 核心方法,根据文本内容自动切换情绪"""
|
||||
if not text.strip():
|
||||
raise ValueError("[GSV TTS] TTS 文本不能为空")
|
||||
|
||||
endpoint = f"{self.api_base}/tts"
|
||||
|
||||
params = self.build_synthesis_params(text)
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav")
|
||||
|
||||
logger.debug(f"[GSV TTS] 正在调用语音合成接口,参数:{params}")
|
||||
|
||||
result = await self._make_request(endpoint, params)
|
||||
if isinstance(result, bytes):
|
||||
with open(path, "wb") as f:
|
||||
f.write(result)
|
||||
return path
|
||||
else:
|
||||
raise Exception(f"[GSV TTS] 合成失败,输入文本:{text},错误信息:{result}")
|
||||
|
||||
def build_synthesis_params(self, text: str) -> dict:
|
||||
"""
|
||||
构建语音合成所需的参数字典。
|
||||
|
||||
当前仅包含默认参数 + 文本,未来可在此基础上动态添加如情绪、角色等语义控制字段。
|
||||
"""
|
||||
params = self.default_params.copy()
|
||||
params["text"] = text
|
||||
# TODO: 在此处添加情绪分析,例如 params["emotion"] = detect_emotion(text)
|
||||
return params
|
||||
|
||||
async def terminate(self):
|
||||
"""终止释放资源:在 ProviderManager 中被调用"""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
logger.info("[GSV TTS] Session 已关闭")
|
||||
@@ -60,10 +60,12 @@ class LLMTunerModelLoader(Provider):
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts: List = [],
|
||||
contexts: List = None,
|
||||
system_prompt: str = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
system_prompt = ""
|
||||
new_record = {"role": "user", "content": prompt}
|
||||
query_context = [*contexts, new_record]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from openai import AsyncOpenAI
|
||||
from ..provider import EmbeddingProvider
|
||||
from ..register import register_provider_adapter
|
||||
from ..entities import ProviderType
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openai_embedding",
|
||||
"OpenAI API Embedding 提供商适配器",
|
||||
provider_type=ProviderType.EMBEDDING,
|
||||
)
|
||||
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.provider_config = provider_config
|
||||
self.provider_settings = provider_settings
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
base_url=provider_config.get(
|
||||
"embedding_api_base", "https://api.openai.com/v1"
|
||||
),
|
||||
timeout=int(provider_config.get("timeout", 20)),
|
||||
)
|
||||
self.model = provider_config.get("embedding_model", "text-embedding-3-small")
|
||||
self.dimension = provider_config.get("embedding_dimensions", 1536)
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""
|
||||
获取文本的嵌入
|
||||
"""
|
||||
embedding = await self.client.embeddings.create(input=text, model=self.model)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
批量获取文本的嵌入
|
||||
"""
|
||||
embeddings = await self.client.embeddings.create(input=texts, model=self.model)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
return self.dimension
|
||||
@@ -31,10 +31,12 @@ class ProviderZhipu(ProviderOpenAIOfficial):
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
contexts=None,
|
||||
system_prompt=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = []
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import List, Union
|
||||
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
@@ -125,11 +126,8 @@ class Context:
|
||||
self.provider_manager.provider_insts.append(provider)
|
||||
|
||||
def get_provider_by_id(self, provider_id: str) -> Provider:
|
||||
"""通过 ID 获取用于文本生成任务的 LLM Provider(Chat_Completion 类型)。"""
|
||||
for provider in self.provider_manager.provider_insts:
|
||||
if provider.meta().id == provider_id:
|
||||
return provider
|
||||
return None
|
||||
"""通过 ID 获取对应的 LLM Provider(Chat_Completion 类型)。"""
|
||||
return self.provider_manager.inst_map.get(provider_id)
|
||||
|
||||
def get_all_providers(self) -> List[Provider]:
|
||||
"""获取所有用于文本生成任务的 LLM Provider(Chat_Completion 类型)。"""
|
||||
@@ -143,24 +141,46 @@ class Context:
|
||||
"""获取所有用于 STT 任务的 Provider。"""
|
||||
return self.provider_manager.stt_provider_insts
|
||||
|
||||
def get_using_provider(self) -> Provider:
|
||||
def get_using_provider(self, umo: str = None) -> Provider:
|
||||
"""
|
||||
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
|
||||
获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
|
||||
|
||||
通过 /provider 指令切换。
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
|
||||
"""
|
||||
if umo and self._config["provider_settings"]["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
prov_id = perf.get(umo, {}).get(ProviderType.CHAT_COMPLETION.value, None)
|
||||
if inst := self.provider_manager.inst_map.get(prov_id, None):
|
||||
return inst
|
||||
return self.provider_manager.curr_provider_inst
|
||||
|
||||
def get_using_tts_provider(self) -> TTSProvider:
|
||||
def get_using_tts_provider(self, umo: str = None) -> TTSProvider:
|
||||
"""
|
||||
获取当前使用的用于 TTS 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
"""
|
||||
if umo and self._config["provider_settings"]["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
prov_id = perf.get(umo, {}).get(ProviderType.TEXT_TO_SPEECH.value, None)
|
||||
if inst := self.provider_manager.inst_map.get(prov_id, None):
|
||||
return inst
|
||||
return self.provider_manager.curr_tts_provider_inst
|
||||
|
||||
def get_using_stt_provider(self) -> STTProvider:
|
||||
def get_using_stt_provider(self, umo: str = None) -> STTProvider:
|
||||
"""
|
||||
获取当前使用的用于 STT 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
"""
|
||||
if umo and self._config["provider_settings"]["separate_provider"]:
|
||||
perf = sp.get("session_provider_perf", {})
|
||||
prov_id = perf.get(umo, {}).get(ProviderType.SPEECH_TO_TEXT.value, None)
|
||||
if inst := self.provider_manager.inst_map.get(prov_id, None):
|
||||
return inst
|
||||
return self.provider_manager.curr_stt_provider_inst
|
||||
|
||||
def get_config(self) -> AstrBotConfig:
|
||||
@@ -301,5 +321,11 @@ class Context:
|
||||
"""
|
||||
self._register_tasks.append(task)
|
||||
|
||||
def register_web_api(self, route: str, view_handler: Awaitable, methods: list, desc: str):
|
||||
def register_web_api(
|
||||
self, route: str, view_handler: Awaitable, methods: list, desc: str
|
||||
):
|
||||
for idx, api in enumerate(self.registered_web_apis):
|
||||
if api[0] == route and methods == api[2]:
|
||||
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
|
||||
return
|
||||
self.registered_web_apis.append((route, view_handler, methods, desc))
|
||||
|
||||
@@ -7,6 +7,9 @@ from astrbot.core.config import AstrBotConfig
|
||||
from .custom_filter import CustomFilter
|
||||
from ..star_handler import StarHandlerMetadata
|
||||
|
||||
class GreedyStr(str):
|
||||
"""标记指令完成其他参数接收后的所有剩余文本。"""
|
||||
pass
|
||||
|
||||
# 标准指令受到 wake_prefix 的制约。
|
||||
class CommandFilter(HandlerFilter):
|
||||
@@ -68,7 +71,22 @@ class CommandFilter(HandlerFilter):
|
||||
) -> Dict[str, Any]:
|
||||
"""将参数列表 params 根据 param_type 转换为参数字典。"""
|
||||
result = {}
|
||||
for i, (param_name, param_type_or_default_val) in enumerate(param_type.items()):
|
||||
param_items = list(param_type.items())
|
||||
for i, (param_name, param_type_or_default_val) in enumerate(param_items):
|
||||
is_greedy = param_type_or_default_val is GreedyStr
|
||||
|
||||
if is_greedy:
|
||||
# GreedyStr 必须是最后一个参数
|
||||
if i != len(param_items) - 1:
|
||||
raise ValueError(
|
||||
f"参数 '{param_name}' (GreedyStr) 必须是最后一个参数。"
|
||||
)
|
||||
|
||||
# 将剩余的所有部分合并成一个字符串
|
||||
remaining_params = params[i:]
|
||||
result[param_name] = " ".join(remaining_params)
|
||||
break
|
||||
# 没有 GreedyStr 的情况
|
||||
if i >= len(params):
|
||||
if (
|
||||
isinstance(param_type_or_default_val, Type)
|
||||
|
||||
@@ -37,6 +37,12 @@ except ImportError:
|
||||
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
|
||||
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
||||
|
||||
try:
|
||||
import nh3
|
||||
except ImportError:
|
||||
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
|
||||
nh3 = None
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig):
|
||||
@@ -140,11 +146,13 @@ class PluginManager:
|
||||
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
|
||||
os.path.join(path, d, d + ".py")
|
||||
):
|
||||
modules.append({
|
||||
"pname": d,
|
||||
"module": module_str,
|
||||
"module_path": os.path.join(path, d, module_str),
|
||||
})
|
||||
modules.append(
|
||||
{
|
||||
"pname": d,
|
||||
"module": module_str,
|
||||
"module_path": os.path.join(path, d, module_str),
|
||||
}
|
||||
)
|
||||
return modules
|
||||
|
||||
def _get_plugin_modules(self) -> List[dict]:
|
||||
@@ -158,7 +166,7 @@ class PluginManager:
|
||||
plugins.extend(_p)
|
||||
return plugins
|
||||
|
||||
def _check_plugin_dept_update(self, target_plugin: str = None):
|
||||
async def _check_plugin_dept_update(self, target_plugin: str = None):
|
||||
"""检查插件的依赖
|
||||
如果 target_plugin 为 None,则检查所有插件的依赖
|
||||
"""
|
||||
@@ -177,7 +185,7 @@ class PluginManager:
|
||||
pth = os.path.join(plugin_path, "requirements.txt")
|
||||
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
|
||||
try:
|
||||
pip_installer.install(requirements_path=pth)
|
||||
await pip_installer.install(requirements_path=pth)
|
||||
except Exception as e:
|
||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {str(e)}")
|
||||
|
||||
@@ -399,7 +407,7 @@ class PluginManager:
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# 尝试安装依赖
|
||||
self._check_plugin_dept_update(target_plugin=root_dir_name)
|
||||
await self._check_plugin_dept_update(target_plugin=root_dir_name)
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -443,11 +451,11 @@ class PluginManager:
|
||||
metadata.repo = metadata_yaml.repo
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
metadata.config = plugin_config
|
||||
if path not in inactivated_plugins:
|
||||
# 只有没有禁用插件时才实例化插件类
|
||||
if plugin_config:
|
||||
metadata.config = plugin_config
|
||||
# metadata.config = plugin_config
|
||||
try:
|
||||
metadata.star_cls = metadata.star_cls_type(
|
||||
context=self.context, config=plugin_config
|
||||
@@ -634,16 +642,21 @@ class PluginManager:
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(plugin_path, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
if os.path.exists(readme_path) and nh3:
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
cleaned_content = nh3.clean(readme_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {"repo": plugin.repo, "readme": readme_content}
|
||||
plugin_info = {
|
||||
"repo": plugin.repo,
|
||||
"readme": cleaned_content,
|
||||
"name": plugin.name,
|
||||
}
|
||||
|
||||
return plugin_info
|
||||
|
||||
@@ -838,6 +851,10 @@ class PluginManager:
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {"repo": plugin.repo, "readme": readme_content}
|
||||
plugin_info = {
|
||||
"repo": plugin.repo,
|
||||
"readme": readme_content,
|
||||
"name": plugin.name,
|
||||
}
|
||||
|
||||
return plugin_info
|
||||
|
||||
@@ -18,7 +18,8 @@ class PluginUpdator(RepoZipUpdator):
|
||||
return self.plugin_store_path
|
||||
|
||||
async def install(self, repo_url: str, proxy="") -> str:
|
||||
repo_name = self.format_repo_name(repo_url)
|
||||
_, repo_name, _ = self.parse_github_url(repo_url)
|
||||
repo_name = self.format_name(repo_name)
|
||||
plugin_path = os.path.join(self.plugin_store_path, repo_name)
|
||||
await self.download_from_repo_url(plugin_path, repo_url, proxy)
|
||||
self.unzip_file(plugin_path + ".zip", plugin_path)
|
||||
@@ -31,10 +32,6 @@ class PluginUpdator(RepoZipUpdator):
|
||||
if not repo_url:
|
||||
raise Exception(f"插件 {plugin.name} 没有指定仓库地址。")
|
||||
|
||||
if proxy:
|
||||
proxy = proxy.removesuffix("/")
|
||||
repo_url = f"{proxy}/{repo_url}"
|
||||
|
||||
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
|
||||
logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}")
|
||||
@@ -54,7 +51,7 @@ class PluginUpdator(RepoZipUpdator):
|
||||
def unzip_file(self, zip_path: str, target_dir: str):
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
update_dir = ""
|
||||
logger.info(f"解压文件: {zip_path}")
|
||||
logger.info(f"正在解压压缩包: {zip_path}")
|
||||
with zipfile.ZipFile(zip_path, "r") as z:
|
||||
update_dir = z.namelist()[0]
|
||||
z.extractall(target_dir)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from pip import main as pip_main
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -9,7 +10,7 @@ class PipInstaller:
|
||||
self.pip_install_arg = pip_install_arg
|
||||
self.pypi_index_url = pypi_index_url
|
||||
|
||||
def install(
|
||||
async def install(
|
||||
self,
|
||||
package_name: str = None,
|
||||
requirements_path: str = None,
|
||||
@@ -29,12 +30,33 @@ class PipInstaller:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
result_code = pip_main(args)
|
||||
assert process.stdout is not None
|
||||
async for line in process.stdout:
|
||||
logger.info(line.decode().strip())
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
await process.wait()
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"安装失败,错误码:{process.returncode}")
|
||||
except FileNotFoundError:
|
||||
# 没有 pip
|
||||
from pip import main as pip_main
|
||||
|
||||
result_code = await asyncio.to_thread(pip_main, args)
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import base64
|
||||
import wave
|
||||
import os
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
import tempfile
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
|
||||
@@ -50,3 +55,46 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
|
||||
rate = wav.getframerate()
|
||||
duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True)
|
||||
return duration
|
||||
|
||||
|
||||
async def wav_to_tencent_silk_base64(wav_path: str) -> str:
|
||||
"""
|
||||
将 WAV 文件转为 Silk,并返回 Base64 字符串。
|
||||
默认采样率为 24000,输出临时文件为 temp/output.silk。
|
||||
|
||||
参数:
|
||||
- wav_path: 输入 .wav 文件路径(需为 PCM 16bit)
|
||||
|
||||
返回:
|
||||
- Base64 编码的 Silk 字符串
|
||||
- duration: 音频时长(秒)
|
||||
"""
|
||||
try:
|
||||
import pilk
|
||||
except ImportError as e:
|
||||
raise Exception("pysilk 模块未安装,请安装 pysilk") from e
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
with wave.open(wav_path, "rb") as wav:
|
||||
rate = wav.getframerate()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".silk", delete=False, dir=temp_dir
|
||||
) as tmp_file:
|
||||
silk_path = tmp_file.name
|
||||
|
||||
try:
|
||||
duration = await asyncio.to_thread(
|
||||
pilk.encode, wav_path, silk_path, pcm_rate=rate, tencent=True
|
||||
)
|
||||
|
||||
with open(silk_path, "rb") as f:
|
||||
silk_bytes = await asyncio.to_thread(f.read)
|
||||
silk_b64 = base64.b64encode(silk_bytes).decode("utf-8")
|
||||
|
||||
return silk_b64, duration # 已是秒
|
||||
finally:
|
||||
if os.path.exists(silk_path):
|
||||
os.remove(silk_path)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import aiohttp
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
@@ -119,28 +120,61 @@ class RepoZipUpdator:
|
||||
)
|
||||
|
||||
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):
|
||||
repo_namespace = repo_url.split("/")[-2:]
|
||||
author = repo_namespace[0]
|
||||
repo = repo_namespace[1]
|
||||
author, repo, branch = self.parse_github_url(repo_url)
|
||||
|
||||
logger.info(f"正在下载更新 {repo} ...")
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
releases = await self.fetch_release_info(url=release_url)
|
||||
if not releases:
|
||||
# download from the default branch directly.
|
||||
logger.info(f"正在从默认分支下载 {author}/{repo} ")
|
||||
|
||||
if branch:
|
||||
logger.info(f"正在从指定分支 {branch} 下载 {author}/{repo}")
|
||||
release_url = (
|
||||
f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||
f"https://github.com/{author}/{repo}/archive/refs/heads/{branch}.zip"
|
||||
)
|
||||
else:
|
||||
release_url = releases[0]["zipball_url"]
|
||||
try:
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
releases = await self.fetch_release_info(url=release_url)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"获取 {author}/{repo} 的 GitHub Releases 失败: {e},将尝试下载默认分支"
|
||||
)
|
||||
releases = []
|
||||
if not releases:
|
||||
# 如果没有最新版本,下载默认分支
|
||||
logger.info(f"正在从默认分支下载 {author}/{repo}")
|
||||
release_url = (
|
||||
f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||
)
|
||||
else:
|
||||
release_url = releases[0]["zipball_url"]
|
||||
|
||||
if proxy:
|
||||
proxy = proxy.rstrip("/")
|
||||
release_url = f"{proxy}/{release_url}"
|
||||
logger.info(f"使用代理下载: {release_url}")
|
||||
logger.info(
|
||||
f"检查到设置了镜像站,将使用镜像站下载 {author}/{repo} 仓库源码: {release_url}"
|
||||
)
|
||||
|
||||
await download_file(release_url, target_path + ".zip")
|
||||
|
||||
def parse_github_url(self, url: str):
|
||||
"""使用正则表达式解析 GitHub 仓库 URL,支持 `.git` 后缀和 `tree/branch` 结构
|
||||
Returns:
|
||||
tuple[str, str, str]: 返回作者名、仓库名和分支名
|
||||
Raises:
|
||||
ValueError: 如果 URL 格式不正确
|
||||
"""
|
||||
cleaned_url = url.rstrip("/")
|
||||
pattern = r"^https://github\.com/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)(\.git)?(?:/tree/([a-zA-Z0-9_-]+))?$"
|
||||
match = re.match(pattern, cleaned_url)
|
||||
|
||||
if match:
|
||||
author = match.group(1)
|
||||
repo = match.group(2)
|
||||
branch = match.group(4)
|
||||
return author, repo, branch
|
||||
else:
|
||||
raise ValueError("无效的 GitHub URL")
|
||||
|
||||
def unzip_file(self, zip_path: str, target_dir: str):
|
||||
"""
|
||||
解压缩文件, 并将压缩包内**第一个**文件夹内的文件移动到 target_dir
|
||||
@@ -174,16 +208,5 @@ class RepoZipUpdator:
|
||||
f"删除更新文件失败,可以手动删除 {zip_path} 和 {os.path.join(target_dir, update_dir)}"
|
||||
)
|
||||
|
||||
def format_repo_name(self, repo_url: str) -> str:
|
||||
if repo_url.endswith("/"):
|
||||
repo_url = repo_url[:-1]
|
||||
|
||||
repo_namespace = repo_url.split("/")[-2:]
|
||||
repo = repo_namespace[1]
|
||||
|
||||
repo = self.format_name(repo)
|
||||
|
||||
return repo
|
||||
|
||||
def format_name(self, name: str) -> str:
|
||||
return name.replace("-", "_").lower()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt
|
||||
import datetime
|
||||
import asyncio
|
||||
from .route import Route, Response, RouteContext
|
||||
from quart import request
|
||||
from astrbot.core import WEBUI_SK, DEMO_MODE
|
||||
@@ -21,7 +22,11 @@ class AuthRoute(Route):
|
||||
post_data = await request.json
|
||||
if post_data["username"] == username and post_data["password"] == password:
|
||||
change_pwd_hint = False
|
||||
if username == "astrbot" and password == "77b90590a8945a7d36c963981a307dc9":
|
||||
if (
|
||||
username == "astrbot"
|
||||
and password == "77b90590a8945a7d36c963981a307dc9"
|
||||
and not DEMO_MODE
|
||||
):
|
||||
change_pwd_hint = True
|
||||
logger.warning("为了保证安全,请尽快修改默认密码。")
|
||||
|
||||
@@ -37,6 +42,7 @@ class AuthRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
else:
|
||||
await asyncio.sleep(3)
|
||||
return Response().error("用户名或密码错误").__dict__
|
||||
|
||||
async def edit_account(self):
|
||||
@@ -72,7 +78,7 @@ class AuthRoute(Route):
|
||||
def generate_jwt(self, username):
|
||||
payload = {
|
||||
"username": username,
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
}
|
||||
token = jwt.encode(payload, WEBUI_SK, algorithm="HS256")
|
||||
return token
|
||||
|
||||
@@ -26,6 +26,7 @@ class ChatRoute(Route):
|
||||
"/chat/conversations": ("GET", self.get_conversations),
|
||||
"/chat/get_conversation": ("GET", self.get_conversation),
|
||||
"/chat/delete_conversation": ("GET", self.delete_conversation),
|
||||
"/chat/rename_conversation": ("POST", self.rename_conversation),
|
||||
"/chat/get_file": ("GET", self.get_file),
|
||||
"/chat/post_image": ("POST", self.post_image),
|
||||
"/chat/post_file": ("POST", self.post_file),
|
||||
@@ -61,16 +62,25 @@ class ChatRoute(Route):
|
||||
return Response().error("Missing key: filename").__dict__
|
||||
|
||||
try:
|
||||
with open(os.path.join(self.imgs_dir, filename), "rb") as f:
|
||||
if filename.endswith(".wav"):
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
||||
|
||||
if not real_file_path.startswith(real_imgs_dir):
|
||||
return Response().error("Invalid file path").__dict__
|
||||
|
||||
with open(real_file_path, "rb") as f:
|
||||
filename_ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
if filename_ext == ".wav":
|
||||
return QuartResponse(f.read(), mimetype="audio/wav")
|
||||
elif filename.split(".")[-1] in self.supported_imgs:
|
||||
elif filename_ext[1:] in self.supported_imgs:
|
||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
||||
else:
|
||||
return QuartResponse(f.read())
|
||||
|
||||
except FileNotFoundError:
|
||||
return Response().error("File not found").__dict__
|
||||
except (FileNotFoundError, OSError):
|
||||
return Response().error("File access error").__dict__
|
||||
|
||||
async def post_image(self):
|
||||
post_data = await request.files
|
||||
@@ -91,7 +101,6 @@ class ChatRoute(Route):
|
||||
|
||||
file = post_data["file"]
|
||||
filename = f"{str(uuid.uuid4())}"
|
||||
print(file)
|
||||
# 通过文件格式判断文件类型
|
||||
if file.content_type.startswith("audio"):
|
||||
filename += ".wav"
|
||||
@@ -143,7 +152,7 @@ class ChatRoute(Route):
|
||||
try:
|
||||
history = json.loads(conversation.history)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
logger.error(f"Failed to parse conversation history: {e}")
|
||||
history = []
|
||||
new_his = {"type": "user", "message": message}
|
||||
if image_url:
|
||||
@@ -197,6 +206,9 @@ class ChatRoute(Route):
|
||||
if streaming and type != "end":
|
||||
continue
|
||||
|
||||
if type == "update_title":
|
||||
continue
|
||||
|
||||
if result_text:
|
||||
conversation = self.db.get_conversation_by_user_id(
|
||||
username, cid
|
||||
@@ -204,7 +216,7 @@ class ChatRoute(Route):
|
||||
try:
|
||||
history = json.loads(conversation.history)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
logger.error(f"Failed to parse conversation history: {e}")
|
||||
history = []
|
||||
history.append({"type": "bot", "message": result_text})
|
||||
self.db.update_conversation(
|
||||
@@ -242,6 +254,18 @@ class ChatRoute(Route):
|
||||
self.db.new_conversation(username, conversation_id)
|
||||
return Response().ok(data={"conversation_id": conversation_id}).__dict__
|
||||
|
||||
async def rename_conversation(self):
|
||||
username = g.get("username", "guest")
|
||||
post_data = await request.json
|
||||
if "conversation_id" not in post_data or "title" not in post_data:
|
||||
return Response().error("Missing key: conversation_id or title").__dict__
|
||||
|
||||
conversation_id = post_data["conversation_id"]
|
||||
title = post_data["title"]
|
||||
|
||||
self.db.update_conversation_title(username, conversation_id, title=title)
|
||||
return Response().ok(message="重命名成功!").__dict__
|
||||
|
||||
async def get_conversations(self):
|
||||
username = g.get("username", "guest")
|
||||
conversations = self.db.get_conversations(username)
|
||||
|
||||
@@ -9,6 +9,7 @@ from astrbot.core.platform.register import platform_registry
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core import logger
|
||||
import asyncio
|
||||
|
||||
|
||||
def try_cast(value: str, type_: str):
|
||||
@@ -153,6 +154,7 @@ class ConfigRoute(Route):
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config: AstrBotConfig = core_lifecycle.astrbot_config
|
||||
self.routes = {
|
||||
"/config/get": ("GET", self.get_configs),
|
||||
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
|
||||
@@ -164,9 +166,125 @@ class ConfigRoute(Route):
|
||||
"/config/provider/update": ("POST", self.post_update_provider),
|
||||
"/config/provider/delete": ("POST", self.post_delete_provider),
|
||||
"/config/llmtools": ("GET", self.get_llm_tools),
|
||||
"/config/provider/check_status": ("GET", self.check_all_providers_status),
|
||||
"/config/provider/list": ("GET", self.get_provider_config_list),
|
||||
"/config/provider/get_session_seperate": (
|
||||
"GET",
|
||||
lambda: Response()
|
||||
.ok({"enable": self.config["provider_settings"]["separate_provider"]})
|
||||
.__dict__,
|
||||
),
|
||||
"/config/provider/set_session_seperate": (
|
||||
"POST",
|
||||
self.post_session_seperate,
|
||||
),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def _test_single_provider(self, provider):
|
||||
"""辅助函数:测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_name = provider.provider_config.get("id", "Unknown Provider")
|
||||
logger.debug(f"Got provider meta: {meta}")
|
||||
if not provider_name and meta:
|
||||
provider_name = meta.id
|
||||
elif not provider_name:
|
||||
provider_name = "Unknown Provider"
|
||||
status_info = {
|
||||
"id": getattr(meta, "id", "Unknown ID"),
|
||||
"model": getattr(meta, "model", "Unknown Model"),
|
||||
"type": getattr(meta, "type", "Unknown Type"),
|
||||
"name": provider_name,
|
||||
"status": "unavailable", # 默认为不可用
|
||||
"error": None,
|
||||
}
|
||||
logger.debug(
|
||||
f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})"
|
||||
)
|
||||
try:
|
||||
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
|
||||
response = await asyncio.wait_for(
|
||||
provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0
|
||||
)
|
||||
logger.debug(f"Received response from {status_info['name']}: {response}")
|
||||
# 只要 text_chat 调用成功返回一个 LLMResponse 对象 (即 response 不为 None),就认为可用
|
||||
if response is not None:
|
||||
status_info["status"] = "available"
|
||||
response_text_snippet = ""
|
||||
if hasattr(response, "completion_text") and response.completion_text:
|
||||
response_text_snippet = (
|
||||
response.completion_text[:70] + "..."
|
||||
if len(response.completion_text) > 70
|
||||
else response.completion_text
|
||||
)
|
||||
elif hasattr(response, "result_chain") and response.result_chain:
|
||||
try:
|
||||
response_text_snippet = (
|
||||
response.result_chain.get_plain_text()[:70] + "..."
|
||||
if len(response.result_chain.get_plain_text()) > 70
|
||||
else response.result_chain.get_plain_text()
|
||||
)
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'"
|
||||
)
|
||||
else:
|
||||
# 这个分支理论上不应该被走到,除非 text_chat 实现可能返回 None
|
||||
status_info["error"] = (
|
||||
"Test call returned None, but expected an LLMResponse object."
|
||||
)
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None."
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
status_info["error"] = (
|
||||
"Connection timed out after 45 seconds during test call."
|
||||
)
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out."
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
status_info["error"] = error_message
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}"
|
||||
)
|
||||
logger.debug(
|
||||
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}"
|
||||
)
|
||||
return status_info
|
||||
|
||||
async def check_all_providers_status(self):
|
||||
"""
|
||||
API 接口: 检查所有 LLM Providers 的状态
|
||||
"""
|
||||
logger.info("API call received: /config/provider/check_status")
|
||||
try:
|
||||
all_providers: typing.List = (
|
||||
self.core_lifecycle.star_context.get_all_providers()
|
||||
)
|
||||
logger.debug(f"Found {len(all_providers)} providers to check.")
|
||||
|
||||
if not all_providers:
|
||||
logger.info("No providers found to check.")
|
||||
return Response().ok([]).__dict__
|
||||
|
||||
tasks = [self._test_single_provider(p) for p in all_providers]
|
||||
logger.debug(f"Created {len(tasks)} tasks for concurrent provider checks.")
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
logger.info(f"Provider status check completed. Results: {results}")
|
||||
|
||||
return Response().ok(results).__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error in check_all_providers_status: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
|
||||
)
|
||||
|
||||
async def get_configs(self):
|
||||
# plugin_name 为空时返回 AstrBot 配置
|
||||
# 否则返回指定 plugin_name 的插件配置
|
||||
@@ -175,6 +293,32 @@ class ConfigRoute(Route):
|
||||
return Response().ok(await self._get_astrbot_config()).__dict__
|
||||
return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
|
||||
|
||||
async def post_session_seperate(self):
|
||||
"""设置提供商会话隔离"""
|
||||
post_config = await request.json
|
||||
enable = post_config.get("enable", None)
|
||||
if enable is None:
|
||||
return Response().error("缺少参数 enable").__dict__
|
||||
|
||||
astrbot_config = self.core_lifecycle.astrbot_config
|
||||
astrbot_config["provider_settings"]["separate_provider"] = enable
|
||||
try:
|
||||
astrbot_config.save_config()
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "设置成功~").__dict__
|
||||
|
||||
async def get_provider_config_list(self):
|
||||
provider_type = request.args.get("provider_type", None)
|
||||
if not provider_type:
|
||||
return Response().error("缺少参数 provider_type").__dict__
|
||||
provider_list = []
|
||||
astrbot_config = self.core_lifecycle.astrbot_config
|
||||
for provider in astrbot_config["provider"]:
|
||||
if provider.get("provider_type", None) == provider_type:
|
||||
provider_list.append(provider)
|
||||
return Response().ok(provider_list).__dict__
|
||||
|
||||
async def post_astrbot_configs(self):
|
||||
post_configs = await request.json
|
||||
try:
|
||||
|
||||
@@ -23,6 +23,7 @@ class LogRoute(Route):
|
||||
**message, # see astrbot/core/log.py
|
||||
}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0.07) # 控制发送频率,避免过快
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as e:
|
||||
|
||||
@@ -18,6 +18,12 @@ from astrbot.core.star.filter.regex import RegexFilter
|
||||
from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core import DEMO_MODE
|
||||
|
||||
try:
|
||||
import nh3
|
||||
except ImportError:
|
||||
logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。")
|
||||
nh3 = None
|
||||
|
||||
|
||||
class PluginRoute(Route):
|
||||
def __init__(
|
||||
@@ -148,9 +154,7 @@ class PluginRoute(Route):
|
||||
if handler.event_type == EventType.AdapterMessageEvent:
|
||||
# 处理平台适配器消息事件
|
||||
has_admin = False
|
||||
for (
|
||||
filter
|
||||
) in (
|
||||
for filter in (
|
||||
handler.event_filters
|
||||
): # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高
|
||||
if isinstance(filter, CommandFilter):
|
||||
@@ -328,6 +332,9 @@ class PluginRoute(Route):
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def get_plugin_readme(self):
|
||||
if not nh3:
|
||||
return Response().error("未安装 nh3 库").__dict__
|
||||
|
||||
plugin_name = request.args.get("name")
|
||||
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
|
||||
|
||||
@@ -363,9 +370,11 @@ class PluginRoute(Route):
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
|
||||
cleaned_content = nh3.clean(readme_content)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok({"content": readme_content}, "成功获取README内容")
|
||||
.ok({"content": cleaned_content}, "成功获取README内容")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -386,14 +395,12 @@ class PluginRoute(Route):
|
||||
platform_type = platform.get("type", "")
|
||||
platform_id = platform.get("id", "")
|
||||
|
||||
platforms.append(
|
||||
{
|
||||
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
|
||||
"id": platform_id, # 保留id字段以便前端可以显示
|
||||
"type": platform_type,
|
||||
"display_name": f"{platform_type}({platform_id})",
|
||||
}
|
||||
)
|
||||
platforms.append({
|
||||
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
|
||||
"id": platform_id, # 保留id字段以便前端可以显示
|
||||
"type": platform_type,
|
||||
"display_name": f"{platform_type}({platform_id})",
|
||||
})
|
||||
|
||||
adjusted_platform_enable = {}
|
||||
for platform_id, plugins in platform_enable.items():
|
||||
@@ -402,13 +409,11 @@ class PluginRoute(Route):
|
||||
# 获取所有插件,包括系统内部插件
|
||||
plugins = []
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
plugins.append(
|
||||
{
|
||||
"name": plugin.name,
|
||||
"desc": plugin.desc,
|
||||
"reserved": plugin.reserved, # 添加reserved标志
|
||||
}
|
||||
)
|
||||
plugins.append({
|
||||
"name": plugin.name,
|
||||
"desc": plugin.desc,
|
||||
"reserved": plugin.reserved, # 添加reserved标志
|
||||
})
|
||||
|
||||
logger.debug(
|
||||
f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}"
|
||||
@@ -416,13 +421,11 @@ class PluginRoute(Route):
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"platforms": platforms,
|
||||
"plugins": plugins,
|
||||
"platform_enable": adjusted_platform_enable,
|
||||
}
|
||||
)
|
||||
.ok({
|
||||
"platforms": platforms,
|
||||
"plugins": plugins,
|
||||
"platform_enable": adjusted_platform_enable,
|
||||
})
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -46,11 +46,27 @@ class StatRoute(Route):
|
||||
h, m = divmod(m, 60)
|
||||
return f"{h}小时{m}分{s}秒"
|
||||
|
||||
def is_default_cred(self):
|
||||
username = self.config["dashboard"]["username"]
|
||||
password = self.config["dashboard"]["password"]
|
||||
return (
|
||||
username == "astrbot"
|
||||
and password == "77b90590a8945a7d36c963981a307dc9"
|
||||
and not DEMO_MODE
|
||||
)
|
||||
|
||||
async def get_version(self):
|
||||
return Response().ok({
|
||||
"version": VERSION,
|
||||
"dashboard_version": await get_dashboard_version(),
|
||||
}).__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"version": VERSION,
|
||||
"dashboard_version": await get_dashboard_version(),
|
||||
"change_pwd_hint": self.is_default_cred(),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_start_time(self):
|
||||
return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__
|
||||
|
||||
@@ -12,7 +12,10 @@ class StaticFileRoute(Route):
|
||||
"/logs",
|
||||
"/extension",
|
||||
"/dashboard/default",
|
||||
"/project-atri",
|
||||
"/alkaid",
|
||||
"/alkaid/knowledge-base",
|
||||
"/alkaid/long-term-memory",
|
||||
"/alkaid/other",
|
||||
"/console",
|
||||
"/chat",
|
||||
"/settings",
|
||||
|
||||
@@ -91,7 +91,7 @@ class UpdateRoute(Route):
|
||||
# pip 更新依赖
|
||||
logger.info("更新依赖中...")
|
||||
try:
|
||||
pip_installer.install(requirements_path="requirements.txt")
|
||||
await pip_installer.install(requirements_path="requirements.txt")
|
||||
except Exception as e:
|
||||
logger.error(f"更新依赖失败: {e}")
|
||||
|
||||
@@ -140,7 +140,7 @@ class UpdateRoute(Route):
|
||||
if not package:
|
||||
return Response().error("缺少参数 package 或不合法。").__dict__
|
||||
try:
|
||||
pip_installer.install(package, mirror=mirror)
|
||||
await pip_installer.install(package, mirror=mirror)
|
||||
return Response().ok(None, "安装成功。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"/api/update_pip: {traceback.format_exc()}")
|
||||
|
||||
@@ -70,13 +70,13 @@ class AstrBotDashboard:
|
||||
for api in registered_web_apis:
|
||||
route, view_handler, methods, _ = api
|
||||
if route == f"/{subpath}" and request.method in methods:
|
||||
return await view_handler(*args, **kwargs)
|
||||
return await view_handler(*args, **kwargs)
|
||||
return jsonify(Response().error("未找到该路由").__dict__)
|
||||
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
return
|
||||
allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"]
|
||||
allowed_endpoints = ["/api/auth/login", "/api/file"]
|
||||
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
|
||||
return
|
||||
# claim jwt
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# What's Changed
|
||||
|
||||
1. 新增:WebUI 支持暗夜模式。
|
||||
2. 修复:修复 WebUI Chat 接口的未授权访问安全漏洞、插件 README 可能存在的 XSS 注入漏洞。
|
||||
3. 优化:优化 Vec DB 在 indexing 过程时的数据库事务处理。
|
||||
4. 修复:WebUI 下,插件市场的推荐卡片无法点击帮助文档的问题。
|
||||
5. 新增:知识库。
|
||||
6. 新增:WebUI 提供商测试功能,一键检测可用性。
|
||||
7. 新增:WebUI 提供商分类功能,按能力分类提供商。
|
||||
@@ -0,0 +1,11 @@
|
||||
# What's Changed
|
||||
|
||||
1. 优化:强化了 WebUI 安全性
|
||||
2. 修复:测试文本生成提供商时可能出现的误报
|
||||
3. 修复:刷新知识库页面时出现404
|
||||
4. 新增:WeChatPadPro 支持获取引用、语音收发、视频等消息段
|
||||
5. 优化:WebUI 账户修改页面的设计逻辑
|
||||
6. 优化:插件更新后自动刷新插件列表
|
||||
7. 新增:支持下载插件的指定分支
|
||||
8. 修复:WeChatPadPro 群聊模式下 @ 不回复等问题
|
||||
9. 其他更新、优化及修复
|
||||
@@ -0,0 +1,13 @@
|
||||
# What's Changed
|
||||
|
||||
1. 修复:如果设置了 GitHub 加速地址,更新插件会报错
|
||||
2. 修复:部分场景下,`只@触发等待` 配置项功能无效的问题
|
||||
3. 新增:增加 `只@触发等待时是否回复` 配置项
|
||||
4. 新增:**支持模型提供商使用时会话隔离(需要手动开启配置项:提供商会话隔离)**
|
||||
5. 新增:Google Gemini 提供商支持 URL 上下文功能
|
||||
6. 新增:优化 WebChat 的 UI 显示,WebChat 支持修改标题和自动生成标题,支持 WebChatBox
|
||||
7. 新增:支持可配置是否忽略 @ 全体成员
|
||||
8. 优化:WebUI 顶栏移动端显示
|
||||
9. 优化:插件/AstrBot 配置项完整性检查的同时也保证**配置项相对顺序一致性**
|
||||
10. 优化:perf: 分段回复时,仅在输出的第一句话带上回复/引用
|
||||
11. 修复: Windows 下部署项目时可能出现的 UnicodeDecodeError。
|
||||
@@ -0,0 +1,18 @@
|
||||
# What's Changed
|
||||
|
||||
1. 新增:支持接入 Slack
|
||||
2. 新增:支持接入 Discord
|
||||
3. 新增:支持接入 KOOK
|
||||
4. 新增:支持接入 VoceChat
|
||||
5. 新增:微信客服支持语音的收发
|
||||
6. 新增:实现 WebUI 的 i18n 模型,WebUI 现已支持 English。
|
||||
7. 新增:支持接入 GPT SoVITS
|
||||
8. 优化:支持通过引用 Bot 消息来唤醒 Bot
|
||||
9. 优化:WebUI 滚动条、侧边栏样式优化
|
||||
10. 优化:WebUI ChatBox 的样式优化,添加切换夜间模式按钮
|
||||
11. 优化:WebUI Chat 页面的 SSE 连接优化及一些其他样式优化
|
||||
12. 优化:钉钉发送图片支持使用 AstrBot 自带的文件服务器
|
||||
13. 优化:新建服务提供商时,如果没有添加 Key,会弹出警告提示框
|
||||
14. 修复:会话隔离模式下,WeChatPadPro 会话 ID 为自身 ID
|
||||
15. 修复:会话隔离模式下,WeChatPadPro 无法回复群聊消息
|
||||
16. 修复:使用 uvx 启动 AstrBot 时,插件依赖无法正常安装
|
||||
@@ -31,6 +31,7 @@
|
||||
"vee-validate": "4.11.3",
|
||||
"vite-plugin-vuetify": "1.0.2",
|
||||
"vue": "3.3.4",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue-router": "4.2.4",
|
||||
"vue3-apexcharts": "1.4.4",
|
||||
"vue3-print-nb": "0.1.4",
|
||||
@@ -41,11 +42,11 @@
|
||||
"@mdi/font": "7.2.96",
|
||||
"@rushstack/eslint-patch": "1.3.3",
|
||||
"@types/chance": "1.1.3",
|
||||
"@types/node": "20.5.7",
|
||||
"@types/node": "^20.5.7",
|
||||
"@vitejs/plugin-vue": "4.3.3",
|
||||
"@vue/eslint-config-prettier": "8.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.3",
|
||||
"@vue/tsconfig": "0.4.0",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-plugin-vue": "9.17.0",
|
||||
"prettier": "3.0.2",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="2500" height="2500"><path fill="#4285F4" d="M1024 155.733C1024 70.4 953.6 0 868.267 0H155.733C70.4 0 0 70.4 0 155.733v712.534C0 953.6 70.4 1024 155.733 1024h712.534C953.6 1024 1024 953.6 1024 868.267V155.733z"/><path fill="#FFF" d="M810.667 422.4c-2.134 6.4-4.267 14.933-8.534 23.467C774.4 505.6 701.867 620.8 701.867 620.8l-21.334 36.267h102.4L588.8 915.2l44.8-174.933h-78.933l27.733-115.2c-23.467 6.4-49.067 12.8-81.067 23.466 0 0-42.666 25.6-121.6-46.933 0 0-53.333-46.933-23.466-59.733 12.8-4.267 64-10.667 104.533-17.067 55.467-6.4 87.467-10.667 87.467-10.667s-168.534 2.134-206.934-4.266c-40.533-6.4-89.6-72.534-100.266-132.267 0 0-17.067-32 36.266-17.067s268.8 59.734 268.8 59.734-281.6-87.467-300.8-108.8c-19.2-21.334-55.466-115.2-51.2-172.8 0 0 2.134-14.934 17.067-10.667 0 0 209.067 96 352 147.2 140.8 53.333 264.533 81.067 247.467 147.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 928 B |
@@ -0,0 +1 @@
|
||||
<svg height="1828" viewBox="-10.63 -.07077792 823.87 610.06955549" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m678.27 51.62c90.35 132.84 134.97 282.68 118.29 455.18-.07.73-.45 1.4-1.05 1.84-68.42 50.24-134.71 80.73-200.07 100.95a2.55 2.55 0 0 1 -2.81-.95c-15.1-21.01-28.82-43.16-40.84-66.42-.69-1.37-.06-3.02 1.36-3.56 21.79-8.21 42.51-18.05 62.44-29.7 1.57-.92 1.67-3.17.22-4.25-4.23-3.14-8.42-6.44-12.43-9.74-.75-.61-1.76-.73-2.61-.32-129.39 59.75-271.13 59.75-402.05 0-.85-.38-1.86-.25-2.59.35-4 3.3-8.2 6.57-12.39 9.71-1.45 1.08-1.33 3.33.25 4.25 19.93 11.43 40.65 21.49 62.41 29.74 1.41.54 2.08 2.15 1.38 3.52-11.76 23.29-25.48 45.44-40.86 66.45-.67.85-1.77 1.24-2.81.92-65.05-20.22-131.34-50.71-199.76-100.95-.57-.44-.98-1.14-1.04-1.87-13.94-149.21 14.47-300.29 118.18-455.18.25-.41.63-.73 1.07-.92 51.03-23.42 105.7-40.65 162.84-50.49 1.04-.16 2.08.32 2.62 1.24 7.06 12.5 15.13 28.53 20.59 41.63 60.23-9.2 121.4-9.2 182.89 0 5.46-12.82 13.25-29.13 20.28-41.63a2.47 2.47 0 0 1 2.62-1.24c57.17 9.87 111.84 27.1 162.83 50.49.45.19.82.51 1.04.95zm-339.04 283.7c.63-44.11-31.53-80.61-71.9-80.61-40.04 0-71.89 36.18-71.89 80.61 0 44.42 32.48 80.6 71.89 80.6 40.05 0 71.9-36.18 71.9-80.6zm265.82 0c.63-44.11-31.53-80.61-71.89-80.61-40.05 0-71.9 36.18-71.9 80.61 0 44.42 32.48 80.6 71.9 80.6 40.36 0 71.89-36.18 71.89-80.6z" fill="#5865f2"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg enable-background="new 0 0 2447.6 2452.5" viewBox="0 0 2447.6 2452.5" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill-rule="evenodd"><path d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z" fill="#36c5f0"/><path d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z" fill="#2eb67d"/><path d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z" fill="#ecb22e"/><path d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0" fill="#e01e5a"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="svg2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="2500" height="2500"><style>.st0{fill:url(#path2995-1-0_1_)}.st1{fill:#c8daea}.st2{fill:#a9c9dd}.st3{fill:url(#path2991_1_)}</style><linearGradient id="path2995-1-0_1_" gradientUnits="userSpaceOnUse" x1="-683.305" y1="534.845" x2="-693.305" y2="511.512" gradientTransform="matrix(6 0 0 -6 4255 3247)"><stop offset="0" stop-color="#37aee2"/><stop offset="1" stop-color="#1e96c8"/></linearGradient><path id="path2995-1-0" class="st0" d="M240 120c0 66.3-53.7 120-120 120S0 186.3 0 120 53.7 0 120 0s120 53.7 120 120z"/><path id="path2993" class="st1" d="M98 175c-3.9 0-3.2-1.5-4.6-5.2L82 132.2 152.8 88l8.3 2.2-6.9 18.8L98 175z"/><path id="path2989" class="st2" d="M98 175c3 0 4.3-1.4 6-3 2.6-2.5 36-35 36-35l-20.5-5-19 12-2.5 30v1z"/><linearGradient id="path2991_1_" gradientUnits="userSpaceOnUse" x1="128.991" y1="118.245" x2="153.991" y2="78.245" gradientTransform="matrix(1 0 0 -1 0 242)"><stop offset="0" stop-color="#eff7fc"/><stop offset="1" stop-color="#fff"/></linearGradient><path id="path2991" class="st3" d="M100 144.4l48.4 35.7c5.5 3 9.5 1.5 10.9-5.1L179 82.2c2-8.1-3.1-11.7-8.4-9.3L55 117.5c-7.9 3.2-7.8 7.6-1.4 9.5l29.7 9.3L152 93c3.2-2 6.2-.9 3.8 1.3L100 144.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -5,8 +5,8 @@
|
||||
<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-btn color="gray" @click="handleCancel">{{ t('core.common.dialog.cancelButton') }}</v-btn>
|
||||
<v-btn color="red" @click="handleConfirm">{{ t('core.common.dialog.confirmButton') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -14,6 +14,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const title = ref("");
|
||||
@@ -21,8 +24,8 @@ const message = ref("");
|
||||
let resolvePromise = null; // ✅ 确保 Promise 句柄可用
|
||||
|
||||
const open = (options) => {
|
||||
title.value = options.title || "确认操作";
|
||||
message.value = options.message || "你确定要执行此操作吗?";
|
||||
title.value = options.title || t('core.common.dialog.confirmTitle');
|
||||
message.value = options.message || t('core.common.dialog.confirmMessage');
|
||||
isOpen.value = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -107,7 +107,7 @@ function saveEditedContent() {
|
||||
color="primary"
|
||||
class="editor-fullscreen-btn"
|
||||
@click="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)"
|
||||
title="全屏编辑"
|
||||
:title="t('core.common.editor.fullscreen')"
|
||||
>
|
||||
<v-icon>mdi-fullscreen</v-icon>
|
||||
</v-btn>
|
||||
@@ -288,10 +288,10 @@ function saveEditedContent() {
|
||||
<v-btn icon @click="dialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-toolbar-title>编辑内容 - {{ currentEditingKey }}</v-toolbar-title>
|
||||
<v-toolbar-title>{{ t('core.common.editor.editingTitle') }} - {{ currentEditingKey }}</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-items>
|
||||
<v-btn variant="text" @click="saveEditedContent">保存</v-btn>
|
||||
<v-btn variant="text" @click="saveEditedContent">{{ t('core.common.save') }}</v-btn>
|
||||
</v-toolbar-items>
|
||||
</v-toolbar>
|
||||
<v-card-text class="pa-0">
|
||||
@@ -309,12 +309,17 @@ function saveEditedContent() {
|
||||
|
||||
<script>
|
||||
import ListConfigItem from './ListConfigItem.vue';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'AstrBotConfig',
|
||||
components: {
|
||||
ListConfigItem
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
return { t };
|
||||
},
|
||||
props: {
|
||||
metadata: {
|
||||
type: Object,
|
||||
@@ -340,12 +345,12 @@ export default {
|
||||
.config-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--v-primary-darken1);
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -400,12 +405,12 @@ export default {
|
||||
.property-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.property-hint {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCommonStore } from '@/stores/common';
|
||||
<template>
|
||||
<div>
|
||||
<!-- 添加筛选级别控件 -->
|
||||
<div class="filter-controls mb-2">
|
||||
<div class="filter-controls mb-2" v-if="showLevelBtns">
|
||||
<v-chip-group v-model="selectedLevels" column multiple>
|
||||
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
|
||||
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
|
||||
@@ -51,7 +51,11 @@ export default {
|
||||
props: {
|
||||
historyNum: {
|
||||
type: String,
|
||||
default: -1
|
||||
default: "-1"
|
||||
},
|
||||
showLevelBtns: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -30,6 +32,9 @@ const emit = defineEmits([
|
||||
|
||||
const reveal = ref(false);
|
||||
|
||||
// 国际化
|
||||
const { tm } = useModuleI18n('features/extension');
|
||||
|
||||
// 操作函数
|
||||
const configure = () => {
|
||||
emit('configure', props.extension);
|
||||
@@ -46,13 +51,13 @@ const reloadExtension = () => {
|
||||
const $confirm = inject("$confirm");
|
||||
const uninstallExtension = async () => {
|
||||
if (typeof $confirm !== "function") {
|
||||
console.error("$confirm 未正确注册");
|
||||
console.error(tm("card.errors.confirmNotRegistered"));
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await $confirm({
|
||||
title: "删除确认",
|
||||
message: "你确定要删除当前插件吗?",
|
||||
title: tm("dialogs.uninstall.title"),
|
||||
message: tm("dialogs.uninstall.message"),
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
@@ -75,7 +80,9 @@ const viewReadme = () => {
|
||||
|
||||
<template>
|
||||
<v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1"
|
||||
:style="{ height: $vuetify.display.xs ? '250px' : '220px', backgroundColor: highlight ? '#FAF0DB' : '#ffffff', color: highlight ? '#000' : '#000000' }">
|
||||
:style="{ height: $vuetify.display.xs ? '250px' : '220px',
|
||||
backgroundColor: useCustomizerStore().uiTheme==='PurpleTheme' ? marketMode ? '#f8f0dd' : '#ffffff' : '#282833',
|
||||
color: useCustomizerStore().uiTheme==='PurpleTheme' ? '#000000dd' : '#ffffff'}">
|
||||
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;">
|
||||
|
||||
<div class="flex-grow-1">
|
||||
@@ -87,13 +94,13 @@ const viewReadme = () => {
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
|
||||
</template>
|
||||
<span>有新版本可用: {{ extension.online_version }}</span>
|
||||
<span>{{ tm("card.status.hasUpdate") }}: {{ extension.online_version }}</span>
|
||||
</v-tooltip>
|
||||
<v-tooltip location="top" v-if="!extension.activated && !marketMode">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps" color="error" class="ml-2" icon="mdi-cancel" size="small"></v-icon>
|
||||
</template>
|
||||
<span>该插件已经被禁用</span>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
|
||||
@@ -108,7 +115,7 @@ const viewReadme = () => {
|
||||
</v-chip>
|
||||
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length">
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length }}个行为
|
||||
{{ extension.handlers?.length }}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
@@ -124,16 +131,16 @@ const viewReadme = () => {
|
||||
borderRadius: '8px',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center'
|
||||
}" alt="logo" />
|
||||
}" :alt="tm('card.alt.logo')" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions style="padding: 0px; margin-top: auto;">
|
||||
<v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn>
|
||||
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
|
||||
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
|
||||
<v-card-actions style="margin-left: 0px; gap: 2px;">
|
||||
<v-btn color="teal-accent-4" :text="tm('buttons.viewDocs')" variant="text" @click="viewReadme"></v-btn>
|
||||
<v-btn v-if="!marketMode" color="teal-accent-4" :text="tm('buttons.actions')" variant="text" @click="reveal = true"></v-btn>
|
||||
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" :text="tm('buttons.install')" variant="text"
|
||||
@click="emit('install', extension)"></v-btn>
|
||||
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" text="已安装" variant="text" disabled></v-btn>
|
||||
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" :text="tm('status.installed')" variant="text" disabled></v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
<v-expand-transition v-if="!marketMode">
|
||||
@@ -142,7 +149,7 @@ const viewReadme = () => {
|
||||
<v-card-text style="overflow-y: auto;">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<img v-if="extension.logo" :src="extension.logo"
|
||||
style="height: 50px; width: 50px; border-radius: 8px; margin-right: 16px;" alt="扩展图标" />
|
||||
style="height: 50px; width: 50px; border-radius: 8px; margin-right: 16px;" :alt="tm('card.alt.extensionIcon')" />
|
||||
<h3>{{ extension.name }}</h3>
|
||||
</div>
|
||||
|
||||
@@ -156,39 +163,39 @@ const viewReadme = () => {
|
||||
}">
|
||||
<v-btn prepend-icon="mdi-cog" color="primary" variant="tonal" @click="configure"
|
||||
:block="$vuetify.display.xs">
|
||||
插件配置
|
||||
{{ tm("card.actions.pluginConfig") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="mdi-delete" color="error" variant="tonal" @click="uninstallExtension"
|
||||
:block="$vuetify.display.xs">
|
||||
卸载插件
|
||||
{{ tm("card.actions.uninstallPlugin") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="mdi-reload" color="primary" variant="tonal" @click="reloadExtension"
|
||||
:block="$vuetify.display.xs">
|
||||
重载插件
|
||||
{{ tm("card.actions.reloadPlugin") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn :prepend-icon="extension.activated ? 'mdi-cancel' : 'mdi-check-circle'"
|
||||
:color="extension.activated ? 'error' : 'success'" variant="tonal" @click="toggleActivation"
|
||||
:block="$vuetify.display.xs">
|
||||
{{ extension.activated ? '禁用' : '启用' }}插件
|
||||
{{ extension.activated ? tm('buttons.disable') : tm('buttons.enable') }}{{ tm("card.actions.togglePlugin") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="mdi-cogs" color="info" variant="tonal" @click="viewHandlers"
|
||||
:block="$vuetify.display.xs">
|
||||
查看行为 ({{ extension.handlers.length }})
|
||||
{{ tm("card.actions.viewHandlers") }} ({{ extension.handlers.length }})
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="mdi-update" color="primary" variant="tonal" :disabled="!extension?.has_update "
|
||||
@click="updateExtension" :block="$vuetify.display.xs">
|
||||
更新到 {{ extension.online_version || extension.version }}
|
||||
{{ tm("card.actions.updateTo") }} {{ extension.online_version || extension.version }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pt-0 d-flex justify-center">
|
||||
<v-btn color="teal-accent-4" text="返回" variant="text" @click="reveal = false"></v-btn>
|
||||
<v-btn color="teal-accent-4" :text="tm('buttons.back')" variant="text" @click="reveal = false"></v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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>
|
||||
<p class="text-grey mt-4">{{ displayEmptyText }}</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
@update:model-value="toggleEnabled(item)"
|
||||
></v-switch>
|
||||
</template>
|
||||
<span>{{ getItemEnabled(item) ? '已启用' : '已禁用' }}</span>
|
||||
<span>{{ getItemEnabled(item) ? t('core.common.itemCard.enabled') : t('core.common.itemCard.disabled') }}</span>
|
||||
</v-tooltip>
|
||||
</v-card-title>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
prepend-icon="mdi-delete"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
删除
|
||||
{{ t('core.common.itemCard.delete') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@@ -52,7 +52,7 @@
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="$emit('edit', item)"
|
||||
>
|
||||
编辑
|
||||
{{ t('core.common.itemCard.edit') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
@@ -62,8 +62,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'ItemCardGrid',
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
return { t };
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
@@ -83,10 +89,15 @@ export default {
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无数据'
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['toggle-enabled', 'delete', 'edit'],
|
||||
computed: {
|
||||
displayEmptyText() {
|
||||
return this.emptyText || this.t('core.common.itemCard.noData');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getItemTitle(item) {
|
||||
return item[this.titleField];
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<v-menu offset="12" location="bottom center">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
:variant="(props.variant === 'header' || props.variant === 'chatbox') ? 'flat' : 'text'"
|
||||
:color="(props.variant === 'header' || props.variant === 'chatbox') ? 'var(--v-theme-surface)' : undefined"
|
||||
:rounded="(props.variant === 'header' || props.variant === 'chatbox') ? 'sm' : undefined"
|
||||
icon
|
||||
size="small"
|
||||
:class="['language-switcher', `language-switcher--${props.variant}`, (props.variant === 'header' || props.variant === 'chatbox') ? 'action-btn' : '']"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
|
||||
>
|
||||
mdi-translate
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ t('core.common.language') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="language-dropdown" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }"
|
||||
class="language-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||
import { useCustomizerStore } from '@/stores/customizer'
|
||||
import type { Locale } from '@/i18n/types'
|
||||
|
||||
// 定义props来控制样式变体
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: 'default' | 'header' | 'chatbox'
|
||||
}>(), {
|
||||
variant: 'default'
|
||||
})
|
||||
|
||||
// 使用新的i18n系统
|
||||
const { t } = useI18n()
|
||||
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher()
|
||||
|
||||
const languages = computed(() =>
|
||||
languageOptions.value.map(lang => ({
|
||||
code: lang.value,
|
||||
name: lang.label,
|
||||
flag: lang.flag
|
||||
}))
|
||||
)
|
||||
|
||||
const currentLocale = computed(() => locale.value)
|
||||
|
||||
const changeLanguage = async (langCode: string) => {
|
||||
await switchLanguage(langCode as Locale)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-flag {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 默认变体样式 - 圆形按钮用于登录页 */
|
||||
.language-switcher--default {
|
||||
margin: 0 4px;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 50% !important;
|
||||
min-width: 32px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.language-switcher--default:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
||||
.language-switcher--header {
|
||||
/* action-btn类已经处理了margin-right: 6px,不需要额外样式 */
|
||||
}
|
||||
|
||||
/* ChatBox变体样式 - 与Header保持一致 */
|
||||
.language-switcher--chatbox {
|
||||
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
||||
}
|
||||
|
||||
/* 深色模式下的悬停效果(仅对default变体) */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
background: #f8f6fc !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 深色模式下的下拉框样式 */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-dropdown {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.language-item:hover {
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
}
|
||||
|
||||
.language-item-selected {
|
||||
background: rgba(94, 53, 177, 0.15) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.language-item-selected:hover {
|
||||
background: rgba(94, 53, 177, 0.2) !important;
|
||||
}
|
||||
|
||||
/* 深色模式下的列表项悬停效果 */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-item:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
|
||||
:deep(.v-theme--PurpleThemeDark) .language-item-selected {
|
||||
background: rgba(114, 46, 209, 0.2) !important;
|
||||
}
|
||||
|
||||
:deep(.v-theme--PurpleThemeDark) .language-item-selected:hover {
|
||||
background: rgba(114, 46, 209, 0.25) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -37,11 +37,11 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<v-text-field v-model="newItem" label="添加新项,按回车确认添加" @keyup.enter="addItem" clearable dense hide-details
|
||||
<v-text-field v-model="newItem" :label="t('core.common.list.addItemPlaceholder')" @keyup.enter="addItem" clearable dense hide-details
|
||||
variant="outlined" density="compact"></v-text-field>
|
||||
<v-btn @click="addItem" text variant="tonal">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
添加
|
||||
{{ t('core.common.list.addButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
@@ -49,8 +49,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'ListConfigItem',
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
return { t };
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
@@ -104,11 +110,11 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.list-config-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
border: 1px solid var(--v-theme-border);
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--v-theme-background);
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
|
||||
@@ -5,15 +5,41 @@
|
||||
<img width="110" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h2 class="text-secondary">AstrBot 仪表盘</h2>
|
||||
<h4 class="text-disabled">登录以继续</h4>
|
||||
<h2
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
|
||||
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
||||
></h2>
|
||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
||||
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No props or other logic needed for this simple component
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>(), {
|
||||
title: '', // 默认为空,组件会使用翻译值
|
||||
subtitle: ''
|
||||
})
|
||||
|
||||
// 智能格式化标题,在小屏幕上允许在合适位置换行
|
||||
const formatTitle = (title: string) => {
|
||||
// 如果标题包含 "AstrBot" 和其他文字,在它们之间添加换行机会
|
||||
if (title.includes('AstrBot ') || title.includes('AstrBot')) {
|
||||
// 处理 "AstrBot 仪表盘" 或 "AstrBot Dashboard" 等格式
|
||||
return title.replace(/(AstrBot)\s+(.+)/, '$1<wbr> $2');
|
||||
}
|
||||
return title;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -30,6 +56,8 @@
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
@@ -50,21 +78,50 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #5e35b1;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* 在小屏幕上允许在指定位置换行 */
|
||||
@media (max-width: 420px) {
|
||||
.logo-text h2 {
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-text h4 {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #616161;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式处理 */
|
||||
@media (max-width: 520px) {
|
||||
.logo-content {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.logo-text h4 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logo-image img {
|
||||
width: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { ref, watch, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -22,6 +23,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:show']);
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n();
|
||||
|
||||
const content = ref(null);
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
@@ -54,10 +58,10 @@ async function fetchReadme() {
|
||||
if (res.data.status === 'ok') {
|
||||
content.value = res.data.data.content;
|
||||
} else {
|
||||
error.value = res.data.message || '获取README失败';
|
||||
error.value = res.data.message || t('core.common.readme.errors.fetchFailed');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '获取README时发生错误';
|
||||
error.value = err.message || t('core.common.readme.errors.fetchError');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -99,13 +103,23 @@ function renderMarkdown(content) {
|
||||
function refreshReadme() {
|
||||
fetchReadme();
|
||||
}
|
||||
|
||||
// 计算属性处理双向绑定
|
||||
const _show = computed({
|
||||
get() {
|
||||
return props.show;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="_show" width="800" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h5">插件说明文档</span>
|
||||
<span class="text-h5">{{ t('core.common.readme.title') }}</span>
|
||||
<v-btn icon @click="$emit('update:show', false)">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
@@ -119,21 +133,21 @@ function refreshReadme() {
|
||||
prepend-icon="mdi-github"
|
||||
@click="openRepoInNewTab()"
|
||||
>
|
||||
在GitHub中查看仓库
|
||||
{{ t('core.common.readme.buttons.viewOnGithub') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="refreshReadme()"
|
||||
>
|
||||
刷新文档
|
||||
{{ t('core.common.readme.buttons.refresh') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="d-flex flex-column align-center justify-center" style="height: 100%;">
|
||||
<v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular>
|
||||
<p class="text-body-1 text-center">正在加载README文档...</p>
|
||||
<p class="text-body-1 text-center">{{ t('core.common.readme.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 内容显示 -->
|
||||
@@ -148,14 +162,14 @@ function refreshReadme() {
|
||||
<!-- 无内容提示 -->
|
||||
<div v-else class="d-flex flex-column align-center justify-center" style="height: 100%;">
|
||||
<v-icon size="64" color="warning" class="mb-4">mdi-file-question-outline</v-icon>
|
||||
<p class="text-body-1 text-center mb-4">该插件未提供文档链接或GitHub仓库地址。<br>请查看插件市场或联系插件作者获取更多信息。</p>
|
||||
<p class="text-body-1 text-center mb-4">{{ t('core.common.readme.empty.title') }}<br>{{ t('core.common.readme.empty.subtitle') }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" variant="tonal" @click="$emit('update:show', false)">
|
||||
关闭
|
||||
{{ t('core.common.close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
@@ -164,124 +178,124 @@ function refreshReadme() {
|
||||
|
||||
<style>
|
||||
.markdown-body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 8px 0;
|
||||
color: #24292e;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 8px 0;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 85%;
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
background-color: var(--v-theme-codeBg);
|
||||
border-radius: 3px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 2em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
box-sizing: border-box;
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--v-theme-background);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 1em;
|
||||
color: var(--v-theme-secondaryText);
|
||||
border-left: 0.25em solid var(--v-theme-border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
color: var(--v-theme-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
margin-bottom: 16px;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid var(--v-theme-background);
|
||||
}
|
||||
|
||||
.markdown-body table tr {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
background-color: var(--v-theme-surface);
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
background-color: var(--v-theme-background);
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -299,4 +313,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-dialog v-model="visible" persistent max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>正在等待 AstrBot 重启...</v-card-title>
|
||||
<v-card-title>{{ t('core.common.restart.waiting') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-progress-linear indeterminate color="primary"></v-progress-linear>
|
||||
</v-card-text>
|
||||
@@ -11,12 +11,16 @@
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
|
||||
export default {
|
||||
name: 'WaitingForRestart',
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
return { t };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
@@ -47,7 +51,7 @@ export default {
|
||||
}, 1000)
|
||||
} else {
|
||||
if (this.cnt == 10) {
|
||||
this.status = '拉取状态达到最大次数,请手动检查。'
|
||||
this.status = this.t('core.common.restart.maxRetriesReached')
|
||||
}
|
||||
this.cnt = 0
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -3,14 +3,25 @@ export type ConfigProps = {
|
||||
Customizer_drawer: boolean;
|
||||
mini_sidebar: boolean;
|
||||
fontTheme: string;
|
||||
uiTheme: string;
|
||||
inputBg: boolean;
|
||||
};
|
||||
|
||||
function checkUITheme() {
|
||||
/* 检查localStorage有无记忆的主题选项,如有则使用,否则使用默认值 */
|
||||
const theme = localStorage.getItem("uiTheme");
|
||||
if (!theme || !(['PurpleTheme', 'PurpleThemeDark'].includes(theme))) {
|
||||
localStorage.setItem("uiTheme", "PurpleTheme"); // todo: 这部分可以根据vuetify.ts的默认主题动态调整
|
||||
return 'PurpleTheme';
|
||||
} else return theme;
|
||||
}
|
||||
|
||||
const config: ConfigProps = {
|
||||
Sidebar_drawer: true,
|
||||
Customizer_drawer: false,
|
||||
mini_sidebar: false,
|
||||
fontTheme: 'Roboto',
|
||||
uiTheme: checkUITheme(),
|
||||
inputBg: false
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { translations as staticTranslations } from './translations';
|
||||
import type { Locale } from './types';
|
||||
|
||||
// 全局状态
|
||||
const currentLocale = ref<Locale>('zh-CN');
|
||||
const translations = ref<Record<string, any>>({});
|
||||
|
||||
/**
|
||||
* 初始化i18n系统
|
||||
*/
|
||||
export async function initI18n(locale: Locale = 'zh-CN') {
|
||||
currentLocale.value = locale;
|
||||
|
||||
// 加载静态翻译数据
|
||||
loadTranslations(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载翻译数据(现在从静态导入获取)
|
||||
*/
|
||||
function loadTranslations(locale: Locale) {
|
||||
try {
|
||||
const data = staticTranslations[locale];
|
||||
if (data) {
|
||||
translations.value = data;
|
||||
} else {
|
||||
console.warn(`Translations not found for locale: ${locale}`);
|
||||
// 回退到中文
|
||||
if (locale !== 'zh-CN') {
|
||||
console.log('Falling back to zh-CN');
|
||||
translations.value = staticTranslations['zh-CN'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load translations for ${locale}:`, error);
|
||||
// 回退到中文
|
||||
if (locale !== 'zh-CN') {
|
||||
console.log('Falling back to zh-CN');
|
||||
translations.value = staticTranslations['zh-CN'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主要的翻译函数组合
|
||||
*/
|
||||
export function useI18n() {
|
||||
// 翻译函数
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
const keys = key.split('.');
|
||||
let value: any = translations.value;
|
||||
|
||||
// 遍历键路径
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
// 返回带括号的键名,便于在开发时识别缺失的翻译
|
||||
return `[MISSING: ${key}]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation value is not string: ${key}`, value);
|
||||
// 返回带括号的键名,便于在开发时识别类型错误的翻译
|
||||
return `[INVALID: ${key}]`;
|
||||
}
|
||||
|
||||
// 此时value确定是string类型
|
||||
let result: string = value;
|
||||
|
||||
// 处理参数插值
|
||||
if (params) {
|
||||
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
|
||||
return params[paramKey]?.toString() || match;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 切换语言
|
||||
const setLocale = async (newLocale: Locale) => {
|
||||
if (newLocale !== currentLocale.value) {
|
||||
currentLocale.value = newLocale;
|
||||
loadTranslations(newLocale);
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('astrbot-locale', newLocale);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前语言
|
||||
const locale = computed(() => currentLocale.value);
|
||||
|
||||
// 获取可用语言列表
|
||||
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
|
||||
|
||||
// 检查是否已加载
|
||||
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
|
||||
|
||||
return {
|
||||
t,
|
||||
locale,
|
||||
setLocale,
|
||||
availableLocales,
|
||||
isLoaded
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块特定的翻译函数
|
||||
*/
|
||||
export function useModuleI18n(moduleName: string) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const tm = (key: string, params?: Record<string, string | number>): string => {
|
||||
// 将斜杠转换为点号以匹配嵌套对象结构
|
||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||
return t(`${normalizedModuleName}.${key}`, params);
|
||||
};
|
||||
|
||||
return { tm };
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言切换器组合函数
|
||||
*/
|
||||
export function useLanguageSwitcher() {
|
||||
const { locale, setLocale, availableLocales } = useI18n();
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
|
||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
|
||||
]);
|
||||
|
||||
const currentLanguage = computed(() => {
|
||||
return languageOptions.value.find(lang => lang.value === locale.value);
|
||||
});
|
||||
|
||||
const switchLanguage = async (newLocale: Locale) => {
|
||||
await setLocale(newLocale);
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
languageOptions,
|
||||
currentLanguage,
|
||||
switchLanguage,
|
||||
availableLocales
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化函数(在应用启动时调用)
|
||||
export async function setupI18n() {
|
||||
// 从localStorage获取保存的语言设置
|
||||
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
|
||||
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
|
||||
? savedLocale
|
||||
: 'zh-CN';
|
||||
|
||||
await initI18n(initialLocale);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Dynamic I18n Loader
|
||||
* 动态国际化加载器,支持按需加载和缓存机制
|
||||
*/
|
||||
|
||||
export interface LoaderCache {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ModuleInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
loaded: boolean;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class I18nLoader {
|
||||
private cache: Map<string, any> = new Map();
|
||||
private moduleRegistry: Map<string, ModuleInfo> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.registerModules();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册所有可用的翻译模块
|
||||
*/
|
||||
private registerModules(): void {
|
||||
const modules = [
|
||||
// 核心模块
|
||||
{ name: 'core/common', path: 'core/common.json' },
|
||||
{ name: 'core/actions', path: 'core/actions.json' },
|
||||
{ name: 'core/status', path: 'core/status.json' },
|
||||
{ name: 'core/navigation', path: 'core/navigation.json' },
|
||||
{ name: 'core/header', path: 'core/header.json' },
|
||||
|
||||
// 功能模块
|
||||
{ name: 'features/chat', path: 'features/chat.json' },
|
||||
{ name: 'features/extension', path: 'features/extension.json' },
|
||||
{ name: 'features/conversation', path: 'features/conversation.json' },
|
||||
{ name: 'features/tooluse', path: 'features/tool-use.json' },
|
||||
{ name: 'features/provider', path: 'features/provider.json' },
|
||||
{ name: 'features/platform', path: 'features/platform.json' },
|
||||
{ name: 'features/config', path: 'features/config.json' },
|
||||
{ name: 'features/console', path: 'features/console.json' },
|
||||
{ name: 'features/about', path: 'features/about.json' },
|
||||
{ name: 'features/settings', path: 'features/settings.json' },
|
||||
{ name: 'features/auth', path: 'features/auth.json' },
|
||||
{ name: 'features/chart', path: 'features/chart.json' },
|
||||
{ name: 'features/dashboard', path: 'features/dashboard.json' },
|
||||
{ name: 'features/alkaid/index', path: 'features/alkaid/index.json' },
|
||||
{ name: 'features/alkaid/knowledge-base', path: 'features/alkaid/knowledge-base.json' },
|
||||
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
|
||||
|
||||
// 消息模块
|
||||
{ name: 'messages/errors', path: 'messages/errors.json' },
|
||||
{ name: 'messages/success', path: 'messages/success.json' },
|
||||
{ name: 'messages/validation', path: 'messages/validation.json' }
|
||||
];
|
||||
|
||||
modules.forEach(module => {
|
||||
this.moduleRegistry.set(module.name, {
|
||||
name: module.name,
|
||||
path: module.path,
|
||||
loaded: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个模块
|
||||
*/
|
||||
async loadModule(locale: string, moduleName: string): Promise<any> {
|
||||
const cacheKey = `${locale}:${moduleName}`;
|
||||
|
||||
// 检查缓存
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
const moduleInfo = this.moduleRegistry.get(moduleName);
|
||||
if (!moduleInfo) {
|
||||
console.warn(`模块 ${moduleName} 未注册`);
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用动态import加载JSON文件,兼容构建和开发环境
|
||||
const modulePath = `../locales/${locale}/${moduleInfo.path}`;
|
||||
const module = await import(/* @vite-ignore */ modulePath);
|
||||
const data = module.default || module;
|
||||
|
||||
// 缓存结果
|
||||
this.cache.set(cacheKey, data);
|
||||
|
||||
// 更新模块信息
|
||||
moduleInfo.loaded = true;
|
||||
moduleInfo.data = data;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`加载模块 ${moduleName} 失败:`, error);
|
||||
|
||||
// 回退方案:尝试使用fetch(开发环境)
|
||||
try {
|
||||
const modulePath = `/src/i18n/locales/${locale}/${moduleInfo.path}`;
|
||||
const response = await fetch(modulePath);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 缓存结果
|
||||
this.cache.set(cacheKey, data);
|
||||
|
||||
// 更新模块信息
|
||||
moduleInfo.loaded = true;
|
||||
moduleInfo.data = data;
|
||||
|
||||
return data;
|
||||
} catch (fetchError) {
|
||||
console.error(`回退fetch加载也失败:`, fetchError);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用模块加载器 - 减少重复代码,提高可维护性
|
||||
*/
|
||||
private async loadModules(
|
||||
locale: string,
|
||||
prefix: string,
|
||||
overrideList: string[] = []
|
||||
): Promise<any> {
|
||||
// 使用覆盖列表或从注册表中筛选符合前缀的模块名
|
||||
const moduleNames = overrideList.length > 0
|
||||
? overrideList
|
||||
: Array.from(this.moduleRegistry.keys()).filter(key => key.startsWith(prefix));
|
||||
|
||||
const results = await Promise.all(
|
||||
moduleNames.map(module => this.loadModule(locale, module))
|
||||
);
|
||||
|
||||
return this.mergeModules(results, moduleNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载核心模块(最高优先级)
|
||||
*/
|
||||
async loadCoreModules(locale: string): Promise<any> {
|
||||
return this.loadModules(locale, 'core');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载功能模块
|
||||
*/
|
||||
async loadFeatureModules(locale: string, features?: string[]): Promise<any> {
|
||||
return this.loadModules(locale, 'features', features || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载消息模块
|
||||
*/
|
||||
async loadMessageModules(locale: string): Promise<any> {
|
||||
return this.loadModules(locale, 'messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载所有模块
|
||||
*/
|
||||
async loadAllModules(locale: string): Promise<any> {
|
||||
const [core, features, messages] = await Promise.all([
|
||||
this.loadCoreModules(locale),
|
||||
this.loadFeatureModules(locale),
|
||||
this.loadMessageModules(locale)
|
||||
]);
|
||||
|
||||
return {
|
||||
...core,
|
||||
...features,
|
||||
...messages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完整语言包(所有模块合并)
|
||||
*/
|
||||
async loadLocale(locale: string): Promise<any> {
|
||||
return this.loadAllModules(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个模块数据
|
||||
*/
|
||||
private mergeModules(modules: any[], moduleNames: string[]): any {
|
||||
const result: any = {};
|
||||
const pathRegistry = new Map<string, string>();
|
||||
|
||||
modules.forEach((module, index) => {
|
||||
const moduleName = moduleNames[index];
|
||||
const nameParts = moduleName.split('/');
|
||||
|
||||
// 构建嵌套对象结构(对所有模块统一处理)
|
||||
let current = result;
|
||||
for (let i = 0; i < nameParts.length - 1; i++) {
|
||||
if (!current[nameParts[i]]) {
|
||||
current[nameParts[i]] = {};
|
||||
}
|
||||
current = current[nameParts[i]];
|
||||
}
|
||||
|
||||
// 冲突检测:检查最终键是否已存在
|
||||
const finalKey = nameParts[nameParts.length - 1];
|
||||
const fullPath = nameParts.join('.');
|
||||
|
||||
if (current[finalKey] && pathRegistry.has(fullPath)) {
|
||||
const existingModule = pathRegistry.get(fullPath);
|
||||
console.warn(`⚠️ I18n模块路径冲突: "${fullPath}" 已被模块 "${existingModule}" 占用,模块 "${moduleName}" 可能会覆盖部分键值`);
|
||||
}
|
||||
|
||||
// 记录路径和模块名的映射
|
||||
pathRegistry.set(fullPath, moduleName);
|
||||
|
||||
// 设置最终值(保持原有的浅合并行为)
|
||||
current[finalKey] = { ...current[finalKey], ...module };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载关键模块
|
||||
*/
|
||||
async preloadEssentials(locale: string): Promise<void> {
|
||||
const essentials = [
|
||||
'core/common',
|
||||
'core/navigation',
|
||||
'features/chat'
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
essentials.map(module => this.loadModule(locale, module))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存
|
||||
*/
|
||||
clearCache(locale?: string): void {
|
||||
if (locale) {
|
||||
// 清理特定语言的缓存
|
||||
const keys = Array.from(this.cache.keys()).filter((key: string) => key.startsWith(`${locale}:`));
|
||||
keys.forEach((key: string) => this.cache.delete(key));
|
||||
} else {
|
||||
// 清理所有缓存
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加载状态
|
||||
*/
|
||||
getLoadingStatus(): { total: number; loaded: number; modules: ModuleInfo[] } {
|
||||
const modules = Array.from(this.moduleRegistry.values());
|
||||
const loaded = modules.filter(m => m.loaded).length;
|
||||
|
||||
return {
|
||||
total: modules.length,
|
||||
loaded,
|
||||
modules
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 热重载模块
|
||||
*/
|
||||
async reloadModule(locale: string, moduleName: string): Promise<any> {
|
||||
const cacheKey = `${locale}:${moduleName}`;
|
||||
this.cache.delete(cacheKey);
|
||||
|
||||
const moduleInfo = this.moduleRegistry.get(moduleName);
|
||||
if (moduleInfo) {
|
||||
moduleInfo.loaded = false;
|
||||
}
|
||||
|
||||
return this.loadModule(locale, moduleName);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"create": "Create",
|
||||
"read": "Read",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"backup": "Backup",
|
||||
"restore": "Restore",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"cut": "Cut",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"refresh": "Refresh",
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"clear": "Clear"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading...",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"author": "Author",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"reload": "Reload",
|
||||
"configure": "Configure",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"update": "Update",
|
||||
"language": "Language",
|
||||
"locale": "en-US",
|
||||
"type": "Type",
|
||||
"press": "Press",
|
||||
"longPress": "Long press",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"dialog": {
|
||||
"confirmTitle": "Confirm Action",
|
||||
"confirmMessage": "Are you sure you want to perform this action?",
|
||||
"confirmButton": "Confirm",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"restart": {
|
||||
"waiting": "Waiting for AstrBot to restart...",
|
||||
"maxRetriesReached": "Maximum retry attempts reached, please check manually."
|
||||
},
|
||||
"readme": {
|
||||
"title": "Extension Documentation",
|
||||
"buttons": {
|
||||
"viewOnGithub": "View Repository on GitHub",
|
||||
"refresh": "Refresh Documentation"
|
||||
},
|
||||
"loading": "Loading README documentation...",
|
||||
"errors": {
|
||||
"fetchFailed": "Failed to fetch README",
|
||||
"fetchError": "Error occurred while fetching README"
|
||||
},
|
||||
"empty": {
|
||||
"title": "This extension does not provide documentation link or GitHub repository address.",
|
||||
"subtitle": "Please check the extension marketplace or contact the extension author for more information."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"fullscreen": "Fullscreen Edit",
|
||||
"editingTitle": "Editing Content"
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "Add new item, press Enter to confirm",
|
||||
"addButton": "Add"
|
||||
},
|
||||
"itemCard": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"noData": "No data available"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"logoTitle": "AstrBot Dashboard",
|
||||
"version": {
|
||||
"hasNewVersion": "AstrBot has a new version!",
|
||||
"dashboardHasNewVersion": "WebUI has a new version!"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Update",
|
||||
"account": "Account",
|
||||
"theme": {
|
||||
"light": "Light Mode",
|
||||
"dark": "Dark Mode"
|
||||
}
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Update AstrBot",
|
||||
"currentVersion": "Current Version",
|
||||
"status": {
|
||||
"checking": "Checking for updates...",
|
||||
"switching": "Switching version...",
|
||||
"updating": "Updating..."
|
||||
},
|
||||
"tabs": {
|
||||
"release": "😊 Release",
|
||||
"dev": "🧐 Development (master branch)"
|
||||
},
|
||||
"updateToLatest": "Update to Latest Version",
|
||||
"tip": "💡 TIP: Switching to an older version or a specific version will not re-download the dashboard files, which may cause some data display errors. You can find the corresponding dashboard files dist.zip at",
|
||||
"tipLink": "here",
|
||||
"tipContinue": ", extract and replace the data/dist folder. Of course, the frontend source code is in the dashboard directory, you can also build it yourself using npm install and npm build.",
|
||||
"dockerTip": "The `Update to Latest Version` button will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use",
|
||||
"dockerTipLink": "watchtower",
|
||||
"dockerTipContinue": "to automatically monitor and pull.",
|
||||
"table": {
|
||||
"tag": "Tag",
|
||||
"publishDate": "Publish Date",
|
||||
"content": "Content",
|
||||
"sourceUrl": "Source URL",
|
||||
"actions": "Actions",
|
||||
"sha": "SHA",
|
||||
"date": "Date",
|
||||
"message": "Message",
|
||||
"view": "View",
|
||||
"switch": "Switch"
|
||||
},
|
||||
"manualInput": {
|
||||
"title": "Manual Input Version or Commit SHA",
|
||||
"placeholder": "Enter version number or commit hash from master branch.",
|
||||
"hint": "e.g. v3.3.16 (without SHA) or 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b",
|
||||
"linkText": "View master branch commit history (click copy on the right to copy)",
|
||||
"confirm": "Confirm Switch"
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Update Dashboard to Latest Version Only",
|
||||
"currentVersion": "Current Version",
|
||||
"hasNewVersion": "New version available!",
|
||||
"isLatest": "Already the latest version.",
|
||||
"downloadAndUpdate": "Download and Update"
|
||||
}
|
||||
},
|
||||
"accountDialog": {
|
||||
"title": "Modify Account",
|
||||
"securityWarning": "Security Reminder: Please change the default password to ensure account security",
|
||||
"form": {
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"newUsername": "New Username (Optional)",
|
||||
"passwordHint": "Password must be at least 8 characters",
|
||||
"usernameHint": "Leave blank to keep current username",
|
||||
"defaultCredentials": "Default username and password are both astrbot"
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Please enter password",
|
||||
"passwordMinLength": "Password must be at least 8 characters",
|
||||
"usernameMinLength": "Username must be at least 3 characters"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save Changes",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"messages": {
|
||||
"updateFailed": "Update failed, please try again"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"dashboard": "Dashboard",
|
||||
"platforms": "Platforms",
|
||||
"providers": "Providers",
|
||||
"toolUse": "MCP Tools",
|
||||
"config": "Config",
|
||||
"extension": "Extensions",
|
||||
"extensionMarketplace": "Extension Market",
|
||||
"chat": "Chat",
|
||||
"conversation": "Conversations",
|
||||
"console": "Console",
|
||||
"alkaid": "Alkaid Lab",
|
||||
"about": "About",
|
||||
"settings": "Settings",
|
||||
"documentation": "Documentation",
|
||||
"github": "GitHub",
|
||||
"drag": "Drag"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"loading": "Loading",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"pending": "Pending",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"timeout": "Timeout",
|
||||
"connecting": "Connecting",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"ready": "Ready",
|
||||
"busy": "Busy"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hero": {
|
||||
"title": "AstrBot",
|
||||
"subtitle": "A project out of interests and loves ❤️",
|
||||
"starButton": "Star this project! 🌟",
|
||||
"issueButton": "Submit Issue"
|
||||
},
|
||||
"contributors": {
|
||||
"title": "Contributors",
|
||||
"description": "This project is maintained by many open source community members. Thanks to every contributor for their dedication!",
|
||||
"viewLink": "View AstrBot Contributors"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Global Deployment",
|
||||
"license": "AstrBot is open source under AGPL v3 license"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Alkaid Laboratory",
|
||||
"subtitle": "Explore cutting-edge AI features",
|
||||
"comingSoon": "The world ahead, let's explore it later!",
|
||||
"page": {
|
||||
"title": "The Alkaid Project.",
|
||||
"subtitle": "AstrBot Alpha Project",
|
||||
"navigation": {
|
||||
"knowledgeBase": "Knowledge Base",
|
||||
"longTermMemory": "Long-term Memory",
|
||||
"other": "..."
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"knowledgeBase": "Knowledge Base",
|
||||
"longTermMemory": "Long-term Memory",
|
||||
"advancedChat": "Advanced Chat",
|
||||
"multiModal": "Multi-modal Interaction"
|
||||
},
|
||||
"status": {
|
||||
"experimental": "Experimental",
|
||||
"beta": "Beta",
|
||||
"stable": "Stable",
|
||||
"deprecated": "Deprecated"
|
||||
},
|
||||
"sigma": {
|
||||
"subtitle": "AstrBot Experimental Project",
|
||||
"visualization": "Visualization",
|
||||
"filterUserId": "Filter User ID",
|
||||
"filter": "Filter",
|
||||
"resetFilter": "Reset Filter",
|
||||
"refreshGraph": "Refresh Graph",
|
||||
"nodeDetails": "Node Details",
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"name": "Name",
|
||||
"userId": "User ID",
|
||||
"timestamp": "Timestamp",
|
||||
"graphStats": "Graph Statistics",
|
||||
"nodeCount": "Node Count",
|
||||
"edgeCount": "Edge Count",
|
||||
"inDevelopment": "Under Development"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"title": "Knowledge Base",
|
||||
"subtitle": "Manage and query knowledge base content",
|
||||
"upload": {
|
||||
"title": "Upload Documents",
|
||||
"selectFiles": "Select Files",
|
||||
"supportedFormats": "Supported Formats",
|
||||
"dragDrop": "Drag files here",
|
||||
"processing": "Processing...",
|
||||
"success": "Upload Successful",
|
||||
"error": "Upload Failed"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search knowledge base...",
|
||||
"results": "Search Results",
|
||||
"noResults": "No relevant content found",
|
||||
"searching": "Searching..."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Document List",
|
||||
"name": "Document Name",
|
||||
"size": "Size",
|
||||
"uploadTime": "Upload Time",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"management": {
|
||||
"delete": "Delete",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"reindex": "Reindex"
|
||||
},
|
||||
"notInstalled": {
|
||||
"title": "Knowledge base plugin is not installed yet",
|
||||
"install": "Install now"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No knowledge base yet, create one now! 🙂",
|
||||
"create": "Create Knowledge Base"
|
||||
},
|
||||
"list": {
|
||||
"title": "Knowledge Base List",
|
||||
"create": "Create Knowledge Base",
|
||||
"config": "Configure",
|
||||
"knowledgeCount": "knowledge items",
|
||||
"tips": "Tips: Learn how to use through /kb command in chat page!"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Create New Knowledge Base",
|
||||
"nameLabel": "Knowledge Base Name",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Brief description of the knowledge base...",
|
||||
"embeddingModelLabel": "Embedding Model",
|
||||
"providerInfo": "Provider ID: {id} | Embedding Model Dimensions: {dimensions}",
|
||||
"tips": "Tips: Once you choose an embedding model for a knowledge base, please do not modify the provider's model or vector dimension information, otherwise it will seriously affect the recall rate of the knowledge base or even cause errors.",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create"
|
||||
},
|
||||
"emojiPicker": {
|
||||
"title": "Select Emoji",
|
||||
"close": "Close",
|
||||
"categories": {
|
||||
"emotions": "Smileys and Emotions",
|
||||
"animals": "Animals and Nature",
|
||||
"food": "Food and Drink",
|
||||
"activities": "Activities and Objects",
|
||||
"travel": "Travel and Places",
|
||||
"symbols": "Symbols and Flags"
|
||||
}
|
||||
},
|
||||
"contentDialog": {
|
||||
"title": "Knowledge Base Management",
|
||||
"embeddingModel": "Embedding Model",
|
||||
"vectorDimension": "Vector Dimension",
|
||||
"usage": "Usage: Enter \"/kb use {name}\" in the chat page",
|
||||
"tabs": {
|
||||
"upload": "Upload Files",
|
||||
"search": "Search Content"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload Files to Knowledge Base",
|
||||
"subtitle": "Supports txt, pdf, word, excel and other formats",
|
||||
"dropzone": "Drag and drop files here or click to upload",
|
||||
"chunkSettings": {
|
||||
"title": "Chunk Settings",
|
||||
"tooltip": "Chunk size determines the size of each text block, overlap length determines the overlap between adjacent text blocks.\nSmaller chunks are more precise but increase quantity, appropriate overlap can improve retrieval accuracy.",
|
||||
"chunkSizeLabel": "Chunk Size",
|
||||
"chunkSizeHint": "Control the size of each text block, leave empty to use default value",
|
||||
"overlapLabel": "Overlap Length",
|
||||
"overlapHint": "Control the overlap between adjacent text blocks, leave empty to use default value"
|
||||
},
|
||||
"upload": "Upload File",
|
||||
"uploading": "Uploading..."
|
||||
},
|
||||
"search": {
|
||||
"queryLabel": "Search Knowledge Base Content",
|
||||
"queryPlaceholder": "Enter keywords to search knowledge base content...",
|
||||
"resultCountLabel": "Result Count",
|
||||
"searching": "Searching...",
|
||||
"resultsTitle": "Search Results",
|
||||
"relevance": "Relevance",
|
||||
"noResults": "No matching content found"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Confirm Delete",
|
||||
"confirmText": "Are you sure you want to delete knowledge base {name}?",
|
||||
"warning": "This operation is irreversible, all knowledge base content will be permanently deleted.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"messages": {
|
||||
"pluginNotAvailable": "Plugin not installed or unavailable",
|
||||
"checkPluginFailed": "Failed to check plugin",
|
||||
"installFailed": "Installation failed",
|
||||
"installPluginFailed": "Failed to install plugin",
|
||||
"getKnowledgeBaseListFailed": "Failed to get knowledge base list",
|
||||
"knowledgeBaseCreated": "Knowledge base created successfully",
|
||||
"createFailed": "Creation failed",
|
||||
"createKnowledgeBaseFailed": "Failed to create knowledge base",
|
||||
"pleaseEnterKnowledgeBaseName": "Please enter knowledge base name",
|
||||
"pleaseSelectFile": "Please select a file first",
|
||||
"operationSuccess": "Operation successful: {message}",
|
||||
"uploadFailed": "Upload failed",
|
||||
"fileUploadFailed": "File upload failed",
|
||||
"pleaseEnterSearchContent": "Please enter search content",
|
||||
"noMatchingContent": "No matching content found",
|
||||
"searchFailed": "Search failed",
|
||||
"searchKnowledgeBaseFailed": "Failed to search knowledge base",
|
||||
"deleteTargetNotExists": "Delete target does not exist",
|
||||
"knowledgeBaseDeleted": "Knowledge base deleted successfully",
|
||||
"deleteFailed": "Deletion failed",
|
||||
"deleteKnowledgeBaseFailed": "Failed to delete knowledge base",
|
||||
"getEmbeddingModelListFailed": "Failed to get embedding model list"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "Long-term Memory",
|
||||
"subtitle": "AI assistant's long-term memory management",
|
||||
"memories": {
|
||||
"title": "Memory List",
|
||||
"content": "Memory Content",
|
||||
"importance": "Importance Level",
|
||||
"createTime": "Create Time",
|
||||
"lastAccess": "Last Access",
|
||||
"category": "Category"
|
||||
},
|
||||
"categories": {
|
||||
"personal": "Personal Information",
|
||||
"preferences": "Preference Settings",
|
||||
"conversations": "Conversation History",
|
||||
"facts": "Factual Information",
|
||||
"skills": "Skill Knowledge"
|
||||
},
|
||||
"importance": {
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View Details",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"category": "By Category",
|
||||
"importance": "By Importance",
|
||||
"dateRange": "By Date Range",
|
||||
"title": "Filters",
|
||||
"userIdLabel": "Filter by User ID",
|
||||
"filterButton": "Filter",
|
||||
"resetButton": "Reset Filter",
|
||||
"refreshButton": "Refresh Graph"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search Memory",
|
||||
"userIdLabel": "User ID",
|
||||
"queryLabel": "Enter keywords",
|
||||
"searchButton": "Search",
|
||||
"resultsTitle": "Search Results",
|
||||
"noResults": "No relevant memory content found",
|
||||
"similarity": "Relevance",
|
||||
"noTextContent": "No text content"
|
||||
},
|
||||
"addMemory": {
|
||||
"title": "Add Memory Data",
|
||||
"textLabel": "Enter text content",
|
||||
"userIdLabel": "User ID",
|
||||
"summarizeLabel": "Need summary",
|
||||
"addButton": "Add Data"
|
||||
},
|
||||
"nodeDetails": {
|
||||
"title": "Node Details",
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"name": "Name",
|
||||
"userId": "User ID",
|
||||
"timestamp": "Timestamp"
|
||||
},
|
||||
"graphStats": {
|
||||
"title": "Graph Statistics",
|
||||
"nodeCount": "Node Count",
|
||||
"edgeCount": "Edge Count"
|
||||
},
|
||||
"factDialog": {
|
||||
"title": "Memory Fact",
|
||||
"id": "ID",
|
||||
"docId": "Document ID",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At",
|
||||
"metadata": "Metadata",
|
||||
"metadataKey": "Key",
|
||||
"metadataValue": "Value",
|
||||
"loading": "Loading...",
|
||||
"close": "Close",
|
||||
"noValue": "None",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"messages": {
|
||||
"searchQueryRequired": "Please enter search keywords",
|
||||
"searchSuccess": "Found {count} relevant memories",
|
||||
"searchNoResults": "No relevant memory content found",
|
||||
"searchError": "Search failed",
|
||||
"addSuccess": "Memory data added successfully!",
|
||||
"addError": "Failed to add memory data",
|
||||
"factDetailsError": "Failed to get memory details",
|
||||
"metadataParseError": "Unable to parse metadata",
|
||||
"relationNoMemoryData": "This relation has no associated memory data"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"login": "Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"logo": {
|
||||
"title": "AstrBot Dashboard",
|
||||
"subtitle": "Welcome"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "Switch to Dark Theme",
|
||||
"switchToLight": "Switch to Light Theme"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"messageCount": "Message Count",
|
||||
"time": "Time"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"title": "Let's Chat!",
|
||||
"subtitle": "Chat with AI Assistant",
|
||||
"input": {
|
||||
"placeholder": "Start typing...",
|
||||
"send": "Send",
|
||||
"clear": "Clear",
|
||||
"upload": "Upload File",
|
||||
"voice": "Voice Input",
|
||||
"recordingPrompt": "Recording, please speak...",
|
||||
"chatPrompt": "Let's chat!"
|
||||
},
|
||||
"message": {
|
||||
"user": "User",
|
||||
"assistant": "Assistant",
|
||||
"system": "System",
|
||||
"error": "Error Message",
|
||||
"loading": "Thinking..."
|
||||
},
|
||||
"voice": {
|
||||
"start": "Start Recording",
|
||||
"stop": "Stop Recording",
|
||||
"recording": "New Recording",
|
||||
"processing": "Processing...",
|
||||
"error": "Recording Failed"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to AstrBot",
|
||||
"subtitle": "Your Intelligent Chat Assistant",
|
||||
"quickActions": "Quick Actions",
|
||||
"examples": "Example Questions"
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copy",
|
||||
"regenerate": "Regenerate",
|
||||
"like": "Like",
|
||||
"dislike": "Dislike",
|
||||
"share": "Share",
|
||||
"newChat": "New Chat",
|
||||
"deleteChat": "Delete this conversation",
|
||||
"editTitle": "Edit Title",
|
||||
"fullscreen": "Fullscreen Mode",
|
||||
"exitFullscreen": "Exit Fullscreen"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "New Conversation",
|
||||
"noHistory": "No conversation history",
|
||||
"systemStatus": "System Status",
|
||||
"llmService": "LLM Service",
|
||||
"speechToText": "Speech to Text"
|
||||
},
|
||||
"modes": {
|
||||
"darkMode": "Switch to Dark Mode",
|
||||
"lightMode": "Switch to Light Mode"
|
||||
}, "shortcuts": {
|
||||
"help": "Get Help",
|
||||
"voiceRecord": "Record Voice",
|
||||
"pasteImage": "Paste Image"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection Status Notice",
|
||||
"message": "The system detected that the chat connection needs to be re-established.",
|
||||
"reasons": "This may be due to:",
|
||||
"reasonWindowResize": "Switching chat window size (normal behavior)",
|
||||
"reasonMultipleTabs": "Opening chat pages in other tabs",
|
||||
"reasonNetworkIssue": "Temporary network interruption",
|
||||
"notice": "Note: To ensure proper message delivery, the system only allows one active chat connection at a time. If you're using chat in multiple tabs, please keep only one page open.",
|
||||
"understand": "Got it",
|
||||
"status": {
|
||||
"reconnecting": "Reconnecting...",
|
||||
"reconnected": "Chat connection re-established",
|
||||
"failed": "Connection failed, please refresh the page"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"title": "Configuration",
|
||||
"subtitle": "Manage system configuration and settings",
|
||||
"editor": {
|
||||
"visual": "Visual Editor",
|
||||
"code": "Code Editor",
|
||||
"revertCode": "Revert to Previous Code",
|
||||
"applyConfig": "Apply This Configuration",
|
||||
"applyTip": "`Apply This Configuration` will stage and apply the configuration to the visual editor. To save, you need to click the save button in the bottom right corner."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save Configuration",
|
||||
"delete": "Delete This Item",
|
||||
"add": "Add",
|
||||
"reset": "Reset to Default",
|
||||
"export": "Export Configuration",
|
||||
"import": "Import Configuration",
|
||||
"validate": "Validate Configuration"
|
||||
},
|
||||
"help": {
|
||||
"documentation": "Official Documentation",
|
||||
"support": "Join Group for Help",
|
||||
"helpText": "Don't understand the configuration? Please see {documentation} or {support}.",
|
||||
"helpPrefix": "Don't understand the configuration? Please see",
|
||||
"helpMiddle": "or",
|
||||
"helpSuffix": "."
|
||||
},
|
||||
"messages": {
|
||||
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
|
||||
"configApplyError": "Configuration not applied, JSON format error.",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveError": "Failed to save configuration",
|
||||
"loadError": "Failed to load configuration"
|
||||
},
|
||||
"sections": {
|
||||
"general": "General Settings",
|
||||
"advanced": "Advanced Settings",
|
||||
"security": "Security Settings",
|
||||
"appearance": "Appearance Settings",
|
||||
"notification": "Notification Settings"
|
||||
},
|
||||
"general": {
|
||||
"botName": "Bot Name",
|
||||
"language": "Interface Language",
|
||||
"timezone": "Timezone",
|
||||
"autoSave": "Auto Save",
|
||||
"debugMode": "Debug Mode"
|
||||
},
|
||||
"advanced": {
|
||||
"logLevel": "Log Level",
|
||||
"maxConnections": "Max Connections",
|
||||
"timeout": "Timeout",
|
||||
"retryAttempts": "Retry Attempts",
|
||||
"cacheSize": "Cache Size"
|
||||
},
|
||||
"security": {
|
||||
"apiKey": "API Key",
|
||||
"allowedHosts": "Allowed Hosts",
|
||||
"rateLimit": "Rate Limit",
|
||||
"encryption": "Encryption Settings"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "Console",
|
||||
"autoScroll": {
|
||||
"enabled": "Auto-scroll enabled",
|
||||
"disabled": "Auto-scroll disabled"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "Install pip Package",
|
||||
"dialogTitle": "Install Pip Package",
|
||||
"packageLabel": "*Package name, e.g. llmtuner",
|
||||
"mirrorLabel": "Force PyPI repository URL (optional)",
|
||||
"mirrorHint": "Force PyPI repository URL > Config item `PyPI Repository Address`",
|
||||
"installButton": "Install"
|
||||
}
|
||||
}
|
||||